@opendata-ai/openchart-engine 7.2.4 → 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.
Files changed (45) hide show
  1. package/dist/index.d.ts +88 -39
  2. package/dist/index.js +4134 -2930
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +143 -78
  6. package/src/__tests__/axes.test.ts +129 -1
  7. package/src/__tests__/compile-chart.test.ts +6 -0
  8. package/src/__tests__/compile-layer.test.ts +49 -0
  9. package/src/__tests__/dimensions.test.ts +70 -2
  10. package/src/__tests__/helpers/invariants.ts +265 -0
  11. package/src/__tests__/known-bugs.test.ts +113 -0
  12. package/src/__tests__/layout-fuzz.test.ts +358 -0
  13. package/src/__tests__/layout-invariants.test.ts +535 -0
  14. package/src/__tests__/legend.test.ts +37 -0
  15. package/src/annotations/__tests__/compute.test.ts +8 -15
  16. package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
  17. package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
  18. package/src/annotations/__tests__/placement.test.ts +316 -0
  19. package/src/annotations/collisions.ts +19 -7
  20. package/src/annotations/compute.ts +257 -16
  21. package/src/annotations/geometry.ts +73 -15
  22. package/src/annotations/placement.ts +588 -0
  23. package/src/annotations/resolve-text.ts +58 -10
  24. package/src/charts/__tests__/post-process.test.ts +3 -3
  25. package/src/charts/bar/__tests__/compute.test.ts +131 -45
  26. package/src/charts/bar/compute.ts +44 -4
  27. package/src/charts/bar/index.ts +9 -2
  28. package/src/charts/bar/labels.ts +77 -26
  29. package/src/charts/column/labels.ts +29 -16
  30. package/src/charts/post-process.ts +21 -7
  31. package/src/charts/registry.ts +4 -2
  32. package/src/compile/layer.ts +31 -9
  33. package/src/compile.ts +88 -149
  34. package/src/index.ts +9 -0
  35. package/src/layout/axes/rotation.ts +36 -0
  36. package/src/layout/axes.ts +130 -8
  37. package/src/layout/dimensions.ts +80 -98
  38. package/src/layout/plan.ts +550 -0
  39. package/src/layout/scales.ts +22 -0
  40. package/src/layout/shared.ts +44 -0
  41. package/src/legend/compute.ts +315 -78
  42. package/src/legend/wrap.ts +11 -7
  43. package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
  44. package/src/tables/__tests__/heatmap.test.ts +24 -20
  45. package/src/tables/heatmap.ts +25 -9
@@ -1,4 +1,4 @@
1
- import type { AxisTick, LayoutStrategy } from '@opendata-ai/openchart-core';
1
+ import type { AxisTick, Encoding, LayoutStrategy } from '@opendata-ai/openchart-core';
2
2
  import { resolveTheme } from '@opendata-ai/openchart-core';
3
3
  import { scaleLinear, scaleLog } from 'd3-scale';
4
4
  import { describe, expect, it } from 'vitest';
@@ -1057,3 +1057,131 @@ describe('axis tickPosition', () => {
1057
1057
  expect(axes.y!.tickPosition).toBe('gutter');
1058
1058
  });
1059
1059
  });
