@opendata-ai/openchart-engine 6.16.0 → 6.18.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.16.0",
3
+ "version": "6.18.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.16.0",
48
+ "@opendata-ai/openchart-core": "6.18.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 = {
@@ -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
  });
@@ -249,6 +249,7 @@ function normalizeSankeySpec(spec: SankeySpec, _warnings: string[]): NormalizedS
249
249
  iterations: spec.iterations ?? 6,
250
250
  linkStyle: spec.linkStyle ?? 'gradient',
251
251
  nodeLabelAlign: spec.nodeLabelAlign ?? 'auto',
252
+ nodeSort: spec.nodeSort,
252
253
  chrome: normalizeChrome(spec.chrome),
253
254
  legend: spec.legend,
254
255
  theme: spec.theme ?? {},
@@ -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
  /**
@@ -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,
@@ -324,6 +324,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
324
324
  sankeySpec.nodePadding,
325
325
  sankeySpec.nodeAlign,
326
326
  sankeySpec.iterations,
327
+ sankeySpec.nodeSort,
327
328
  );
328
329
 
329
330
  // 6b. Check if any right-side node labels overflow the right edge.
@@ -363,6 +364,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
363
364
  sankeySpec.nodePadding,
364
365
  sankeySpec.nodeAlign,
365
366
  sankeySpec.iterations,
367
+ sankeySpec.nodeSort,
366
368
  ));
367
369
  }
368
370
 
@@ -73,6 +73,7 @@ export function computeSankeyLayout(
73
73
  nodePadding: number,
74
74
  nodeAlign: SankeyNodeAlign,
75
75
  iterations: number,
76
+ nodeSort?: string[],
76
77
  ): SankeyLayoutResult {
77
78
  // Extract unique node IDs from source and target columns
78
79
  const nodeSet = new Set<string>();
@@ -113,6 +114,19 @@ export function computeSankeyLayout(
113
114
  ])
114
115
  .iterations(iterations);
115
116
 
117
+ // Apply explicit node ordering when provided.
118
+ // Builds a comparator from the ordered ID array so d3-sankey places nodes
119
+ // top-to-bottom within each column according to the spec's nodeSort.
120
+ if (nodeSort && nodeSort.length > 0) {
121
+ const orderMap = new Map(nodeSort.map((id, i) => [id, i]));
122
+ const fallback = nodeSort.length;
123
+ generator.nodeSort(
124
+ (a: SankeyNode<NodeExtra, LinkExtra>, b: SankeyNode<NodeExtra, LinkExtra>) =>
125
+ (orderMap.get((a as unknown as NodeExtra).id) ?? fallback) -
126
+ (orderMap.get((b as unknown as NodeExtra).id) ?? fallback),
127
+ );
128
+ }
129
+
116
130
  const graph = generator({
117
131
  nodes: nodes as unknown as Array<SankeyNode<NodeExtra, LinkExtra>>,
118
132
  links: links as unknown as Array<SankeyLink<NodeExtra, LinkExtra>>,
@@ -29,6 +29,7 @@ export interface NormalizedSankeySpec {
29
29
  iterations: number;
30
30
  linkStyle: SankeyLinkColor;
31
31
  nodeLabelAlign: 'auto' | 'left' | 'right';
32
+ nodeSort?: string[];
32
33
  chrome: NormalizedChrome;
33
34
  legend?: LegendConfig;
34
35
  theme: ThemeConfig;
@@ -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
 
@@ -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 (autoAssigned.has(key)) {
52
- bg = autoAssigned.get(key)!;
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
- // Assign from categorical palette
55
- bg = categoricalPalette[nextPaletteIndex % categoricalPalette.length];
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)