@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/dist/index.js +71 -30
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +5 -5
- package/src/__tests__/compile-chart.test.ts +9 -4
- package/src/__tests__/legend.test.ts +121 -1
- package/src/annotations/resolve-text.ts +1 -0
- package/src/compile.ts +18 -9
- package/src/layout/axes.ts +3 -3
- package/src/layout/dimensions.ts +1 -1
- package/src/layout/scales.ts +10 -2
- package/src/legend/compute.ts +39 -7
- package/src/tables/__tests__/category-colors.test.ts +91 -14
- package/src/tables/category-colors.ts +20 -9
- package/src/transforms/__tests__/predicates.test.ts +17 -0
- package/src/transforms/predicates.ts +7 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
198
|
-
expect(axesThumb.y!.ticks.length).toBeLessThanOrEqual(
|
|
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
|
|
70
|
-
expect(layout.legend.entries.length).toBe(
|
|
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(
|
|
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: '
|
|
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
|
});
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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
|
|
package/src/layout/axes.ts
CHANGED
package/src/layout/dimensions.ts
CHANGED
package/src/layout/scales.ts
CHANGED
|
@@ -512,7 +512,11 @@ function buildOrdinalColorScale(
|
|
|
512
512
|
? explicitDomain.map(String)
|
|
513
513
|
: applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
|
|
514
514
|
|
|
515
|
-
|
|
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([
|
|
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
|
package/src/legend/compute.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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(
|
|
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('
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
109
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
49
|
-
|
|
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
|
-
|
|
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:
|
|
58
|
+
// oneOf: use loose equality (same rationale as equal above)
|
|
57
59
|
if (pred.oneOf !== undefined) {
|
|
58
|
-
|
|
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
|