@opendata-ai/openchart-engine 7.3.0 → 7.4.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": "7.3.0",
3
+ "version": "7.4.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",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "7.3.0",
51
+ "@opendata-ai/openchart-core": "7.4.0",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -171,7 +171,6 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
171
171
  "title": {
172
172
  "maxWidth": 561,
173
173
  "style": {
174
- "dominantBaseline": "hanging",
175
174
  "fill": "#09090b",
176
175
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
177
176
  "fontSize": 26,
@@ -718,7 +717,6 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
718
717
  "source": {
719
718
  "maxWidth": 755,
720
719
  "style": {
721
- "dominantBaseline": "hanging",
722
720
  "fill": "#71717a",
723
721
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
724
722
  "fontSize": 11,
@@ -733,7 +731,6 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
733
731
  "subtitle": {
734
732
  "maxWidth": 755,
735
733
  "style": {
736
- "dominantBaseline": "hanging",
737
734
  "fill": "#71717a",
738
735
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
739
736
  "fontSize": 14,
@@ -748,7 +745,6 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
748
745
  "title": {
749
746
  "maxWidth": 755,
750
747
  "style": {
751
- "dominantBaseline": "hanging",
752
748
  "fill": "#09090b",
753
749
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
754
750
  "fontSize": 26,
@@ -1705,7 +1701,6 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1705
1701
  "byline": {
1706
1702
  "maxWidth": 431,
1707
1703
  "style": {
1708
- "dominantBaseline": "hanging",
1709
1704
  "fill": "#71717a",
1710
1705
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
1711
1706
  "fontSize": 11,
@@ -1720,7 +1715,6 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1720
1715
  "source": {
1721
1716
  "maxWidth": 431,
1722
1717
  "style": {
1723
- "dominantBaseline": "hanging",
1724
1718
  "fill": "#71717a",
1725
1719
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
1726
1720
  "fontSize": 11,
@@ -1735,7 +1729,6 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1735
1729
  "title": {
1736
1730
  "maxWidth": 561,
1737
1731
  "style": {
1738
- "dominantBaseline": "hanging",
1739
1732
  "fill": "#09090b",
1740
1733
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
1741
1734
  "fontSize": 26,
@@ -1804,7 +1797,6 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1804
1797
  "label": {
1805
1798
  "connector": undefined,
1806
1799
  "style": {
1807
- "dominantBaseline": "hanging",
1808
1800
  "fill": "#0000ff",
1809
1801
  "fontFamily": "system-ui, -apple-system, sans-serif",
1810
1802
  "fontSize": 10,
@@ -1815,7 +1807,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1815
1807
  "text": "30",
1816
1808
  "visible": true,
1817
1809
  "x": 122.67875862068962,
1818
- "y": 175.92857142857142,
1810
+ "y": 183.92857142857142,
1819
1811
  },
1820
1812
  "orient": "vertical",
1821
1813
  "type": "rect",
@@ -1849,7 +1841,6 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1849
1841
  "label": {
1850
1842
  "connector": undefined,
1851
1843
  "style": {
1852
- "dominantBaseline": "hanging",
1853
1844
  "fill": "#0000ff",
1854
1845
  "fontFamily": "system-ui, -apple-system, sans-serif",
1855
1846
  "fontSize": 10,
@@ -1860,7 +1851,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1860
1851
  "text": "55",
1861
1852
  "visible": true,
1862
1853
  "x": 246.30358620689654,
1863
- "y": 91.53571428571429,
1854
+ "y": 99.53571428571429,
1864
1855
  },
1865
1856
  "orient": "vertical",
1866
1857
  "type": "rect",
@@ -1894,7 +1885,6 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1894
1885
  "label": {
1895
1886
  "connector": undefined,
1896
1887
  "style": {
1897
- "dominantBaseline": "hanging",
1898
1888
  "fill": "#0000ff",
1899
1889
  "fontFamily": "system-ui, -apple-system, sans-serif",
1900
1890
  "fontSize": 10,
@@ -1905,7 +1895,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1905
1895
  "text": "70",
1906
1896
  "visible": true,
1907
1897
  "x": 369.92841379310346,
1908
- "y": 40.9,
1898
+ "y": 48.9,
1909
1899
  },
1910
1900
  "orient": "vertical",
1911
1901
  "type": "rect",
@@ -1939,7 +1929,6 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1939
1929
  "label": {
1940
1930
  "connector": undefined,
1941
1931
  "style": {
1942
- "dominantBaseline": "hanging",
1943
1932
  "fill": "#0000ff",
1944
1933
  "fontFamily": "system-ui, -apple-system, sans-serif",
1945
1934
  "fontSize": 10,
@@ -1950,7 +1939,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1950
1939
  "text": "45",
1951
1940
  "visible": true,
1952
1941
  "x": 493.55324137931035,
1953
- "y": 125.29285714285712,
1942
+ "y": 133.29285714285712,
1954
1943
  },
1955
1944
  "orient": "vertical",
1956
1945
  "type": "rect",
@@ -1123,3 +1123,65 @@ describe('axis extent and titlePosition', () => {
1123
1123
  expect(axes.y!.titlePosition!.angle).toBe(-90);
1124
1124
  });
1125
1125
  });
1126
+
1127
+ describe('quantitative axis minimum tick floor', () => {
1128
+ // A tight domain like [73, 88] makes D3's `scale.ticks(2)` collapse to a
1129
+ // single nice value ([80]). Before the floor, this rendered as a lone tick
1130
+ // (a real iPhone blog chart showed only "80"). The floor must hold even at
1131
+ // very narrow widths where the target count drops to 2.
1132
+ const tightDomainSpec: NormalizedChartSpec = {
1133
+ markType: 'bar',
1134
+ markDef: { type: 'bar', orient: 'horizontal' },
1135
+ data: [
1136
+ { district: 'A', score: 79 },
1137
+ { district: 'B', score: 73 },
1138
+ { district: 'C', score: 88 },
1139
+ ],
1140
+ encoding: {
1141
+ y: { field: 'district', type: 'nominal' },
1142
+ x: {
1143
+ field: 'score',
1144
+ type: 'quantitative',
1145
+ axis: { format: '.0f', grid: true },
1146
+ scale: { domain: [73, 88], nice: false },
1147
+ },
1148
+ },
1149
+ chrome: {},
1150
+ annotations: [],
1151
+ responsive: true,
1152
+ theme: {},
1153
+ darkMode: 'off',
1154
+ labels: { density: 'all', format: '.0f' },
1155
+ };
1156
+
1157
+ it('renders >= 2 ticks at a very narrow (320px-ish) chart width', () => {
1158
+ // Narrow chart area forces the minimal density target count down to 2,
1159
+ // where D3 collapses [73, 88] to the single tick "80" without the floor.
1160
+ const narrowArea = { x: 90, y: 50, width: 90, height: 180 };
1161
+ const scales = computeScales(tightDomainSpec, narrowArea, tightDomainSpec.data);
1162
+ const axes = computeAxes(scales, narrowArea, minimalStrategy, theme, undefined, {
1163
+ data: tightDomainSpec.data,
1164
+ encoding: tightDomainSpec.encoding as Encoding,
1165
+ markType: 'bar',
1166
+ });
1167
+
1168
+ expect(axes.x).toBeDefined();
1169
+ expect(axes.x!.ticks.length).toBeGreaterThanOrEqual(2);
1170
+ });
1171
+
1172
+ it('holds the 2-tick floor across a sweep of narrow widths', () => {
1173
+ for (const width of [60, 90, 120, 220, 320]) {
1174
+ const area = { x: 90, y: 50, width, height: 180 };
1175
+ const scales = computeScales(tightDomainSpec, area, tightDomainSpec.data);
1176
+ const axes = computeAxes(scales, area, minimalStrategy, theme, undefined, {
1177
+ data: tightDomainSpec.data,
1178
+ encoding: tightDomainSpec.encoding as Encoding,
1179
+ markType: 'bar',
1180
+ });
1181
+ expect(
1182
+ axes.x!.ticks.length,
1183
+ `expected >= 2 ticks at width ${width}, got ${axes.x!.ticks.length}`,
1184
+ ).toBeGreaterThanOrEqual(2);
1185
+ }
1186
+ });
1187
+ });
@@ -655,4 +655,67 @@ describe('bottom legend placement (defect-3 regression)', () => {
655
655
  expect(sourceAbsoluteY).toBeGreaterThan(legendBottom);
656
656
  expect(bylineAbsoluteY).toBeGreaterThan(sourceAbsoluteY);
657
657
  });
658
+
659
+ // Guards issue 2 (narrow-width space efficiency). The responsive metrics
660
+ // (scaled container padding, halved horizontal padding, collapsed legend/
661
+ // chrome gaps below the compact breakpoint) already keep the plot area's
662
+ // share of a phone-sized container within a couple of points of desktop.
663
+ // These floors lock that in: if a future change stops scaling padding down
664
+ // on narrow viewports, the plot share drops and the test fails.
665
+ describe('plot-area share stays efficient at narrow widths', () => {
666
+ const titledSpec: NormalizedChartSpec = {
667
+ ...baseSpec,
668
+ markType: 'bar',
669
+ markDef: { type: 'bar' },
670
+ encoding: {
671
+ x: { field: 'date', type: 'nominal' },
672
+ y: { field: 'value', type: 'quantitative' },
673
+ },
674
+ chrome: {
675
+ title: { text: 'A representative chart title' },
676
+ subtitle: { text: 'A subtitle line' },
677
+ source: { text: 'Source: somewhere' },
678
+ },
679
+ };
680
+
681
+ it('reserves at least 85% of container width for the plot at 375px', () => {
682
+ const dims = computeDimensions(
683
+ titledSpec,
684
+ { width: 375, height: 500 },
685
+ emptyLegend,
686
+ lightTheme,
687
+ );
688
+ expect(dims.chartArea.width / 375).toBeGreaterThanOrEqual(0.85);
689
+ });
690
+
691
+ it('reserves at least 55% of container height for the plot at 375px', () => {
692
+ const dims = computeDimensions(
693
+ titledSpec,
694
+ { width: 375, height: 500 },
695
+ emptyLegend,
696
+ lightTheme,
697
+ );
698
+ expect(dims.chartArea.height / 500).toBeGreaterThanOrEqual(0.55);
699
+ });
700
+
701
+ it('narrow plot width share is no worse than desktop', () => {
702
+ const narrow = computeDimensions(
703
+ titledSpec,
704
+ { width: 375, height: 500 },
705
+ emptyLegend,
706
+ lightTheme,
707
+ );
708
+ const desktop = computeDimensions(
709
+ titledSpec,
710
+ { width: 700, height: 500 },
711
+ emptyLegend,
712
+ lightTheme,
713
+ );
714
+ // Narrow viewports must not waste more horizontal space than desktop:
715
+ // the halved horizontal padding keeps the share on par (allow 2pt slack).
716
+ expect(narrow.chartArea.width / 375).toBeGreaterThanOrEqual(
717
+ desktop.chartArea.width / 700 - 0.02,
718
+ );
719
+ });
720
+ });
658
721
  });
@@ -91,14 +91,13 @@ export function extractBoxes(layout: ChartLayout): NamedRect[] {
91
91
  });
92
92
  }
93
93
 
94
- // Chrome elements: y interpretation depends on dominantBaseline.
95
- // 'hanging' = y is top of text; 'auto' = y is baseline (ascender ~80% of fontSize).
94
+ // Chrome elements store y as the TOP edge of the text box (renderers convert
95
+ // to the alphabetic baseline via textAscent), so y is the box top directly.
96
96
  for (const key of CHROME_KEYS) {
97
97
  const el = layout.chrome[key];
98
98
  if (el) {
99
99
  const h = el.style.fontSize * el.style.lineHeight;
100
- const top = el.style.dominantBaseline === 'hanging' ? el.y : el.y - el.style.fontSize * 0.8;
101
- boxes.push({ x: el.x, y: top, width: el.maxWidth, height: h, name: key });
100
+ boxes.push({ x: el.x, y: el.y, width: el.maxWidth, height: h, name: key });
102
101
  }
103
102
  }
104
103
 
@@ -229,8 +228,7 @@ export function checkLayoutInvariants(
229
228
  const el = layout.chrome[key];
230
229
  if (el) {
231
230
  const h = el.style.fontSize * el.style.lineHeight;
232
- const top = el.style.dominantBaseline === 'hanging' ? el.y : el.y - el.style.fontSize * 0.8;
233
- const chromeBox: Rect = { x: el.x, y: top, width: el.maxWidth, height: h };
231
+ const chromeBox: Rect = { x: el.x, y: el.y, width: el.maxWidth, height: h };
234
232
  if (rectsOverlap(xAxisBand, chromeBox, 0.5)) {
235
233
  violations.push(
236
234
  `x-axis labels intersect ${key}: xAxis=${formatRect(xAxisBand)} ${key}=${formatRect(chromeBox)}`,
@@ -1,4 +1,5 @@
1
1
  import type { ChartLayout } from '@opendata-ai/openchart-core';
2
+ import { HEIGHT_SHORT_MAX } from '@opendata-ai/openchart-core';
2
3
  import { afterAll, describe, expect, it } from 'vitest';
3
4
  import { compileChart } from '../compile';
4
5
  import { checkLayoutInvariants } from './helpers/invariants';
@@ -293,14 +294,28 @@ function generateSpec(rand: () => number): {
293
294
  // Fuzz test
294
295
  // ---------------------------------------------------------------------------
295
296
 
297
+ // Short-container threshold below which the rotated-label starvation family is
298
+ // tolerated (see the legend-overflow allowlist below).
299
+ const STARVATION_HEIGHT_MAX = HEIGHT_SHORT_MAX;
300
+
296
301
  // Known engine bugs that the fuzz legitimately catches. Filter these so the
297
- // suite passes while still failing on NEW violation types.
298
- function isKnownViolation(v: string): boolean {
299
- return (
300
- v.startsWith('legend entry ') ||
301
- v.startsWith('legend-entry-') ||
302
- v.startsWith('x-axis-labels outside SVG viewport')
303
- );
302
+ // suite passes while still failing on NEW violation types. `ctx` scopes the
303
+ // space-starvation allowlist so ordinary-size regressions still fail.
304
+ function isKnownViolation(v: string, ctx: { height: number; rotatedTicks: boolean }): boolean {
305
+ if (v.startsWith('legend entry ') || v.startsWith('legend-entry-')) return true;
306
+ if (v.startsWith('x-axis-labels outside SVG viewport')) return true;
307
+ // Space starvation at extreme aspect ratios: a very short container (e.g.
308
+ // 240px tall) with title chrome + auto-rotated long category labels + a
309
+ // bottom legend cannot fit everything. Reserving the correct rotated-label
310
+ // extent (textWidth*|sin θ| + lineHeight*|cos θ|) pushes the bottom legend
311
+ // past the viewport. The previous under-reservation only "fit" by letting
312
+ // rotated ticks overlap the legend/source, which was the very bug the extent
313
+ // fix addresses. Only tolerate it in that family (short container AND rotated
314
+ // ticks present); an ordinary-size legend overflow is a real regression.
315
+ if (v.startsWith('legend outside SVG viewport')) {
316
+ return ctx.height <= STARVATION_HEIGHT_MAX && ctx.rotatedTicks;
317
+ }
318
+ return false;
304
319
  }
305
320
 
306
321
  let compileSkipCount = 0;
@@ -326,7 +341,8 @@ describe('layout fuzz', () => {
326
341
  return;
327
342
  }
328
343
  const violations = checkLayoutInvariants(layout, { svgWidth: width, svgHeight: height });
329
- const novel = violations.filter((v) => !isKnownViolation(v));
344
+ const rotatedTicks = Math.abs(layout.axes.x?.tickAngle ?? 0) > 10;
345
+ const novel = violations.filter((v) => !isKnownViolation(v, { height, rotatedTicks }));
330
346
  if (novel.length > 0) {
331
347
  const msg = [
332
348
  `Seed: ${SEED}, Case: ${i}`,
@@ -179,8 +179,10 @@ describe('layout invariants', () => {
179
179
  );
180
180
  });
181
181
 
182
- // Rotated long labels overflow the SVG bottom edge
183
- it.fails('long labels, no chrome at 800px', () => {
182
+ // Rotated long labels used to overflow the SVG bottom edge; the rotated-
183
+ // label extent math (textWidth*|sin θ| + lineHeight*|cos θ|) now reserves
184
+ // the correct bottom margin.
185
+ it('long labels, no chrome at 800px', () => {
184
186
  compileAndCheck(
185
187
  {
186
188
  mark: 'bar',