@opendata-ai/openchart-engine 6.16.0 → 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 +26 -8
- 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 +89 -1
- package/src/layout/axes.ts +3 -3
- package/src/legend/compute.ts +21 -0
- package/src/tables/__tests__/category-colors.test.ts +41 -6
- package/src/tables/category-colors.ts +11 -6
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 = {
|
|
@@ -305,4 +305,92 @@ describe('computeLegend', () => {
|
|
|
305
305
|
const scatterLegend = computeLegend(scatterSpec, fullStrategy, theme, chartArea);
|
|
306
306
|
expect(scatterLegend.entries[0].shape).toBe('circle');
|
|
307
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
|
+
});
|
|
308
396
|
});
|
package/src/layout/axes.ts
CHANGED
package/src/legend/compute.ts
CHANGED
|
@@ -184,6 +184,27 @@ export function computeLegend(
|
|
|
184
184
|
|
|
185
185
|
let entries = extractColorEntries(spec, theme);
|
|
186
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
|
+
|
|
187
208
|
const labelStyle: TextStyle = {
|
|
188
209
|
fontFamily: theme.fonts.family,
|
|
189
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
|
|
|
@@ -112,6 +133,7 @@ describe('computeCategoryColors', () => {
|
|
|
112
133
|
categoryColors: {
|
|
113
134
|
active: '#ffff00',
|
|
114
135
|
},
|
|
136
|
+
autoAssign: true,
|
|
115
137
|
};
|
|
116
138
|
const darkTheme = getTheme(true);
|
|
117
139
|
const darkColors = computeCategoryColors(data, col, darkTheme, true);
|
|
@@ -125,6 +147,19 @@ describe('computeCategoryColors', () => {
|
|
|
125
147
|
expect(darkColors.has(3)).toBe(true); // pending
|
|
126
148
|
});
|
|
127
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);
|
|
158
|
+
|
|
159
|
+
// Both "active" rows should get the same auto-assigned color
|
|
160
|
+
expect(colors.get(0)!.backgroundColor).toBe(colors.get(2)!.backgroundColor);
|
|
161
|
+
});
|
|
162
|
+
|
|
128
163
|
it('dark mode text contrast still meets AA', () => {
|
|
129
164
|
const col: ColumnConfig = {
|
|
130
165
|
key: 'status',
|
|
@@ -48,13 +48,18 @@ export function computeCategoryColors(
|
|
|
48
48
|
}
|
|
49
49
|
bg = explicitMap[key];
|
|
50
50
|
isExplicit = true;
|
|
51
|
-
} else if (
|
|
52
|
-
|
|
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
|
+
}
|
|
53
60
|
} else {
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
nextPaletteIndex++;
|
|
57
|
-
autoAssigned.set(key, bg);
|
|
61
|
+
// Default: skip unmapped values (no color assigned)
|
|
62
|
+
continue;
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
// Dark mode adaptation (skip for explicit user-provided colors)
|