Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat(sheets): conditional formatting color scales' (#107) from feat/cf-color-scales into main

scott 69479fee 77ff3844

+263 -5
+101 -1
src/sheets/conditional-format.ts
··· 105 105 /** 106 106 * Safely convert a value to a number, returning null if not numeric. 107 107 */ 108 - function toNumber(v: unknown): number | null { 108 + export function toNumber(v: unknown): number | null { 109 109 if (v === null || v === undefined || v === '') return null; 110 110 if (typeof v === 'number') return v; 111 111 const n = Number(v); 112 112 return isNaN(n) ? null : n; 113 113 } 114 + 115 + // ============================================================ 116 + // Color Scale (#120) 117 + // ============================================================ 118 + 119 + /** Parse a hex color (#rrggbb) to [r, g, b]. */ 120 + export function parseHex(hex: string): [number, number, number] { 121 + const h = hex.replace('#', ''); 122 + return [ 123 + parseInt(h.substring(0, 2), 16), 124 + parseInt(h.substring(2, 4), 16), 125 + parseInt(h.substring(4, 6), 16), 126 + ]; 127 + } 128 + 129 + /** Convert [r, g, b] to #rrggbb. */ 130 + export function toHex(rgb: [number, number, number]): string { 131 + return '#' + rgb.map(c => Math.round(c).toString(16).padStart(2, '0')).join(''); 132 + } 133 + 134 + /** Linearly interpolate between two RGB colors. t in [0, 1]. */ 135 + export function lerpColor( 136 + a: [number, number, number], 137 + b: [number, number, number], 138 + t: number, 139 + ): [number, number, number] { 140 + return [ 141 + a[0] + (b[0] - a[0]) * t, 142 + a[1] + (b[1] - a[1]) * t, 143 + a[2] + (b[2] - a[2]) * t, 144 + ]; 145 + } 146 + 147 + /** 148 + * Compute a color scale background for a numeric value given the 149 + * min/max of the data range and a color scale rule. 150 + * 151 + * Supports 2-color (minColor→maxColor) and 3-color (minColor→midColor→maxColor) scales. 152 + * Default colors match Excel: red → yellow → green. 153 + */ 154 + export function colorScaleBg( 155 + value: unknown, 156 + min: number, 157 + max: number, 158 + rule: CfRule, 159 + ): string | null { 160 + const num = toNumber(value); 161 + if (num === null) return null; 162 + if (min === max) return toHex(parseHex(rule.midColor || rule.minColor || '#ffeb84')); 163 + 164 + const t = Math.max(0, Math.min(1, (num - min) / (max - min))); 165 + 166 + const lo = parseHex(rule.minColor || '#f8696b'); 167 + const hi = parseHex(rule.maxColor || '#63be7b'); 168 + 169 + if (rule.midColor) { 170 + const mid = parseHex(rule.midColor); 171 + if (t <= 0.5) { 172 + return toHex(lerpColor(lo, mid, t * 2)); 173 + } 174 + return toHex(lerpColor(mid, hi, (t - 0.5) * 2)); 175 + } 176 + 177 + return toHex(lerpColor(lo, hi, t)); 178 + } 179 + 180 + /** 181 + * Given a map of cellId→numericValue and a colorScale rule, 182 + * compute per-cell background color styles. 183 + * 184 + * Returns a Map<string, CfStyleResult> keyed by cell ID. 185 + */ 186 + export function computeColorScale( 187 + cellValues: Map<string, unknown>, 188 + rule: CfRule, 189 + ): Map<string, CfStyleResult> { 190 + const result = new Map<string, CfStyleResult>(); 191 + const nums: { id: string; val: number }[] = []; 192 + 193 + for (const [id, v] of cellValues) { 194 + const n = toNumber(v); 195 + if (n !== null) nums.push({ id, val: n }); 196 + } 197 + 198 + if (nums.length === 0) return result; 199 + 200 + let min = nums[0].val; 201 + let max = nums[0].val; 202 + for (const { val } of nums) { 203 + if (val < min) min = val; 204 + if (val > max) max = val; 205 + } 206 + 207 + for (const { id, val } of nums) { 208 + const bg = colorScaleBg(val, min, max, rule); 209 + if (bg) result.set(id, { bgColor: bg }); 210 + } 211 + 212 + return result; 213 + }
+20 -2
src/sheets/main.ts
··· 17 17 import { validateChartConfig, parseDataRange, extractChartData, transformChartData, buildChartJsConfig, CHART_TYPES, CHART_COLORS } from './charts.js'; 18 18 import { getUniqueColumnValues, applyFilters, clearColumnFilter, clearAllFilters, buildFilterState } from './filter.js'; 19 19 import { multiColumnSort } from './sort.js'; 20 - import { evaluateRules, buildCfStyle } from './conditional-format.js'; 20 + import { evaluateRules, buildCfStyle, computeColorScale } from './conditional-format.js'; 21 21 import { isErrorValue, getErrorInfo, formatErrorTooltip } from './error-tooltips.js'; 22 22 import { validateCell, getDropdownItems, parseListItems } from './data-validation.js'; 23 23 import { buildBorderStyle, applyBorderPreset, getWrapStyle, getStripedRowClass } from './cell-styles.js'; ··· 458 458 const cfRules = getCfRulesArray(); 459 459 const stripedEnabled = getStripedRows(); 460 460 461 + // Pre-compute color scale styles (needs all cell values for min/max) 462 + const colorScaleRule = cfRules.find(r => r.type === 'colorScale'); 463 + let colorScaleStyles: Map<string, { bgColor?: string; textColor?: string }> | null = null; 464 + if (colorScaleRule) { 465 + const allCellValues = new Map<string, unknown>(); 466 + for (let r = 1; r <= rowCount; r++) { 467 + for (let c = 1; c <= colCount; c++) { 468 + const id = cellId(c, r); 469 + const cd = getCellData(id); 470 + if (cd) { 471 + const dv = computeDisplayValue(id, cd); 472 + allCellValues.set(id, dv); 473 + } 474 + } 475 + } 476 + colorScaleStyles = computeColorScale(allCellValues, colorScaleRule); 477 + } 478 + 461 479 // --- All rows rendered; browser handles scroll natively (no JS virtual scroll) --- 462 480 // Compute all visible rows (frozen + body), respecting hidden rows 463 481 const allRowsToRender: number[] = []; ··· 545 563 if (c <= freezeC) tdStyle += 'left:' + frozenLeftOffsets[c] + 'px;'; 546 564 547 565 // Conditional formatting (computed early so bg can go on td) 548 - const cfResult = evaluateRules(displayValue, cfRules); 566 + const cfResult = evaluateRules(displayValue, cfRules) || (colorScaleStyles && colorScaleStyles.get(id)) || null; 549 567 const cfStyleStr = buildCfStyle(cfResult); 550 568 551 569 // Background on td so inset box-shadow grid lines paint on top
+8 -1
src/sheets/types.ts
··· 181 181 | 'between' 182 182 | 'textContains' 183 183 | 'isEmpty' 184 - | 'isNotEmpty'; 184 + | 'isNotEmpty' 185 + | 'colorScale'; 185 186 186 187 export interface CfRule { 187 188 type: CfRuleType; ··· 190 191 bgColor?: string; 191 192 textColor?: string; 192 193 name?: string; 194 + /** Color scale: low-end color (default: #f8696b red) */ 195 + minColor?: string; 196 + /** Color scale: midpoint color (default: #ffeb84 yellow, omit for 2-color) */ 197 + midColor?: string; 198 + /** Color scale: high-end color (default: #63be7b green) */ 199 + maxColor?: string; 193 200 } 194 201 195 202 export interface CfStyleResult {
+134 -1
tests/conditional-format.test.ts
··· 3 3 * VSDD: Red phase — these tests define the spec. 4 4 */ 5 5 import { describe, it, expect } from 'vitest'; 6 - import { evaluateRule, evaluateRules, buildCfStyle } from '../src/sheets/conditional-format.js'; 6 + import { 7 + evaluateRule, evaluateRules, buildCfStyle, 8 + parseHex, toHex, lerpColor, colorScaleBg, computeColorScale, 9 + } from '../src/sheets/conditional-format.js'; 7 10 8 11 describe('evaluateRule', () => { 9 12 describe('greaterThan', () => { ··· 315 318 expect(buildCfStyle({})).toBe(''); 316 319 }); 317 320 }); 321 + 322 + // ============================================================ 323 + // Color Scale (#120) 324 + // ============================================================ 325 + 326 + describe('parseHex', () => { 327 + it('parses #ff0000 to [255, 0, 0]', () => { 328 + expect(parseHex('#ff0000')).toEqual([255, 0, 0]); 329 + }); 330 + 331 + it('parses without # prefix', () => { 332 + expect(parseHex('00ff00')).toEqual([0, 255, 0]); 333 + }); 334 + }); 335 + 336 + describe('toHex', () => { 337 + it('converts [255, 0, 0] to #ff0000', () => { 338 + expect(toHex([255, 0, 0])).toBe('#ff0000'); 339 + }); 340 + 341 + it('pads single-digit channels', () => { 342 + expect(toHex([0, 0, 0])).toBe('#000000'); 343 + }); 344 + }); 345 + 346 + describe('lerpColor', () => { 347 + it('returns start color at t=0', () => { 348 + expect(lerpColor([255, 0, 0], [0, 255, 0], 0)).toEqual([255, 0, 0]); 349 + }); 350 + 351 + it('returns end color at t=1', () => { 352 + expect(lerpColor([255, 0, 0], [0, 255, 0], 1)).toEqual([0, 255, 0]); 353 + }); 354 + 355 + it('returns midpoint at t=0.5', () => { 356 + const [r, g, b] = lerpColor([0, 0, 0], [200, 100, 50], 0.5); 357 + expect(r).toBeCloseTo(100); 358 + expect(g).toBeCloseTo(50); 359 + expect(b).toBeCloseTo(25); 360 + }); 361 + }); 362 + 363 + describe('colorScaleBg', () => { 364 + const rule = { type: 'colorScale' as const, minColor: '#ff0000', maxColor: '#00ff00' }; 365 + 366 + it('returns low color for minimum value', () => { 367 + expect(colorScaleBg(0, 0, 100, rule)).toBe('#ff0000'); 368 + }); 369 + 370 + it('returns high color for maximum value', () => { 371 + expect(colorScaleBg(100, 0, 100, rule)).toBe('#00ff00'); 372 + }); 373 + 374 + it('returns midpoint color for middle value', () => { 375 + const bg = colorScaleBg(50, 0, 100, rule); 376 + expect(bg).toBe('#808000'); // midpoint of red and green 377 + }); 378 + 379 + it('returns null for non-numeric value', () => { 380 + expect(colorScaleBg('hello', 0, 100, rule)).toBeNull(); 381 + }); 382 + 383 + it('handles min === max without dividing by zero', () => { 384 + const bg = colorScaleBg(5, 5, 5, rule); 385 + expect(bg).not.toBeNull(); 386 + }); 387 + 388 + it('supports 3-color scale with midColor', () => { 389 + const rule3 = { type: 'colorScale' as const, minColor: '#ff0000', midColor: '#ffff00', maxColor: '#00ff00' }; 390 + // At t=0 → red 391 + expect(colorScaleBg(0, 0, 100, rule3)).toBe('#ff0000'); 392 + // At t=0.5 → yellow 393 + expect(colorScaleBg(50, 0, 100, rule3)).toBe('#ffff00'); 394 + // At t=1 → green 395 + expect(colorScaleBg(100, 0, 100, rule3)).toBe('#00ff00'); 396 + }); 397 + 398 + it('clamps values outside min/max', () => { 399 + expect(colorScaleBg(-10, 0, 100, rule)).toBe('#ff0000'); 400 + expect(colorScaleBg(200, 0, 100, rule)).toBe('#00ff00'); 401 + }); 402 + }); 403 + 404 + describe('computeColorScale', () => { 405 + const rule = { type: 'colorScale' as const, minColor: '#ff0000', maxColor: '#00ff00' }; 406 + 407 + it('returns styles for all numeric cells', () => { 408 + const values = new Map<string, unknown>([ 409 + ['A1', 0], 410 + ['A2', 50], 411 + ['A3', 100], 412 + ]); 413 + const result = computeColorScale(values, rule); 414 + expect(result.size).toBe(3); 415 + expect(result.get('A1')!.bgColor).toBe('#ff0000'); 416 + expect(result.get('A3')!.bgColor).toBe('#00ff00'); 417 + }); 418 + 419 + it('skips non-numeric cells', () => { 420 + const values = new Map<string, unknown>([ 421 + ['A1', 10], 422 + ['A2', 'text'], 423 + ['A3', 20], 424 + ]); 425 + const result = computeColorScale(values, rule); 426 + expect(result.size).toBe(2); 427 + expect(result.has('A2')).toBe(false); 428 + }); 429 + 430 + it('returns empty map for no numeric values', () => { 431 + const values = new Map<string, unknown>([['A1', 'hello']]); 432 + const result = computeColorScale(values, rule); 433 + expect(result.size).toBe(0); 434 + }); 435 + 436 + it('handles single value', () => { 437 + const values = new Map<string, unknown>([['A1', 42]]); 438 + const result = computeColorScale(values, rule); 439 + expect(result.size).toBe(1); 440 + expect(result.get('A1')!.bgColor).not.toBeNull(); 441 + }); 442 + 443 + it('uses default Excel colors when not specified', () => { 444 + const defaultRule = { type: 'colorScale' as const }; 445 + const values = new Map<string, unknown>([['A1', 0], ['A2', 100]]); 446 + const result = computeColorScale(values, defaultRule); 447 + expect(result.get('A1')!.bgColor).toBe('#f8696b'); // Excel default red 448 + expect(result.get('A2')!.bgColor).toBe('#63be7b'); // Excel default green 449 + }); 450 + });