@opendata-ai/openchart-engine 7.8.0 → 7.9.1

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": "7.8.0",
3
+ "version": "7.9.1",
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",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "7.8.0",
51
+ "@opendata-ai/openchart-core": "7.9.1",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -224,6 +224,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
224
224
  },
225
225
  "fill": "#06b6d4",
226
226
  "height": 57.45223880597016,
227
+ "key": "|A",
227
228
  "label": {
228
229
  "connector": undefined,
229
230
  "style": {
@@ -258,6 +259,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
258
259
  },
259
260
  "fill": "#06b6d4",
260
261
  "height": 57.45223880597016,
262
+ "key": "|B",
261
263
  "label": {
262
264
  "connector": undefined,
263
265
  "style": {
@@ -292,6 +294,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
292
294
  },
293
295
  "fill": "#06b6d4",
294
296
  "height": 57.45223880597016,
297
+ "key": "|C",
295
298
  "label": {
296
299
  "connector": undefined,
297
300
  "style": {
@@ -1037,8 +1040,14 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1037
1040
  "y": 105.2,
1038
1041
  },
1039
1042
  ],
1043
+ "interpolate": "monotone",
1044
+ "key": "US",
1040
1045
  "opacity": undefined,
1041
1046
  "path": "M47,334.25L651.761,105.2",
1047
+ "pointKeys": [
1048
+ "2020-01-01",
1049
+ "2021-01-01",
1050
+ ],
1042
1051
  "points": [
1043
1052
  {
1044
1053
  "x": 47,
@@ -1129,8 +1138,14 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1129
1138
  "y": 143.375,
1130
1139
  },
1131
1140
  ],
1141
+ "interpolate": "monotone",
1142
+ "key": "UK",
1132
1143
  "opacity": undefined,
1133
1144
  "path": "M47,296.075L651.761,143.375",
1145
+ "pointKeys": [
1146
+ "2020-01-01",
1147
+ "2021-01-01",
1148
+ ],
1134
1149
  "points": [
1135
1150
  {
1136
1151
  "x": 47,
@@ -1221,8 +1236,14 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1221
1236
  "y": 242.62999999999997,
1222
1237
  },
1223
1238
  ],
1239
+ "interpolate": "monotone",
1240
+ "key": "FR",
1224
1241
  "opacity": undefined,
1225
1242
  "path": "M47,349.52L651.761,242.63",
1243
+ "pointKeys": [
1244
+ "2020-01-01",
1245
+ "2021-01-01",
1246
+ ],
1226
1247
  "points": [
1227
1248
  {
1228
1249
  "x": 47,
@@ -1313,8 +1334,14 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1313
1334
  "y": 196.82,
1314
1335
  },
1315
1336
  ],
1337
+ "interpolate": "monotone",
1338
+ "key": "DE",
1316
1339
  "opacity": undefined,
1317
1340
  "path": "M47,318.98L651.761,196.82",
1341
+ "pointKeys": [
1342
+ "2020-01-01",
1343
+ "2021-01-01",
1344
+ ],
1318
1345
  "points": [
1319
1346
  {
1320
1347
  "x": 47,
@@ -1804,6 +1831,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1804
1831
  "type": "linear",
1805
1832
  },
1806
1833
  "height": 101.27142857142857,
1834
+ "key": "|A",
1807
1835
  "label": {
1808
1836
  "connector": undefined,
1809
1837
  "style": {
@@ -1849,6 +1877,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1849
1877
  "type": "linear",
1850
1878
  },
1851
1879
  "height": 185.6642857142857,
1880
+ "key": "|B",
1852
1881
  "label": {
1853
1882
  "connector": undefined,
1854
1883
  "style": {
@@ -1894,6 +1923,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1894
1923
  "type": "linear",
1895
1924
  },
1896
1925
  "height": 236.29999999999998,
1926
+ "key": "|C",
1897
1927
  "label": {
1898
1928
  "connector": undefined,
1899
1929
  "style": {
@@ -1939,6 +1969,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1939
1969
  "type": "linear",
1940
1970
  },
1941
1971
  "height": 151.90714285714287,
1972
+ "key": "|D",
1942
1973
  "label": {
1943
1974
  "connector": undefined,
1944
1975
  "style": {
@@ -482,6 +482,82 @@ describe('text-aware tick density', () => {
482
482
  expect(axes.x!.ticks.length).toBe(categories.length);
483
483
  });
484
484
 
485
+ it('keeps narrow band labels when one label is much wider (no global decimation)', () => {
486
+ // Regression: 5 rotated year labels where "2026 (to wk 17)" is much wider
487
+ // than the others. The old every-other thinning dropped BOTH "2023" and
488
+ // "2025" even though the narrow labels had ample room — a single wide label
489
+ // forced global decimation. Greedy per-label retention keeps the narrow
490
+ // ones; only the neighbor immediately crowded by the wide final label drops.
491
+ const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
492
+ const barSpec: NormalizedChartSpec = {
493
+ ...lineSpec,
494
+ markType: 'bar',
495
+ markDef: { type: 'bar', orient: 'vertical' },
496
+ data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
497
+ encoding: {
498
+ x: { field: 'cat', type: 'nominal' },
499
+ y: { field: 'val', type: 'quantitative' },
500
+ },
501
+ };
502
+
503
+ // measureText reports the wide label as genuinely wide and the year labels
504
+ // as narrow, matching real glyph metrics.
505
+ const measure = (text: string) => ({
506
+ width: text.length * 8,
507
+ height: 12,
508
+ });
509
+
510
+ // Narrow area (280px) forces -45° rotation. The middle narrow labels survive
511
+ // — old every-other thinning would have dropped 2023 too.
512
+ const narrowArea = { x: 50, y: 50, width: 280, height: 300 };
513
+ const narrowScales = computeScales(barSpec, narrowArea, barSpec.data);
514
+ const narrowAxes = computeAxes(narrowScales, narrowArea, fullStrategy, theme, measure);
515
+ const narrowKept = narrowAxes.x!.ticks.map((t) => t.label);
516
+ expect(narrowAxes.x!.tickAngle).toBe(-45);
517
+ expect(narrowKept).toContain('2023');
518
+ expect(narrowKept).toContain('2024');
519
+ expect(narrowKept).toContain('2026 (to wk 17)');
520
+
521
+ // Wider area (500px) has room for every label — none are thinned.
522
+ const wideArea = { x: 50, y: 50, width: 500, height: 300 };
523
+ const wideScales = computeScales(barSpec, wideArea, barSpec.data);
524
+ const wideAxes = computeAxes(wideScales, wideArea, fullStrategy, theme, measure);
525
+ expect(wideAxes.x!.ticks.length).toBe(categories.length);
526
+ });
527
+
528
+ it('drops every neighbor a very wide final label overlaps, not just the nearest', () => {
529
+ // A final category long enough to project across several bands at -45° must
530
+ // clear ALL kept labels its footprint overlaps. The last-label protection
531
+ // pass drops each colliding neighbor, not only the immediate one — otherwise
532
+ // an earlier kept label still overlaps the forced final label.
533
+ const categories = ['A', 'B', 'C', 'D', 'This Is A Very Long Final Category Name'];
534
+ const barSpec: NormalizedChartSpec = {
535
+ ...lineSpec,
536
+ markType: 'bar',
537
+ markDef: { type: 'bar', orient: 'vertical' },
538
+ data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
539
+ encoding: {
540
+ x: { field: 'cat', type: 'nominal' },
541
+ y: { field: 'val', type: 'quantitative' },
542
+ },
543
+ };
544
+ const measure = (text: string) => ({ width: text.length * 8, height: 12 });
545
+ const narrowArea = { x: 50, y: 50, width: 260, height: 300 };
546
+ const scales = computeScales(barSpec, narrowArea, barSpec.data);
547
+ const axes = computeAxes(scales, narrowArea, fullStrategy, theme, measure);
548
+ expect(axes.x!.tickAngle).toBe(-45);
549
+
550
+ const kept = axes.x!.ticks.map((t) => t.label);
551
+ // The forced final label is always retained.
552
+ expect(kept).toContain('This Is A Very Long Final Category Name');
553
+ // Every middle label the wide final label's footprint overlaps is dropped —
554
+ // not just the nearest one. Before the fix, "B" and "C" would survive and
555
+ // overlap the final label; only "D" (the immediate neighbor) was dropped.
556
+ expect(kept).not.toContain('B');
557
+ expect(kept).not.toContain('C');
558
+ expect(kept).not.toContain('D');
559
+ });
560
+
485
561
  it('y-axis gridlines match ticks so every gridline has a label', () => {
486
562
  // Force thinning by using a measureText that reports wide labels
487
563
  const wideMeasure = () => ({ width: 200, height: 12 });
@@ -50,11 +50,11 @@ describe('compileChart with animation', () => {
50
50
  const layout = compileChart(spec, { width: 600, height: 400 });
51
51
 
52
52
  expect(layout.animation).toBeDefined();
53
- expect(layout.animation!.enabled).toBe(true);
54
- expect(layout.animation!.duration).toBe(500);
55
- expect(layout.animation!.ease).toBe('smooth');
56
- expect(layout.animation!.staggerDelay).toBe(80);
57
- expect(layout.animation!.staggerOrder).toBe('index');
53
+ expect(layout.animation!.enter).toBeDefined();
54
+ expect(layout.animation!.enter!.duration).toBe(500);
55
+ expect(layout.animation!.enter!.ease).toBe('smooth');
56
+ expect(layout.animation!.enter!.staggerDelay).toBe(80);
57
+ expect(layout.animation!.enter!.staggerOrder).toBe('index');
58
58
  expect(layout.animation!.annotationDelay).toBe(200);
59
59
  });
60
60
 
@@ -80,8 +80,8 @@ describe('compileChart with animation', () => {
80
80
  const layout = compileChart(spec, { width: 600, height: 400 });
81
81
 
82
82
  expect(layout.animation).toBeDefined();
83
- expect(layout.animation!.duration).toBe(800);
84
- expect(layout.animation!.ease).toBe('smooth');
83
+ expect(layout.animation!.enter!.duration).toBe(800);
84
+ expect(layout.animation!.enter!.ease).toBe('smooth');
85
85
  expect(layout.animation!.annotationDelay).toBe(500);
86
86
  });
87
87
 
@@ -152,7 +152,7 @@ describe('compileChart with animation', () => {
152
152
  } as ChartSpec;
153
153
  const layoutCompact = compileChart(specNoAnim, { width: 350, height: 400 });
154
154
  expect(layoutCompact.animation).toBeDefined();
155
- expect(layoutCompact.animation!.enabled).toBe(true);
155
+ expect(layoutCompact.animation!.enter).toBeDefined();
156
156
 
157
157
  // At full width (> 700), no override applies, so no animation
158
158
  const layoutFull = compileChart(specNoAnim, { width: 800, height: 400 });
@@ -943,7 +943,7 @@ describe('compileChart', () => {
943
943
  };
944
944
  const layout = compileChart(spec, { width: 320, height: 200 });
945
945
  expect(layout.display).toBe('sparkline');
946
- expect(layout.animation?.enabled).toBe(true);
946
+ expect(layout.animation?.enter).toBeDefined();
947
947
  });
948
948
 
949
949
  it('breakpoint chrome wins when top-level is sparkline', () => {
@@ -1012,8 +1012,8 @@ describe('compileChart', () => {
1012
1012
  { ...lineSpec, display: 'sparkline' as const, animation: true },
1013
1013
  { width: 400, height: 80 },
1014
1014
  );
1015
- expect(layout.animation?.enabled).toBe(true);
1016
- expect(layout.animation?.duration).toBe(1100);
1015
+ expect(layout.animation?.enter).toBeDefined();
1016
+ expect(layout.animation?.enter?.duration).toBe(1100);
1017
1017
  });
1018
1018
 
1019
1019
  it('sparkline + animation: { enter: { duration: 500 } } respects user duration', () => {
@@ -1025,12 +1025,12 @@ describe('compileChart', () => {
1025
1025
  },
1026
1026
  { width: 400, height: 80 },
1027
1027
  );
1028
- expect(layout.animation?.duration).toBe(500);
1028
+ expect(layout.animation?.enter?.duration).toBe(500);
1029
1029
  });
1030
1030
 
1031
1031
  it('full mode + animation: true uses default 500ms (sparkline bump does not leak)', () => {
1032
1032
  const layout = compileChart({ ...lineSpec, animation: true }, { width: 600, height: 400 });
1033
- expect(layout.animation?.duration).toBe(500);
1033
+ expect(layout.animation?.enter?.duration).toBe(500);
1034
1034
  });
1035
1035
 
1036
1036
  // ---------------------------------------------------------------------------
@@ -162,7 +162,65 @@ describe('computeDimensions', () => {
162
162
  expect(dimsWithTitle.margins.left - dimsWithoutTitle.margins.left).toBeGreaterThanOrEqual(11);
163
163
  });
164
164
 
165
- it('y-axis title margin scales with tick label width', () => {
165
+ it('y-axis title margin scales with tick label width (gutter ticks)', () => {
166
+ // Bar chart: y-tick labels sit in a left gutter, so the title must clear
167
+ // them. Wider tick labels ("2,000,000" vs "9") push the title further out.
168
+ const barBase: NormalizedChartSpec = {
169
+ ...baseSpec,
170
+ markType: 'bar',
171
+ markDef: { type: 'bar' },
172
+ };
173
+ const smallValues: NormalizedChartSpec = {
174
+ ...barBase,
175
+ data: [
176
+ { cat: 'a', value: 5 },
177
+ { cat: 'b', value: 9 },
178
+ ],
179
+ encoding: {
180
+ x: { field: 'cat', type: 'nominal' },
181
+ y: { field: 'value', type: 'quantitative', axis: { title: 'Count' } },
182
+ },
183
+ };
184
+ const largeValues: NormalizedChartSpec = {
185
+ ...barBase,
186
+ data: [
187
+ { cat: 'a', value: 1_500_000 },
188
+ { cat: 'b', value: 2_000_000 },
189
+ ],
190
+ encoding: {
191
+ x: { field: 'cat', type: 'nominal' },
192
+ y: {
193
+ field: 'value',
194
+ type: 'quantitative',
195
+ axis: { title: 'Revenue ($)', format: ',.0f' },
196
+ },
197
+ },
198
+ };
199
+
200
+ const dimsSmall = computeDimensions(
201
+ smallValues,
202
+ { width: 600, height: 400 },
203
+ emptyLegend,
204
+ lightTheme,
205
+ );
206
+ const dimsLarge = computeDimensions(
207
+ largeValues,
208
+ { width: 600, height: 400 },
209
+ emptyLegend,
210
+ lightTheme,
211
+ );
212
+
213
+ // Explicit format ",.0f" on large values produces "2,000,000" tick labels
214
+ // that are much wider than the abbreviated "0.0" from small values,
215
+ // so the y-axis title margin should grow to keep clearance
216
+ expect(dimsLarge.margins.left).toBeGreaterThan(dimsSmall.margins.left);
217
+ });
218
+
219
+ it('y-axis title margin ignores tick label width for inline ticks (line)', () => {
220
+ // Line chart with continuous y: tick labels render inline (above their
221
+ // gridline inside the plot), not in a left gutter. The title only needs to
222
+ // clear the chart edge, so wide tick values must NOT inflate the margin —
223
+ // that dead padding is the exact bug this guards against.
166
224
  const smallValues: NormalizedChartSpec = {
167
225
  ...baseSpec,
168
226
  data: [
@@ -203,10 +261,63 @@ describe('computeDimensions', () => {
203
261
  lightTheme,
204
262
  );
205
263
 
206
- // Explicit format ",.0f" on large values produces "2,000,000" tick labels
207
- // that are much wider than the abbreviated "0.0" from small values,
208
- // so the y-axis title margin should grow to keep clearance
209
- expect(dimsLarge.margins.left).toBeGreaterThan(dimsSmall.margins.left);
264
+ expect(dimsLarge.margins.left).toBe(dimsSmall.margins.left);
265
+ });
266
+
267
+ it('reserves gutter margin when an explicit band scale forces gutter ticks (line)', () => {
268
+ // A line chart's y-tick position is decided by the RESOLVED scale type, not
269
+ // the field type. An explicit `scale.type: 'band'` on a quantitative field
270
+ // resolves to a categorical (gutter) axis, so computeAxes draws a gutter
271
+ // title. The margin reservation must agree — reserving the inline (narrow)
272
+ // margin here would let the title collide with the gutter tick labels.
273
+ const bandScaleLine: NormalizedChartSpec = {
274
+ ...baseSpec,
275
+ data: [
276
+ { date: '2020-01-01', value: 1_500_000 },
277
+ { date: '2021-01-01', value: 2_000_000 },
278
+ ],
279
+ encoding: {
280
+ x: { field: 'date', type: 'temporal' },
281
+ y: {
282
+ field: 'value',
283
+ type: 'quantitative',
284
+ scale: { type: 'band' },
285
+ axis: { title: 'Revenue ($)', format: ',.0f' },
286
+ },
287
+ },
288
+ };
289
+ const inlineLine: NormalizedChartSpec = {
290
+ ...baseSpec,
291
+ data: [
292
+ { date: '2020-01-01', value: 1_500_000 },
293
+ { date: '2021-01-01', value: 2_000_000 },
294
+ ],
295
+ encoding: {
296
+ x: { field: 'date', type: 'temporal' },
297
+ y: {
298
+ field: 'value',
299
+ type: 'quantitative',
300
+ axis: { title: 'Revenue ($)', format: ',.0f' },
301
+ },
302
+ },
303
+ };
304
+
305
+ const bandDims = computeDimensions(
306
+ bandScaleLine,
307
+ { width: 600, height: 400 },
308
+ emptyLegend,
309
+ lightTheme,
310
+ );
311
+ const inlineDims = computeDimensions(
312
+ inlineLine,
313
+ { width: 600, height: 400 },
314
+ emptyLegend,
315
+ lightTheme,
316
+ );
317
+
318
+ // The gutter (band-scale) reservation includes the wide tick-label width, so
319
+ // it must exceed the inline reservation that omits it.
320
+ expect(bandDims.margins.left).toBeGreaterThan(inlineDims.margins.left);
210
321
  });
211
322
 
212
323
  it('applies dark mode theme adaptation', () => {