@opendata-ai/openchart-engine 7.2.4 → 7.3.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 (37) hide show
  1. package/dist/index.d.ts +84 -36
  2. package/dist/index.js +4010 -2871
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +139 -63
  6. package/src/__tests__/axes.test.ts +67 -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 +7 -2
  10. package/src/__tests__/helpers/invariants.ts +267 -0
  11. package/src/__tests__/known-bugs.test.ts +113 -0
  12. package/src/__tests__/layout-fuzz.test.ts +342 -0
  13. package/src/__tests__/layout-invariants.test.ts +533 -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/post-process.ts +21 -7
  26. package/src/compile/layer.ts +31 -9
  27. package/src/compile.ts +85 -148
  28. package/src/index.ts +9 -0
  29. package/src/layout/axes.ts +93 -1
  30. package/src/layout/dimensions.ts +80 -98
  31. package/src/layout/plan.ts +524 -0
  32. package/src/layout/shared.ts +44 -0
  33. package/src/legend/compute.ts +315 -78
  34. package/src/legend/wrap.ts +11 -7
  35. package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
  36. package/src/tables/__tests__/heatmap.test.ts +24 -20
  37. package/src/tables/heatmap.ts +25 -9
@@ -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
  });
@@ -0,0 +1,267 @@
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: y interpretation depends on dominantBaseline.
95
+ // 'hanging' = y is top of text; 'auto' = y is baseline (ascender ~80% of fontSize).
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
+ 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 });
102
+ }
103
+ }
104
+
105
+ // Annotation bounds
106
+ for (let i = 0; i < layout.annotations.length; i++) {
107
+ const annotation = layout.annotations[i];
108
+ if (annotation.bounds) {
109
+ boxes.push({ ...annotation.bounds, name: `annotation-${i}` });
110
+ }
111
+ }
112
+
113
+ // Endpoint labels
114
+ if (layout.endpointLabels?.bounds) {
115
+ boxes.push({ ...layout.endpointLabels.bounds, name: 'endpoint-labels' });
116
+ }
117
+
118
+ return boxes;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Overlap check
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * Throws if two rects overlap by more than epsilon pixels.
127
+ */
128
+ export function assertNoOverlap(a: NamedRect, b: NamedRect, epsilon = 0.5): void {
129
+ const overlaps =
130
+ a.x < b.x + b.width - epsilon &&
131
+ a.x + a.width > b.x + epsilon &&
132
+ a.y < b.y + b.height - epsilon &&
133
+ a.y + a.height > b.y + epsilon;
134
+
135
+ if (overlaps) {
136
+ throw new Error(
137
+ `Overlap detected between "${a.name}" and "${b.name}": ` +
138
+ `a=${JSON.stringify({ x: a.x, y: a.y, width: a.width, height: a.height })}, ` +
139
+ `b=${JSON.stringify({ x: b.x, y: b.y, width: b.width, height: b.height })}`,
140
+ );
141
+ }
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Containment helper
146
+ // ---------------------------------------------------------------------------
147
+
148
+ function isContained(inner: Rect, outer: Rect, tolerance: number): boolean {
149
+ return (
150
+ inner.x >= outer.x - tolerance &&
151
+ inner.y >= outer.y - tolerance &&
152
+ inner.x + inner.width <= outer.x + outer.width + tolerance &&
153
+ inner.y + inner.height <= outer.y + outer.height + tolerance
154
+ );
155
+ }
156
+
157
+ function rectsOverlap(a: Rect, b: Rect, epsilon: number): boolean {
158
+ return (
159
+ a.x < b.x + b.width - epsilon &&
160
+ a.x + a.width > b.x + epsilon &&
161
+ a.y < b.y + b.height - epsilon &&
162
+ a.y + a.height > b.y + epsilon
163
+ );
164
+ }
165
+
166
+ function formatRect(r: Rect): string {
167
+ return JSON.stringify({ x: r.x, y: r.y, width: r.width, height: r.height });
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Full invariant check
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Returns a list of violation strings. Empty when clean.
176
+ */
177
+ export function checkLayoutInvariants(
178
+ layout: ChartLayout,
179
+ opts: { svgWidth: number; svgHeight: number },
180
+ ): string[] {
181
+ const violations: string[] = [];
182
+
183
+ // 1. Legend doesn't intrude into chartArea (top/bottom only; right/bottom-right
184
+ // use area.width reduction, so overlap is a horizontal concern checked by rule 4)
185
+ if (isCategoricalLegend(layout.legend)) {
186
+ const pos = layout.legend.position;
187
+ if (pos === 'top' || pos === 'bottom') {
188
+ if (rectsOverlap(layout.legend.bounds, layout.area, 0.5)) {
189
+ violations.push(
190
+ `legend intrudes into chartArea: legend=${formatRect(layout.legend.bounds)} chartArea=${formatRect(layout.area)}`,
191
+ );
192
+ }
193
+ }
194
+ }
195
+
196
+ // 2. Every legend entryPosition box lies inside legend bounds
197
+ if (isCategoricalLegend(layout.legend) && layout.legend.entryPositions) {
198
+ const rowHeight = layout.legend.rowHeight ?? 16;
199
+ // Entry width includes trailing entryGap; the last entry in a row doesn't
200
+ // need that gap to fit inside bounds, so allow entryGap as extra tolerance.
201
+ const gapTolerance = layout.legend.entryGap + 1;
202
+ for (let i = 0; i < layout.legend.entryPositions.length; i++) {
203
+ const pos = layout.legend.entryPositions[i];
204
+ const entryBox: Rect = {
205
+ x: pos.x,
206
+ y: pos.y,
207
+ width: pos.width,
208
+ height: rowHeight,
209
+ };
210
+ if (!isContained(entryBox, layout.legend.bounds, gapTolerance)) {
211
+ violations.push(
212
+ `legend entry ${i} outside legend bounds: entry=${formatRect(entryBox)} bounds=${formatRect(layout.legend.bounds)}`,
213
+ );
214
+ }
215
+ }
216
+ }
217
+
218
+ // 3. X-axis label band doesn't intersect bottom chrome
219
+ const xExtent = layout.axes.x?.extent;
220
+ if (xExtent && xExtent > 0) {
221
+ const xAxisBand: Rect = {
222
+ x: layout.area.x,
223
+ y: layout.area.y + layout.area.height,
224
+ width: layout.area.width,
225
+ height: xExtent,
226
+ };
227
+ const bottomChromeKeys = ['source', 'byline', 'footer'] as const;
228
+ for (const key of bottomChromeKeys) {
229
+ const el = layout.chrome[key];
230
+ if (el) {
231
+ 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 };
234
+ if (rectsOverlap(xAxisBand, chromeBox, 0.5)) {
235
+ violations.push(
236
+ `x-axis labels intersect ${key}: xAxis=${formatRect(xAxisBand)} ${key}=${formatRect(chromeBox)}`,
237
+ );
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ // 4. All boxes stay within SVG viewport
244
+ const viewport: Rect = {
245
+ x: 0,
246
+ y: 0,
247
+ width: opts.svgWidth,
248
+ height: opts.svgHeight,
249
+ };
250
+ const allBoxes = extractBoxes(layout);
251
+ for (const box of allBoxes) {
252
+ if (!isContained(box, viewport, 2)) {
253
+ violations.push(
254
+ `${box.name} outside SVG viewport: box=${formatRect(box)} viewport=${formatRect(viewport)}`,
255
+ );
256
+ }
257
+ }
258
+
259
+ // 5. Reserved margins cover drawn content
260
+ if (layout.area.y < layout.chrome.topHeight - 1) {
261
+ violations.push(
262
+ `top chrome overflows into chart area: area.y=${layout.area.y} chrome.topHeight=${layout.chrome.topHeight}`,
263
+ );
264
+ }
265
+
266
+ return violations;
267
+ }
@@ -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
+ });