1060
+
1061
+ describe('axis extent and titlePosition', () => {
1062
+ it('emits x-axis extent for a basic line chart', () => {
1063
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
1064
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
1065
+
1066
+ expect(axes.x!.extent).toBeGreaterThan(0);
1067
+ expect(axes.x!.extent).toBeLessThan(200);
1068
+ });
1069
+
1070
+ it('extent is larger when axis has a title', () => {
1071
+ const specWithTitle: NormalizedChartSpec = {
1072
+ ...lineSpec,
1073
+ encoding: {
1074
+ ...lineSpec.encoding,
1075
+ x: { ...lineSpec.encoding.x, axis: { title: 'Year' } },
1076
+ },
1077
+ };
1078
+ const scales = computeScales(specWithTitle, chartArea, specWithTitle.data);
1079
+ const axesWithTitle = computeAxes(scales, chartArea, fullStrategy, theme);
1080
+
1081
+ const scalesNoTitle = computeScales(lineSpec, chartArea, lineSpec.data);
1082
+ const axesNoTitle = computeAxes(scalesNoTitle, chartArea, fullStrategy, theme);
1083
+
1084
+ expect(axesWithTitle.x!.extent).toBeGreaterThan(axesNoTitle.x!.extent!);
1085
+ });
1086
+
1087
+ it('emits titlePosition for x-axis with title', () => {
1088
+ const specWithTitle: NormalizedChartSpec = {
1089
+ ...lineSpec,
1090
+ encoding: {
1091
+ ...lineSpec.encoding,
1092
+ x: { ...lineSpec.encoding.x, axis: { title: 'Year' } },
1093
+ },
1094
+ };
1095
+ const scales = computeScales(specWithTitle, chartArea, specWithTitle.data);
1096
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
1097
+
1098
+ expect(axes.x!.titlePosition).toBeDefined();
1099
+ expect(axes.x!.titlePosition!.x).toBeCloseTo(chartArea.x + chartArea.width / 2, 0);
1100
+ expect(axes.x!.titlePosition!.y).toBeGreaterThan(chartArea.y + chartArea.height);
1101
+ });
1102
+
1103
+ it('emits titlePosition for y-axis with title', () => {
1104
+ const specWithYTitle: NormalizedChartSpec = {
1105
+ ...lineSpec,
1106
+ encoding: {
1107
+ ...lineSpec.encoding,
1108
+ y: { ...lineSpec.encoding.y, axis: { title: 'Value' } },
1109
+ },
1110
+ };
1111
+ const scales = computeScales(specWithYTitle, chartArea, specWithYTitle.data);
1112
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
1113
+ data: specWithYTitle.data,
1114
+ encoding: specWithYTitle.encoding as Encoding,
1115
+ skipX: false,
1116
+ skipY: false,
1117
+ markType: 'line',
1118
+ totalWidth: 600,
1119
+ });
1120
+
1121
+ expect(axes.y!.titlePosition).toBeDefined();
1122
+ expect(axes.y!.titlePosition!.x).toBeLessThan(chartArea.x);
1123
+ expect(axes.y!.titlePosition!.angle).toBe(-90);
1124
+ });
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
+ });
@@ -123,6 +123,12 @@ describe('compileChart', () => {
123
123
  expect(layout.chrome.source!.style.fontSize).toBeGreaterThan(0);
124
124
  });
125
125
 
