@opendata-ai/openchart-engine 6.15.1 → 6.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.15.1",
3
+ "version": "6.17.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "6.15.1",
48
+ "@opendata-ai/openchart-core": "6.17.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -127,7 +127,7 @@ describe('computeAxes', () => {
127
127
  const axesShort = computeAxes(scales, shortArea, fullStrategy, theme);
128
128
 
129
129
  // Even though the strategy says 'full', height < 120 forces minimal (3 ticks)
130
- expect(axesShort.y!.ticks.length).toBeLessThanOrEqual(3);
130
+ expect(axesShort.y!.ticks.length).toBeLessThanOrEqual(4);
131
131
  });
132
132
 
133
133
  it('reduces y-axis ticks for medium-short chart areas (120-200px)', () => {
@@ -150,7 +150,7 @@ describe('computeAxes', () => {
150
150
 
151
151
  // Strategy already says minimal - short height shouldn't change anything
152
152
  const axes = computeAxes(scales, shortArea, minimalStrategy, theme);
153
- expect(axes.y!.ticks.length).toBeLessThanOrEqual(3);
153
+ expect(axes.y!.ticks.length).toBeLessThanOrEqual(4);
154
154
  });
155
155
 
156
156
  // -------------------------------------------------------------------------
@@ -163,7 +163,7 @@ describe('computeAxes', () => {
163
163
  const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
164
164
 
165
165
  // Width < 150 forces minimal density for x-axis
166
- expect(axes.x!.ticks.length).toBeLessThanOrEqual(3);
166
+ expect(axes.x!.ticks.length).toBeLessThanOrEqual(4);
167
167
  });
168
168
 
169
169
  it('reduces x-axis ticks for medium-narrow chart areas (150-300px)', () => {
@@ -194,8 +194,8 @@ describe('computeAxes', () => {
194
194
  const axesFull = computeAxes(scalesFull, fullArea, fullStrategy, theme);
195
195
 
196
196
  // Both axes should have minimal ticks in a thumbnail
197
- expect(axesThumb.x!.ticks.length).toBeLessThanOrEqual(3);
198
- expect(axesThumb.y!.ticks.length).toBeLessThanOrEqual(3);
197
+ expect(axesThumb.x!.ticks.length).toBeLessThanOrEqual(4);
198
+ expect(axesThumb.y!.ticks.length).toBeLessThanOrEqual(4);
199
199
 
200
200
  // And fewer than full-size
201
201
  expect(axesThumb.x!.ticks.length).toBeLessThanOrEqual(axesFull.x!.ticks.length);
@@ -66,8 +66,8 @@ describe('compileChart', () => {
66
66
  // Annotations array exists (empty for this spec since none were specified)
67
67
  expect(layout.annotations).toEqual([]);
68
68
 
69
- // Legend has entries for the two series
70
- expect(layout.legend.entries.length).toBe(2);
69
+ // Legend is auto-suppressed for line charts with endpoint labels
70
+ expect(layout.legend.entries.length).toBe(0);
71
71
 
72
72
  // Tooltip descriptors is a Map (may or may not have entries depending on marks)
73
73
  expect(layout.tooltipDescriptors).toBeInstanceOf(Map);
@@ -147,8 +147,11 @@ describe('compileChart', () => {
147
147
  expect(layout.axes.y!.start.y).not.toBe(layout.axes.y!.end.y);
148
148
  });
149
149
 
150
- it('has a legend when color encoding is present', () => {
151
- const layout = compileChart(lineSpec, { width: 600, height: 400 });
150
+ it('has a legend when color encoding is present and legend forced on', () => {
151
+ const layout = compileChart(
152
+ { ...lineSpec, legend: { show: true } },
153
+ { width: 600, height: 400 },
154
+ );
152
155
  expect(layout.legend.entries.length).toBeGreaterThan(0);
153
156
  expect(layout.legend.entries.some((e) => e.label === 'US')).toBe(true);
154
157
  expect(layout.legend.entries.some((e) => e.label === 'UK')).toBe(true);
@@ -280,6 +283,7 @@ describe('compileChart', () => {
280
283
  const spec = {
281
284
  ...lineSpec,
282
285
  hiddenSeries: ['UK'],
286
+ legend: { show: true },
283
287
  };
284
288
  const layout = compileChart(spec, { width: 600, height: 400 });
285
289
 
@@ -583,6 +587,7 @@ describe('compileGraph', () => {
583
587
  it('applies breakpoint override for legend show', () => {
584
588
  const spec = {
585
589
  ...lineSpec,
590
+ legend: { show: true },
586
591
  overrides: {
587
592
  compact: {
588
593
  legend: { show: false },
@@ -22,7 +22,7 @@ const specWithColor: NormalizedChartSpec = {
22
22
  responsive: true,
23
23
  theme: {},
24
24
  darkMode: 'off',
25
- labels: { density: 'auto', format: '' },
25
+ labels: { density: 'none', format: '', prefix: '' },
26
26
  };
27
27
 
28
28
  const specWithoutColor: NormalizedChartSpec = {
@@ -243,6 +243,38 @@ describe('computeLegend', () => {
243
243
  expect(maxRowsVisible).toBeGreaterThan(defaultVisible);
244
244
  });
245
245
 
246
+ it('uses explicit domain+range colors in legend entries', () => {
247
+ const specExplicit: NormalizedChartSpec = {
248
+ ...specWithColor,
249
+ data: [
250
+ { date: '2020', value: 10, country: 'UK' },
251
+ { date: '2021', value: 20, country: 'US' },
252
+ { date: '2022', value: 30, country: 'Germany' },
253
+ ],
254
+ encoding: {
255
+ x: { field: 'date', type: 'temporal' },
256
+ y: { field: 'value', type: 'quantitative' },
257
+ color: {
258
+ field: 'country',
259
+ type: 'nominal',
260
+ scale: {
261
+ domain: ['US', 'UK', 'Germany'],
262
+ range: ['#ff0000', '#0000ff', '#00ff00'],
263
+ },
264
+ },
265
+ },
266
+ };
267
+ const legend = computeLegend(specExplicit, compactStrategy, theme, chartArea);
268
+ // Data order is UK, US, Germany but domain order is US, UK, Germany
269
+ // Legend should match colors to domain indices, not data order
270
+ const ukEntry = legend.entries.find((e) => e.label === 'UK')!;
271
+ const usEntry = legend.entries.find((e) => e.label === 'US')!;
272
+ const deEntry = legend.entries.find((e) => e.label === 'Germany')!;
273
+ expect(usEntry.color).toBe('#ff0000');
274
+ expect(ukEntry.color).toBe('#0000ff');
275
+ expect(deEntry.color).toBe('#00ff00');
276
+ });
277
+
246
278
  it('uses correct swatch shape for chart type', () => {
247
279
  const lineLegend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
248
280
  expect(lineLegend.entries[0].shape).toBe('line');
@@ -273,4 +305,92 @@ describe('computeLegend', () => {
273
305
  const scatterLegend = computeLegend(scatterSpec, fullStrategy, theme, chartArea);
274
306
  expect(scatterLegend.entries[0].shape).toBe('circle');
275
307
  });
308
+
309
+ describe('auto-suppression for line/area with endpoint labels', () => {
310
+ /** Line spec with labels enabled for suppression tests. */
311
+ const lineWithLabels: NormalizedChartSpec = {
312
+ ...specWithColor,
313
+ labels: { density: 'auto', format: '', prefix: '' },
314
+ };
315
+
316
+ it('suppresses legend for multi-series line chart with default labels', () => {
317
+ const legend = computeLegend(lineWithLabels, fullStrategy, theme, chartArea);
318
+ expect(legend.entries).toHaveLength(0);
319
+ });
320
+
321
+ it('preserves legend when legend.show is explicitly true', () => {
322
+ const spec: NormalizedChartSpec = {
323
+ ...lineWithLabels,
324
+ legend: { show: true },
325
+ hiddenSeries: [],
326
+ seriesStyles: {},
327
+ };
328
+ const legend = computeLegend(spec, fullStrategy, theme, chartArea);
329
+ expect(legend.entries).toHaveLength(3);
330
+ });
331
+
332
+ it('preserves legend when labels density is none', () => {
333
+ const spec: NormalizedChartSpec = {
334
+ ...lineWithLabels,
335
+ labels: { density: 'none', format: '', prefix: '' },
336
+ };
337
+ const legend = computeLegend(spec, fullStrategy, theme, chartArea);
338
+ expect(legend.entries).toHaveLength(3);
339
+ });
340
+
341
+ it('preserves legend at compact breakpoint where labelMode is none', () => {
342
+ const legend = computeLegend(lineWithLabels, compactStrategy, theme, chartArea);
343
+ expect(legend.entries).toHaveLength(3);
344
+ });
345
+
346
+ it('preserves legend for stacked area chart (default stacking)', () => {
347
+ const areaSpec: NormalizedChartSpec = {
348
+ ...lineWithLabels,
349
+ markType: 'area',
350
+ markDef: { type: 'area' },
351
+ };
352
+ const legend = computeLegend(areaSpec, fullStrategy, theme, chartArea);
353
+ expect(legend.entries).toHaveLength(3);
354
+ });
355
+
356
+ it('suppresses legend for unstacked area chart with labels', () => {
357
+ const areaSpec: NormalizedChartSpec = {
358
+ ...lineWithLabels,
359
+ markType: 'area',
360
+ markDef: { type: 'area' },
361
+ encoding: {
362
+ x: { field: 'date', type: 'temporal' },
363
+ y: { field: 'value', type: 'quantitative', stack: null },
364
+ color: { field: 'country', type: 'nominal' },
365
+ },
366
+ };
367
+ const legend = computeLegend(areaSpec, fullStrategy, theme, chartArea);
368
+ expect(legend.entries).toHaveLength(0);
369
+ });
370
+
371
+ it('preserves legend for bar chart (not line/area)', () => {
372
+ const barSpec: NormalizedChartSpec = {
373
+ ...lineWithLabels,
374
+ markType: 'bar',
375
+ markDef: { type: 'bar' },
376
+ encoding: {
377
+ x: { field: 'date', type: 'nominal' },
378
+ y: { field: 'value', type: 'quantitative' },
379
+ color: { field: 'country', type: 'nominal' },
380
+ },
381
+ };
382
+ const legend = computeLegend(barSpec, fullStrategy, theme, chartArea);
383
+ expect(legend.entries).toHaveLength(3);
384
+ });
385
+
386
+ it('does not suppress for single-series line (no color encoding)', () => {
387
+ const noColorWithLabels: NormalizedChartSpec = {
388
+ ...specWithoutColor,
389
+ labels: { density: 'auto', format: '', prefix: '' },
390
+ };
391
+ const legend = computeLegend(noColorWithLabels, fullStrategy, theme, chartArea);
392
+ expect(legend.entries).toHaveLength(0);
393
+ expect(legend.bounds.width).toBe(0);
394
+ });
395
+ });
276
396
  });
@@ -50,6 +50,7 @@ export function resolveTextAnnotation(
50
50
  if (px === null || py === null) return null;
51
51
 
52
52
  const defaultTextFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
53
+
53
54
  const labelStyle = makeAnnotationLabelStyle(
54
55
  annotation.fontSize,
55
56
  annotation.fontWeight,
package/src/compile.ts CHANGED
@@ -375,19 +375,28 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
375
375
  // Compute scales
376
376
  const scales = computeScales(renderSpec, chartArea, renderSpec.data);
377
377
 
378
- // Update color scale to use theme palette
378
+ // Update color scale to use theme palette (only when user hasn't provided an explicit range)
379
379
  if (scales.color) {
380
+ const hasExplicitRange = !!(
381
+ renderSpec.encoding.color &&
382
+ 'field' in renderSpec.encoding.color &&
383
+ (renderSpec.encoding.color.scale?.range as string[] | undefined)?.length
384
+ );
380
385
  if (scales.color.type === 'sequential') {
381
386
  // Sequential: use first sequential palette (or fall back to categorical endpoints)
382
- const seqStops = Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
383
- (scales.color.scale as unknown as import('d3-scale').ScaleLinear<string, string>).range([
384
- seqStops[0],
385
- seqStops[seqStops.length - 1],
386
- ]);
387
+ if (!hasExplicitRange) {
388
+ const seqStops = Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
389
+ (scales.color.scale as unknown as import('d3-scale').ScaleLinear<string, string>).range([
390
+ seqStops[0],
391
+ seqStops[seqStops.length - 1],
392
+ ]);
393
+ }
387
394
  } else {
388
- (scales.color.scale as import('d3-scale').ScaleOrdinal<string, string>).range(
389
- theme.colors.categorical,
390
- );
395
+ if (!hasExplicitRange) {
396
+ (scales.color.scale as import('d3-scale').ScaleOrdinal<string, string>).range(
397
+ theme.colors.categorical,
398
+ );
399
+ }
391
400
  }
392
401
  }
393
402
 
@@ -38,9 +38,9 @@ import type {
38
38
 
39
39
  /** Base tick counts by axis label density. */
40
40
  const TICK_COUNTS: Record<AxisLabelDensity, number> = {
41
- full: 10,
42
- reduced: 7,
43
- minimal: 3,
41
+ full: 12,
42
+ reduced: 8,
43
+ minimal: 4,
44
44
  };
45
45
 
46
46
  /**
@@ -185,7 +185,7 @@ export function computeDimensions(
185
185
  }
186
186
  }
187
187
  if (maxLabelWidth > 0) {
188
- margins.right = Math.max(margins.right, padding + maxLabelWidth + 16);
188
+ margins.right = Math.max(margins.right, padding + maxLabelWidth + 8);
189
189
  }
190
190
  }
191
191
  }
@@ -512,7 +512,11 @@ function buildOrdinalColorScale(
512
512
  ? explicitDomain.map(String)
513
513
  : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
514
514
 
515
- const scale = scaleOrdinal<string>().domain(values).range(palette);
515
+ // Use explicit range if provided, otherwise fall back to theme palette
516
+ const explicitRange = channel.scale?.range as string[] | undefined;
517
+ const colors = explicitRange ?? palette;
518
+
519
+ const scale = scaleOrdinal<string>().domain(values).range(colors);
516
520
 
517
521
  return { scale, type: 'ordinal', channel };
518
522
  }
@@ -526,9 +530,13 @@ function buildSequentialColorScale(
526
530
  const domainMin = min(values) ?? 0;
527
531
  const domainMax = max(values) ?? 1;
528
532
 
533
+ // Use explicit range if provided, otherwise fall back to theme palette endpoints
534
+ const explicitRange = channel.scale?.range as string[] | undefined;
535
+ const colors = explicitRange ?? palette;
536
+
529
537
  const scale = scaleLinear<string>()
530
538
  .domain([domainMin, domainMax])
531
- .range([palette[0], palette[palette.length - 1]])
539
+ .range([colors[0], colors[colors.length - 1]])
532
540
  .clamp(true);
533
541
 
534
542
  // Cast: sequential color scale (number -> string) is structurally incompatible
@@ -70,15 +70,26 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
70
70
  if (colorEnc.type === 'quantitative') return [];
71
71
 
72
72
  const uniqueValues = [...new Set(spec.data.map((d) => String(d[colorEnc.field])))];
73
- const palette = theme.colors.categorical;
73
+ const explicitDomain = colorEnc.scale?.domain as string[] | undefined;
74
+ const explicitRange = colorEnc.scale?.range as string[] | undefined;
75
+ const palette = explicitRange ?? theme.colors.categorical;
74
76
  const shape = swatchShapeForType(spec.markType);
75
77
 
76
- return uniqueValues.map((value, i) => ({
77
- label: value,
78
- color: palette[i % palette.length],
79
- shape,
80
- active: true,
81
- }));
78
+ return uniqueValues.map((value, i) => {
79
+ // When explicit domain+range are provided, look up the color by domain index
80
+ // so legend colors match the mark colors exactly.
81
+ let colorIndex = i;
82
+ if (explicitDomain && explicitRange) {
83
+ const domainIdx = explicitDomain.indexOf(value);
84
+ if (domainIdx >= 0) colorIndex = domainIdx;
85
+ }
86
+ return {
87
+ label: value,
88
+ color: palette[colorIndex % palette.length],
89
+ shape,
90
+ active: true,
91
+ };
92
+ });
82
93
  }
83
94
 
84
95
  /**
@@ -173,6 +184,27 @@ export function computeLegend(
173
184
 
174
185
  let entries = extractColorEntries(spec, theme);
175
186
 
187
+ // Auto-suppress legend when endpoint labels identify series on line/area charts.
188
+ // Guards: keep legend at compact breakpoints (labels hidden), for stacked areas
189
+ // (endpoint labels overlap), and when user explicitly forces legend on.
190
+ const isLineOrArea = spec.markType === 'line' || spec.markType === 'area';
191
+ const hasLabels = spec.labels.density !== 'none';
192
+ const labelsWillRender = strategy.labelMode !== 'none';
193
+ const hasColorEncoding = spec.encoding.color != null;
194
+ const legendNotForced = spec.legend?.show !== true;
195
+
196
+ if (isLineOrArea && hasLabels && labelsWillRender && hasColorEncoding && legendNotForced) {
197
+ const isArea = spec.markType === 'area';
198
+ const quantChannel =
199
+ spec.encoding.y?.type === 'quantitative' ? spec.encoding.y : spec.encoding.x;
200
+ const stackValue = quantChannel?.stack;
201
+ const isStacked = stackValue !== null && stackValue !== false;
202
+
203
+ if (!isArea || !isStacked) {
204
+ entries = [];
205
+ }
206
+ }
207
+
176
208
  const labelStyle: TextStyle = {
177
209
  fontFamily: theme.fonts.family,
178
210
  fontSize: theme.fonts.sizes.small,
@@ -27,32 +27,53 @@ describe('computeCategoryColors', () => {
27
27
  const theme = getTheme();
28
28
  const colors = computeCategoryColors(data, col, theme, false);
29
29
 
30
- expect(colors.size).toBe(4);
30
+ expect(colors.size).toBe(3);
31
31
  // "active" rows (indices 0, 2) should have green background
32
32
  expect(colors.get(0)!.backgroundColor).toBe('#00ff00');
33
33
  expect(colors.get(2)!.backgroundColor).toBe('#00ff00');
34
34
  // "inactive" row (index 1) should have red background
35
35
  expect(colors.get(1)!.backgroundColor).toBe('#ff0000');
36
+ // "pending" (index 3) is not in the explicit map, should be skipped
37
+ expect(colors.has(3)).toBe(false);
38
+ });
39
+
40
+ it('unmapped values are skipped by default', () => {
41
+ const col: ColumnConfig = {
42
+ key: 'status',
43
+ categoryColors: {
44
+ active: '#00ff00',
45
+ },
46
+ };
47
+ const theme = getTheme();
48
+ const colors = computeCategoryColors(data, col, theme, false);
49
+
50
+ // Only "active" rows (indices 0, 2) should be colored
51
+ expect(colors.size).toBe(2);
52
+ expect(colors.get(0)!.backgroundColor).toBe('#00ff00');
53
+ expect(colors.get(2)!.backgroundColor).toBe('#00ff00');
54
+ // Unmapped values should not have entries
55
+ expect(colors.has(1)).toBe(false); // inactive
56
+ expect(colors.has(3)).toBe(false); // pending
36
57
  });
37
58
 
38
- it('unmapped values get palette colors', () => {
59
+ it('autoAssign: true assigns palette colors to unmapped values', () => {
39
60
  const col: ColumnConfig = {
40
61
  key: 'status',
41
62
  categoryColors: {
42
63
  active: '#00ff00',
43
64
  },
65
+ autoAssign: true,
44
66
  };
45
67
  const theme = getTheme();
46
68
  const colors = computeCategoryColors(data, col, theme, false);
47
69
 
48
- // "inactive" and "pending" are not in the explicit map, should get palette colors
70
+ // All 4 rows should be colored
71
+ expect(colors.size).toBe(4);
72
+ // "inactive" and "pending" should get palette colors (not the explicit green)
49
73
  const inactiveBg = colors.get(1)!.backgroundColor!;
50
74
  const pendingBg = colors.get(3)!.backgroundColor!;
51
-
52
- // They should be assigned from the categorical palette
53
75
  expect(inactiveBg).toBeTruthy();
54
76
  expect(pendingBg).toBeTruthy();
55
- // They should not be the explicit mapped color
56
77
  expect(inactiveBg).not.toBe('#00ff00');
57
78
  });
58
79
 
@@ -89,7 +110,7 @@ describe('computeCategoryColors', () => {
89
110
  }
90
111
  });
91
112
 
92
- it('dark mode adapts colors', () => {
113
+ it('dark mode preserves explicit user-provided colors', () => {
93
114
  const col: ColumnConfig = {
94
115
  key: 'status',
95
116
  categoryColors: {
@@ -97,18 +118,46 @@ describe('computeCategoryColors', () => {
97
118
  inactive: '#ff0000',
98
119
  },
99
120
  };
100
- const lightTheme = getTheme(false);
101
121
  const darkTheme = getTheme(true);
122
+ const colors = computeCategoryColors(data, col, darkTheme, true);
123
+
124
+ // Explicit colors should NOT be adapted for dark mode
125
+ expect(colors.get(0)!.backgroundColor).toBe('#00ff00');
126
+ expect(colors.get(1)!.backgroundColor).toBe('#ff0000');
127
+ });
102
128
 
103
- const lightColors = computeCategoryColors(data, col, lightTheme, false);
129
+ it('dark mode adapts auto-assigned palette colors but not explicit ones', () => {
130
+ // Use a bright yellow that will definitely get adapted in dark mode
131
+ const col: ColumnConfig = {
132
+ key: 'status',
133
+ categoryColors: {
134
+ active: '#ffff00',
135
+ },
136
+ autoAssign: true,
137
+ };
138
+ const darkTheme = getTheme(true);
104
139
  const darkColors = computeCategoryColors(data, col, darkTheme, true);
105
140
 
106
- expect(lightColors.size).toBe(darkColors.size);
141
+ // Explicit color should be preserved as-is (not adapted)
142
+ expect(darkColors.get(0)!.backgroundColor).toBe('#ffff00');
143
+
144
+ // Auto-assigned palette colors should still be present (adaptation may or
145
+ // may not visually change them, but the code path runs adaptColorForDarkMode)
146
+ expect(darkColors.has(1)).toBe(true); // inactive
147
+ expect(darkColors.has(3)).toBe(true); // pending
148
+ });
149
+
150
+ it('autoAssign: true gives same value consistent color across rows', () => {
151
+ const col: ColumnConfig = {
152
+ key: 'status',
153
+ categoryColors: {},
154
+ autoAssign: true,
155
+ };
156
+ const theme = getTheme();
157
+ const colors = computeCategoryColors(data, col, theme, false);
107
158
 
108
- // Dark mode colors should be adapted (different from light mode)
109
- const lightBg = lightColors.get(0)!.backgroundColor;
110
- const darkBg = darkColors.get(0)!.backgroundColor;
111
- expect(darkBg).not.toBe(lightBg);
159
+ // Both "active" rows should get the same auto-assigned color
160
+ expect(colors.get(0)!.backgroundColor).toBe(colors.get(2)!.backgroundColor);
112
161
  });
113
162
 
114
163
  it('dark mode text contrast still meets AA', () => {
@@ -137,6 +186,34 @@ describe('computeCategoryColors', () => {
137
186
  expect(colors.size).toBe(0);
138
187
  });
139
188
 
189
+ it('skips transparent and none category colors', () => {
190
+ const dataWithSpecial = [
191
+ { status: 'active' },
192
+ { status: 'inactive' },
193
+ { status: 'pending' },
194
+ { status: 'disabled' },
195
+ ];
196
+ const col: ColumnConfig = {
197
+ key: 'status',
198
+ categoryColors: {
199
+ active: '#00ff00',
200
+ inactive: 'transparent',
201
+ pending: 'none',
202
+ disabled: '#cccccc',
203
+ },
204
+ };
205
+ const theme = getTheme();
206
+ const colors = computeCategoryColors(dataWithSpecial, col, theme, false);
207
+
208
+ // transparent and none rows should be skipped
209
+ expect(colors.has(1)).toBe(false); // inactive = transparent
210
+ expect(colors.has(2)).toBe(false); // pending = none
211
+ // explicit colors should still be present
212
+ expect(colors.get(0)!.backgroundColor).toBe('#00ff00');
213
+ expect(colors.get(3)!.backgroundColor).toBe('#cccccc');
214
+ expect(colors.size).toBe(2);
215
+ });
216
+
140
217
  it('skips null values', () => {
141
218
  const dataWithNull = [{ status: 'active' }, { status: null }, { status: 'inactive' }];
142
219
  const col: ColumnConfig = {
@@ -39,20 +39,31 @@ export function computeCategoryColors(
39
39
 
40
40
  const key = String(raw);
41
41
  let bg: string;
42
+ let isExplicit = false;
42
43
 
43
- if (explicitMap[key]) {
44
+ if (explicitMap[key] != null) {
45
+ if (explicitMap[key] === 'transparent' || explicitMap[key] === 'none') {
46
+ // Skip transparent/none — let the cell inherit default table styling
47
+ continue;
48
+ }
44
49
  bg = explicitMap[key];
45
- } else if (autoAssigned.has(key)) {
46
- bg = autoAssigned.get(key)!;
50
+ isExplicit = true;
51
+ } else if (column.autoAssign) {
52
+ // Auto-assign from palette only when explicitly opted in
53
+ if (autoAssigned.has(key)) {
54
+ bg = autoAssigned.get(key)!;
55
+ } else {
56
+ bg = categoricalPalette[nextPaletteIndex % categoricalPalette.length];
57
+ nextPaletteIndex++;
58
+ autoAssigned.set(key, bg);
59
+ }
47
60
  } else {
48
- // Assign from categorical palette
49
- bg = categoricalPalette[nextPaletteIndex % categoricalPalette.length];
50
- nextPaletteIndex++;
51
- autoAssigned.set(key, bg);
61
+ // Default: skip unmapped values (no color assigned)
62
+ continue;
52
63
  }
53
64
 
54
- // Dark mode adaptation
55
- if (darkMode) {
65
+ // Dark mode adaptation (skip for explicit user-provided colors)
66
+ if (darkMode && !isExplicit) {
56
67
  bg = adaptColorForDarkMode(bg, lightBg, darkBg);
57
68
  }
58
69
 
@@ -14,6 +14,15 @@ describe('evaluatePredicate', () => {
14
14
  it('matches equal numeric value', () => {
15
15
  expect(evaluatePredicate({ age: 30 }, { field: 'age', equal: 30 })).toBe(true);
16
16
  });
17
+
18
+ it('matches string value against numeric predicate via loose equality', () => {
19
+ // CSV/JSON parsing often produces string values for numeric fields
20
+ expect(evaluatePredicate({ id: '181' }, { field: 'id', equal: 181 })).toBe(true);
21
+ });
22
+
23
+ it('matches numeric value against string predicate via loose equality', () => {
24
+ expect(evaluatePredicate({ id: 181 }, { field: 'id', equal: '181' })).toBe(true);
25
+ });
17
26
  });
18
27
 
19
28
  describe('FieldPredicate: lt/lte/gt/gte', () => {
@@ -62,6 +71,14 @@ describe('evaluatePredicate', () => {
62
71
  it('rejects values not in the set', () => {
63
72
  expect(evaluatePredicate({ c: 'green' }, { field: 'c', oneOf: ['red', 'blue'] })).toBe(false);
64
73
  });
74
+
75
+ it('matches string value against numeric oneOf via loose equality', () => {
76
+ expect(evaluatePredicate({ id: '181' }, { field: 'id', oneOf: [181, 200] })).toBe(true);
77
+ });
78
+
79
+ it('rejects value not in numeric oneOf set', () => {
80
+ expect(evaluatePredicate({ id: '999' }, { field: 'id', oneOf: [181, 200] })).toBe(false);
81
+ });
65
82
  });
66
83
 
67
84
  describe('FieldPredicate: valid', () => {
@@ -26,9 +26,11 @@ function evaluateFieldPredicate(datum: DataRow, pred: FieldPredicate): boolean {
26
26
  return pred.valid ? isValid : !isValid;
27
27
  }
28
28
 
29
- // equal
29
+ // equal: use loose equality so "181" == 181 matches across type boundaries
30
+ // (data values may arrive as strings from CSV/JSON parsing)
30
31
  if (pred.equal !== undefined) {
31
- return value === pred.equal;
32
+ // biome-ignore lint/suspicious/noDoubleEquals: intentional loose equality for CSV/JSON type coercion
33
+ return value == pred.equal;
32
34
  }
33
35
 
34
36
  // Numeric comparisons
@@ -53,9 +55,10 @@ function evaluateFieldPredicate(datum: DataRow, pred: FieldPredicate): boolean {
53
55
  return numValue >= min && numValue <= max;
54
56
  }
55
57
 
56
- // oneOf: value is in the set
58
+ // oneOf: use loose equality (same rationale as equal above)
57
59
  if (pred.oneOf !== undefined) {
58
- return pred.oneOf.includes(value);
60
+ // biome-ignore lint/suspicious/noDoubleEquals: intentional loose equality for CSV/JSON type coercion
61
+ return pred.oneOf.some((v) => v == value);
59
62
  }
60
63
 
61
64
  // No condition specified, default to true