126
+ it('emits bottomAnchorY below the chart area', () => {
127
+ const layout = compileChart(lineSpec, { width: 600, height: 400 });
128
+ expect(layout.chrome.bottomAnchorY).toBeDefined();
129
+ expect(layout.chrome.bottomAnchorY).toBeGreaterThanOrEqual(layout.area.y + layout.area.height);
130
+ });
131
+
126
132
  it('has axes with ticks and valid positions for a line chart', () => {
127
133
  const layout = compileChart(lineSpec, { width: 600, height: 400 });
128
134
 
@@ -391,6 +391,55 @@ describe('compileLayer', () => {
391
391
  expect(layout.axes.x).toBeDefined();
392
392
  });
393
393
 
394
+ it('positions y2 axis title on the right side of the chart', () => {
395
+ const spec: LayerSpec = {
396
+ resolve: { scale: { y: 'independent' } },
397
+ layer: [
398
+ {
399
+ mark: 'bar' as const,
400
+ data: [
401
+ { year: '2025', revenue: 10_000_000 },
402
+ { year: '2026', revenue: 15_000_000 },
403
+ ],
404
+ encoding: {
405
+ x: { field: 'year', type: 'ordinal' as const },
406
+ y: { field: 'revenue', type: 'quantitative' as const, axis: { title: 'Revenue ($)' } },
407
+ },
408
+ },
409
+ {
410
+ mark: 'line' as const,
411
+ data: [
412
+ { year: '2025', enrollment: 30_000 },
413
+ { year: '2026', enrollment: 40_000 },
414
+ ],
415
+ encoding: {
416
+ x: { field: 'year', type: 'ordinal' as const },
417
+ y: {
418
+ field: 'enrollment',
419
+ type: 'quantitative' as const,
420
+ axis: { title: 'Enrollment' },
421
+ },
422
+ },
423
+ },
424
+ ],
425
+ };
426
+
427
+ const layout = compileLayer(spec, compileOpts);
428
+ const y1Title = layout.axes.y!.titlePosition;
429
+ const y2Title = layout.axes.y2!.titlePosition;
430
+
431
+ expect(y1Title).toBeDefined();
432
+ expect(y2Title).toBeDefined();
433
+
434
+ // y1 title should be left of chart area, y2 title should be right
435
+ expect(y1Title!.x).toBeLessThan(layout.area.x);
436
+ expect(y2Title!.x).toBeGreaterThan(layout.area.x + layout.area.width);
437
+
438
+ // y2 title should rotate clockwise (90), y1 counter-clockwise (-90)
439
+ expect(y1Title!.angle).toBe(-90);
440
+ expect(y2Title!.angle).toBe(90);
441
+ });
442
+
394
443
  it('tags layer-1 marks with yScale y2', () => {
395
444
  const spec: LayerSpec = {
396
445
  resolve: { scale: { y: 'independent' } },
@@ -182,7 +182,11 @@ describe('computeDimensions', () => {
182
182
  ],
183
183
  encoding: {
184
184
  x: { field: 'date', type: 'temporal' },
185
- y: { field: 'value', type: 'quantitative', axis: { title: 'Revenue ($)' } },
185
+ y: {
186
+ field: 'value',
187
+ type: 'quantitative',
188
+ axis: { title: 'Revenue ($)', format: ',.0f' },
189
+ },
186
190
  },
187
191
  };
188
192
 
@@ -199,7 +203,8 @@ describe('computeDimensions', () => {
199
203
  lightTheme,
200
204
  );
201
205
 
202
- // Larger numeric values produce wider tick labels (e.g. "1.5M" vs "0.0"),
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,
203
208
  // so the y-axis title margin should grow to keep clearance
204
209
  expect(dimsLarge.margins.left).toBeGreaterThan(dimsSmall.margins.left);
205
210
  });
@@ -650,4 +655,67 @@ describe('bottom legend placement (defect-3 regression)', () => {
650
655
  expect(sourceAbsoluteY).toBeGreaterThan(legendBottom);
651
656
  expect(bylineAbsoluteY).toBeGreaterThan(sourceAbsoluteY);
652
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
+ });
653
721
  });
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Layout invariant assertions.
3
+ *
4
+ * Extracts bounding boxes from a compiled ChartLayout and checks that
5
+ * layout elements don't overlap where they shouldn't.
6
+ */
7
+
8
+ import type {
9
+ CategoricalLegendLayout,
10
+ ChartLayout,
11
+ LegendLayout,
12
+ Rect,
13
+ } from '@opendata-ai/openchart-core';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface NamedRect extends Rect {
20
+ name: string;
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Type guards
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function isCategoricalLegend(legend: LegendLayout): legend is CategoricalLegendLayout {
28
+ return 'entries' in legend && (!legend.type || legend.type === 'categorical');
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Box extraction
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const CHROME_KEYS = [
36
+ 'eyebrow',
37
+ 'title',
38
+ 'subtitle',
39
+ 'source',
40
+ 'byline',
41
+ 'footer',
42
+ 'brand',
43
+ ] as const;
44
+
45
+ /**
46
+ * Extracts every layout element's bounding box as a NamedRect.
47
+ */
48
+ export function extractBoxes(layout: ChartLayout): NamedRect[] {
49
+ const boxes: NamedRect[] = [];
50
+
51
+ // Chart area
52
+ boxes.push({ ...layout.area, name: 'chartArea' });
53
+
54
+ // Legend bounds
55
+ boxes.push({ ...layout.legend.bounds, name: 'legend' });
56
+
57
+ // Legend entry positions (categorical only)
58
+ if (isCategoricalLegend(layout.legend) && layout.legend.entryPositions) {
59
+ const rowHeight = layout.legend.rowHeight ?? 16;
60
+ for (let i = 0; i < layout.legend.entryPositions.length; i++) {
61
+ const pos = layout.legend.entryPositions[i];
62
+ boxes.push({
63
+ x: pos.x,
64
+ y: pos.y,
65
+ width: pos.width,
66
+ height: rowHeight,
67
+ name: `legend-entry-${i}`,
68
+ });
69
+ }
70
+ }
71
+
72
+ // X-axis label band
73
+ if (layout.axes.x?.extent && layout.axes.x.extent > 0) {
74
+ boxes.push({
75
+ x: layout.area.x,
76
+ y: layout.area.y + layout.area.height,
77
+ width: layout.area.width,
78
+ height: layout.axes.x.extent,
79
+ name: 'x-axis-labels',
80
+ });
81
+ }
82
+
83
+ // Y-axis label band
84
+ if (layout.axes.y?.extent && layout.axes.y.extent > 0) {
85
+ boxes.push({
86
+ x: layout.area.x - layout.axes.y.extent,
87
+ y: layout.area.y,
88
+ width: layout.axes.y.extent,
89
+ height: layout.area.height,
90
+ name: 'y-axis-labels',
91
+ });
92
+ }
93
+
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
+ for (const key of CHROME_KEYS) {
97
+ const el = layout.chrome[key];
98
+ if (el) {
99
+ const h = el.style.fontSize * el.style.lineHeight;
100
+ boxes.push({ x: el.x, y: el.y, width: el.maxWidth, height: h, name: key });
101
+ }
102
+ }
103
+
104
+ // Annotation bounds
105
+ for (let i = 0; i < layout.annotations.length; i++) {
106
+ const annotation = layout.annotations[i];
107
+ if (annotation.bounds) {
108
+ boxes.push({ ...annotation.bounds, name: `annotation-${i}` });
109
+ }
110
+ }
111
+
112
+ // Endpoint labels
113
+ if (layout.endpointLabels?.bounds) {
114
+ boxes.push({ ...layout.endpointLabels.bounds, name: 'endpoint-labels' });
115
+ }
116
+
117
+ return boxes;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Overlap check
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Throws if two rects overlap by more than epsilon pixels.
126
+ */
127
+ export function assertNoOverlap(a: NamedRect, b: NamedRect, epsilon = 0.5): void {
128
+ const overlaps =
129
+ a.x < b.x + b.width - epsilon &&
130
+ a.x + a.width > b.x + epsilon &&
131
+ a.y < b.y + b.height - epsilon &&
132
+ a.y + a.height > b.y + epsilon;
133
+
134
+ if (overlaps) {
135
+ throw new Error(
136
+ `Overlap detected between "${a.name}" and "${b.name}": ` +
137
+ `a=${JSON.stringify({ x: a.x, y: a.y, width: a.width, height: a.height })}, ` +
138
+ `b=${JSON.stringify({ x: b.x, y: b.y, width: b.width, height: b.height })}`,
139
+ );
140
+ }
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Containment helper
145
+ // ---------------------------------------------------------------------------
146
+
147
+ function isContained(inner: Rect, outer: Rect, tolerance: number): boolean {
148
+ return (
149
+ inner.x >= outer.x - tolerance &&
150
+ inner.y >= outer.y - tolerance &&
151
+ inner.x + inner.width <= outer.x + outer.width + tolerance &&
152
+ inner.y + inner.height <= outer.y + outer.height + tolerance
153
+ );
154
+ }
155
+
156
+ function rectsOverlap(a: Rect, b: Rect, epsilon: number): boolean {
157
+ return (
158
+ a.x < b.x + b.width - epsilon &&
159
+ a.x + a.width > b.x + epsilon &&
160
+ a.y < b.y + b.height - epsilon &&
161
+ a.y + a.height > b.y + epsilon
162
+ );
163
+ }
164
+
165
+ function formatRect(r: Rect): string {
166
+ return JSON.stringify({ x: r.x, y: r.y, width: r.width, height: r.height });
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Full invariant check
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * Returns a list of violation strings. Empty when clean.
175
+ */
176
+ export function checkLayoutInvariants(
177
+ layout: ChartLayout,
178
+ opts: { svgWidth: number; svgHeight: number },
179
+ ): string[] {
180
+ const violations: string[] = [];
181
+
182
+ // 1. Legend doesn't intrude into chartArea (top/bottom only; right/bottom-right
183
+ // use area.width reduction, so overlap is a horizontal concern checked by rule 4)
184
+ if (isCategoricalLegend(layout.legend)) {
185
+ const pos = layout.legend.position;
186
+ if (pos === 'top' || pos === 'bottom') {
187
+ if (rectsOverlap(layout.legend.bounds, layout.area, 0.5)) {
188
+ violations.push(
189
+ `legend intrudes into chartArea: legend=${formatRect(layout.legend.bounds)} chartArea=${formatRect(layout.area)}`,
190
+ );
191
+ }
192
+ }
193
+ }
194
+
195
+ // 2. Every legend entryPosition box lies inside legend bounds
196
+ if (isCategoricalLegend(layout.legend) && layout.legend.entryPositions) {
197
+ const rowHeight = layout.legend.rowHeight ?? 16;
198
+ // Entry width includes trailing entryGap; the last entry in a row doesn't
199
+ // need that gap to fit inside bounds, so allow entryGap as extra tolerance.
200
+ const gapTolerance = layout.legend.entryGap + 1;
201
+ for (let i = 0; i < layout.legend.entryPositions.length; i++) {
202
+ const pos = layout.legend.entryPositions[i];
203
+ const entryBox: Rect = {
204
+ x: pos.x,
205
+ y: pos.y,
206
+ width: pos.width,
207
+ height: rowHeight,
208
+ };
209
+ if (!isContained(entryBox, layout.legend.bounds, gapTolerance)) {
210
+ violations.push(
211
+ `legend entry ${i} outside legend bounds: entry=${formatRect(entryBox)} bounds=${formatRect(layout.legend.bounds)}`,
212
+ );
213
+ }
214
+ }
215
+ }
216
+
217
+ // 3. X-axis label band doesn't intersect bottom chrome
218
+ const xExtent = layout.axes.x?.extent;
219
+ if (xExtent && xExtent > 0) {
220
+ const xAxisBand: Rect = {
221
+ x: layout.area.x,
222
+ y: layout.area.y + layout.area.height,
223
+ width: layout.area.width,
224
+ height: xExtent,
225
+ };
226
+ const bottomChromeKeys = ['source', 'byline', 'footer'] as const;
227
+ for (const key of bottomChromeKeys) {
228
+ const el = layout.chrome[key];
229
+ if (el) {
230
+ const h = el.style.fontSize * el.style.lineHeight;
231
+ const chromeBox: Rect = { x: el.x, y: el.y, width: el.maxWidth, height: h };
232
+ if (rectsOverlap(xAxisBand, chromeBox, 0.5)) {
233
+ violations.push(
234
+ `x-axis labels intersect ${key}: xAxis=${formatRect(xAxisBand)} ${key}=${formatRect(chromeBox)}`,
235
+ );
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ // 4. All boxes stay within SVG viewport
242
+ const viewport: Rect = {
243
+ x: 0,
244
+ y: 0,
245
+ width: opts.svgWidth,
246
+ height: opts.svgHeight,
247
+ };
248
+ const allBoxes = extractBoxes(layout);
249
+ for (const box of allBoxes) {
250
+ if (!isContained(box, viewport, 2)) {
251
+ violations.push(
252
+ `${box.name} outside SVG viewport: box=${formatRect(box)} viewport=${formatRect(viewport)}`,
253
+ );
254
+ }
255
+ }
256
+
257
+ // 5. Reserved margins cover drawn content
258
+ if (layout.area.y < layout.chrome.topHeight - 1) {
259
+ violations.push(
260
+ `top chrome overflows into chart area: area.y=${layout.area.y} chrome.topHeight=${layout.chrome.topHeight}`,
261
+ );
262
+ }
263
+
264
+ return violations;
265
+ }
@@ -0,0 +1,113 @@
1
+ import type { ChartLayout, LineMark } from '@opendata-ai/openchart-core';
2
+ import { estimateTextWidth } from '@opendata-ai/openchart-core';
3
+ import { describe, expect, test } from 'vitest';
4
+ import { compileChart } from '../compile';
5
+
6
+ describe('known layout bugs', () => {
7
+ test('annotation label does not overlap the line it annotates', () => {
8
+ // Generate ~25 smooth sine-wave data points over two years
9
+ const data: { date: string; value: number }[] = [];
10
+ const startDate = new Date('2022-01-01');
11
+ for (let i = 0; i < 25; i++) {
12
+ const d = new Date(startDate);
13
+ d.setDate(d.getDate() + i * 30); // ~monthly
14
+ // Sine wave: value ranges ~20-80 with a peak near i=6 (mid-2022)
15
+ const value = 50 + 30 * Math.sin((i / 25) * 2 * Math.PI);
16
+ data.push({ date: d.toISOString().slice(0, 10), value });
17
+ }
18
+
19
+ // The peak is near i=6 (~July 2022), where sin peaks
20
+ const peakIndex = 6;
21
+ const peakDate = data[peakIndex].date;
22
+ const peakValue = data[peakIndex].value;
23
+
24
+ const spec = {
25
+ mark: 'line' as const,
26
+ data,
27
+ encoding: {
28
+ x: { field: 'date' as const, type: 'temporal' as const },
29
+ y: { field: 'value' as const, type: 'quantitative' as const },
30
+ },
31
+ annotations: [
32
+ {
33
+ type: 'text' as const,
34
+ x: peakDate,
35
+ y: peakValue,
36
+ text: 'Peak season',
37
+ },
38
+ ],
39
+ };
40
+
41
+ const layout: ChartLayout = compileChart(spec, { width: 600, height: 400 });
42
+
43
+ // Find the annotation
44
+ const annotation = layout.annotations[0];
45
+ expect(annotation).toBeDefined();
46
+ expect(annotation.label).toBeDefined();
47
+
48
+ const label = annotation.label!;
49
+ const fontSize = label.style.fontSize;
50
+ const fontWeight = label.style.fontWeight;
51
+
52
+ // Estimate the label bounding box
53
+ const labelWidth = estimateTextWidth(label.text, fontSize, fontWeight);
54
+ const labelHeight = fontSize * 1.3;
55
+
56
+ // Label bounding box (y is the baseline, so top of text is y - fontSize)
57
+ const labelLeft =
58
+ label.style.textAnchor === 'middle'
59
+ ? label.x - labelWidth / 2
60
+ : label.style.textAnchor === 'end'
61
+ ? label.x - labelWidth
62
+ : label.x;
63
+ const labelRight = labelLeft + labelWidth;
64
+ const labelTop = label.y - fontSize;
65
+ const labelBottom = labelTop + labelHeight;
66
+
67
+ // Find the LineMark
68
+ const lineMark = layout.marks.find((m): m is LineMark => m.type === 'line');
69
+ expect(lineMark).toBeDefined();
70
+
71
+ const points = lineMark!.points;
72
+
73
+ // Find line segments that fall within the label's x-range
74
+ let lineMinY = Infinity;
75
+ let lineMaxY = -Infinity;
76
+
77
+ for (let i = 0; i < points.length - 1; i++) {
78
+ const p1 = points[i];
79
+ const p2 = points[i + 1];
80
+
81
+ // Check if this segment overlaps the label's x-range
82
+ const segLeft = Math.min(p1.x, p2.x);
83
+ const segRight = Math.max(p1.x, p2.x);
84
+
85
+ if (segRight >= labelLeft && segLeft <= labelRight) {
86
+ // This segment is within the label's x-range
87
+ // Find the y-range of the line within the label's x-span
88
+ const overlapLeft = Math.max(segLeft, labelLeft);
89
+ const overlapRight = Math.min(segRight, labelRight);
90
+
91
+ // Interpolate y values at the overlap boundaries
92
+ const segWidth = p2.x - p1.x;
93
+ if (Math.abs(segWidth) < 0.001) {
94
+ lineMinY = Math.min(lineMinY, p1.y, p2.y);
95
+ lineMaxY = Math.max(lineMaxY, p1.y, p2.y);
96
+ } else {
97
+ const t1 = (overlapLeft - p1.x) / segWidth;
98
+ const t2 = (overlapRight - p1.x) / segWidth;
99
+ const y1 = p1.y + t1 * (p2.y - p1.y);
100
+ const y2 = p1.y + t2 * (p2.y - p1.y);
101
+ lineMinY = Math.min(lineMinY, y1, y2);
102
+ lineMaxY = Math.max(lineMaxY, y1, y2);
103
+ }
104
+ }
105
+ }
106
+
107
+ // The label bounding box should NOT vertically overlap with the line's y-range.
108
+ // Today the label sits only ANCHOR_OFFSET (8px) from its anchor point ON the line,
109
+ // so the label box almost certainly overlaps the line path.
110
+ const verticallyOverlaps = labelTop < lineMaxY && labelBottom > lineMinY;
111
+ expect(verticallyOverlaps).toBe(false);
112
+ });
113
+ });