@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21

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 (145) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/chunk-DCODCLWI.js +234 -0
  3. package/dist/chunk-DCODCLWI.js.map +1 -0
  4. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  5. package/dist/chunk-EKAUQAAX.js.map +1 -0
  6. package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
  7. package/dist/chunk-KNT5AZ2O.js.map +1 -0
  8. package/dist/chunk-RM5XS6NR.js +113 -0
  9. package/dist/chunk-RM5XS6NR.js.map +1 -0
  10. package/dist/chunk-T32UHMZA.js +282 -0
  11. package/dist/chunk-T32UHMZA.js.map +1 -0
  12. package/dist/export-gif.d.ts +113 -0
  13. package/dist/export-gif.js +21 -0
  14. package/dist/export-gif.js.map +1 -0
  15. package/dist/index.d.ts +283 -24
  16. package/dist/index.js +2012 -763
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.js +7 -69
  21. package/dist/static.js.map +1 -1
  22. package/dist/story/index.d.ts +3 -2
  23. package/dist/story/index.js +5 -3
  24. package/dist/story/index.js.map +1 -1
  25. package/dist/styles.css +1 -1
  26. package/dist/styles.css.map +1 -1
  27. package/package.json +14 -6
  28. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  29. package/dist/chunk-ENXSUXC5.js.map +0 -1
  30. package/src/__test-fixtures__/dom.ts +0 -70
  31. package/src/__test-fixtures__/specs.ts +0 -214
  32. package/src/__tests__/animation.test.ts +0 -358
  33. package/src/__tests__/annotations-render.test.ts +0 -201
  34. package/src/__tests__/compound-labels.test.ts +0 -122
  35. package/src/__tests__/continuous-legend.test.ts +0 -91
  36. package/src/__tests__/crosshair.test.ts +0 -130
  37. package/src/__tests__/edit-events.test.ts +0 -902
  38. package/src/__tests__/events.test.ts +0 -387
  39. package/src/__tests__/export.test.ts +0 -241
  40. package/src/__tests__/font-race.test.ts +0 -199
  41. package/src/__tests__/gradient-ids.test.ts +0 -159
  42. package/src/__tests__/gradient-utils.test.ts +0 -125
  43. package/src/__tests__/known-bugs-render.test.ts +0 -183
  44. package/src/__tests__/map.test.ts +0 -293
  45. package/src/__tests__/mount.test.ts +0 -357
  46. package/src/__tests__/pattern-fills.test.ts +0 -105
  47. package/src/__tests__/resize-timing.test.ts +0 -218
  48. package/src/__tests__/sankey.test.ts +0 -133
  49. package/src/__tests__/selection-events.test.ts +0 -869
  50. package/src/__tests__/series-search.test.ts +0 -311
  51. package/src/__tests__/static.test.ts +0 -141
  52. package/src/__tests__/svg-renderer.test.ts +0 -895
  53. package/src/__tests__/table-keyboard.test.ts +0 -361
  54. package/src/__tests__/table-mount.test.ts +0 -639
  55. package/src/__tests__/tilemap.test.ts +0 -158
  56. package/src/__tests__/tooltip.test.ts +0 -337
  57. package/src/__tests__/transition.test.ts +0 -1777
  58. package/src/__tests__/you-draw-it.test.ts +0 -273
  59. package/src/animation-vars.ts +0 -42
  60. package/src/animation.ts +0 -76
  61. package/src/barlist-mount.ts +0 -383
  62. package/src/barlist-renderer.ts +0 -267
  63. package/src/dom-helpers.ts +0 -17
  64. package/src/export.ts +0 -482
  65. package/src/gradient-utils.ts +0 -138
  66. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  67. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  68. package/src/graph/__tests__/interaction.test.ts +0 -205
  69. package/src/graph/__tests__/keyboard.test.ts +0 -653
  70. package/src/graph/__tests__/search.test.ts +0 -88
  71. package/src/graph/__tests__/simulation.test.ts +0 -233
  72. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  73. package/src/graph/__tests__/zoom.test.ts +0 -203
  74. package/src/graph/canvas-renderer.ts +0 -747
  75. package/src/graph/interaction.ts +0 -367
  76. package/src/graph/keyboard.ts +0 -208
  77. package/src/graph/search.ts +0 -50
  78. package/src/graph/simulation-worker-url.ts +0 -12
  79. package/src/graph/simulation-worker.ts +0 -278
  80. package/src/graph/simulation.ts +0 -422
  81. package/src/graph/spatial-index.ts +0 -121
  82. package/src/graph/types.ts +0 -47
  83. package/src/graph/worker-protocol.ts +0 -70
  84. package/src/graph/zoom.ts +0 -134
  85. package/src/graph-mount.ts +0 -888
  86. package/src/index.ts +0 -76
  87. package/src/interactions/chart-events.ts +0 -139
  88. package/src/interactions/crosshair.ts +0 -233
  89. package/src/interactions/drag-handler.ts +0 -175
  90. package/src/interactions/editing-drags.ts +0 -515
  91. package/src/interactions/index.ts +0 -25
  92. package/src/interactions/keyboard-nav.ts +0 -111
  93. package/src/interactions/legend-interaction.ts +0 -46
  94. package/src/interactions/selection.ts +0 -280
  95. package/src/interactions/tooltip-events.ts +0 -72
  96. package/src/map-camera.ts +0 -88
  97. package/src/map-mount.ts +0 -701
  98. package/src/map-renderer.ts +0 -338
  99. package/src/map-transition.ts +0 -83
  100. package/src/measure-text.ts +0 -134
  101. package/src/mount.ts +0 -1382
  102. package/src/pattern-utils.ts +0 -110
  103. package/src/renderers/annotations.ts +0 -388
  104. package/src/renderers/axes.ts +0 -351
  105. package/src/renderers/brand.ts +0 -87
  106. package/src/renderers/chrome.ts +0 -196
  107. package/src/renderers/endpoint-labels.ts +0 -150
  108. package/src/renderers/legend.ts +0 -311
  109. package/src/renderers/marks.ts +0 -561
  110. package/src/renderers/metrics.ts +0 -63
  111. package/src/renderers/svg-dom.ts +0 -40
  112. package/src/renderers/table-cells.ts +0 -446
  113. package/src/resize-observer.ts +0 -46
  114. package/src/sankey-mount.ts +0 -622
  115. package/src/sankey-renderer.ts +0 -614
  116. package/src/series-search.ts +0 -392
  117. package/src/static.ts +0 -251
  118. package/src/story/__tests__/camera-math.test.ts +0 -145
  119. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  120. package/src/story/__tests__/progress-math.test.ts +0 -95
  121. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  122. package/src/story/__tests__/tween.test.ts +0 -159
  123. package/src/story/camera-math.ts +0 -125
  124. package/src/story/create-chart-story.ts +0 -353
  125. package/src/story/crossfade.ts +0 -70
  126. package/src/story/index.ts +0 -39
  127. package/src/story/progress-math.ts +0 -85
  128. package/src/story/resolve-camera-target.ts +0 -119
  129. package/src/story/scroll-driver.ts +0 -206
  130. package/src/story/story-camera.ts +0 -49
  131. package/src/story/tween.ts +0 -112
  132. package/src/story/types.ts +0 -76
  133. package/src/svg-ids.ts +0 -23
  134. package/src/svg-renderer.ts +0 -383
  135. package/src/table-keyboard.ts +0 -266
  136. package/src/table-mount.ts +0 -567
  137. package/src/table-renderer.ts +0 -414
  138. package/src/text-edit-overlay.ts +0 -255
  139. package/src/theme-tokens.ts +0 -51
  140. package/src/tilemap-mount.ts +0 -511
  141. package/src/tilemap-renderer.ts +0 -396
  142. package/src/tooltip.ts +0 -177
  143. package/src/transition.ts +0 -2816
  144. package/src/you-draw-it.ts +0 -480
  145. /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
@@ -1,895 +0,0 @@
1
- /**
2
- * SVG renderer integration tests.
3
- *
4
- * Tests the full pipeline: spec -> compileChart -> renderChartSVG -> DOM.
5
- * Verifies that each chart type produces the correct SVG mark elements,
6
- * and that chart furniture (chrome, axes, legend, gridlines) renders properly.
7
- */
8
-
9
- import { AXIS_TITLE_GAP, estimateTextWidth, TICK_LABEL_OFFSET } from '@opendata-ai/openchart-core';
10
- import type { ChartSpec, CompileOptions } from '@opendata-ai/openchart-engine';
11
- import { compileChart } from '@opendata-ai/openchart-engine';
12
- import { afterEach, describe, expect, it } from 'vitest';
13
- import { createContainer } from '../__test-fixtures__/dom';
14
- import {
15
- barSpec,
16
- columnSpec,
17
- lineSpec,
18
- multiSeriesBarSpec,
19
- pieSpec,
20
- scatterSpec,
21
- singleSeriesLineSpec,
22
- } from '../__test-fixtures__/specs';
23
- import { renderChartSVG } from '../svg-renderer';
24
-
25
- // ---------------------------------------------------------------------------
26
- // Helpers
27
- // ---------------------------------------------------------------------------
28
-
29
- const COMPILE_OPTS: CompileOptions = { width: 600, height: 400 };
30
-
31
- /**
32
- * Compile a spec and render into a fresh container.
33
- * Returns both the SVG element and the container for querying.
34
- */
35
- function renderSpec(spec: ChartSpec, opts: CompileOptions = COMPILE_OPTS) {
36
- const container = createContainer(opts.width, opts.height);
37
- const layout = compileChart(spec, opts);
38
- const svg = renderChartSVG(layout, container);
39
- return { svg, container, layout };
40
- }
41
-
42
- // ---------------------------------------------------------------------------
43
- // Cleanup
44
- // ---------------------------------------------------------------------------
45
-
46
- afterEach(() => {
47
- document.body.innerHTML = '';
48
- });
49
-
50
- // ---------------------------------------------------------------------------
51
- // Line chart marks
52
- // ---------------------------------------------------------------------------
53
-
54
- describe('line chart SVG rendering', () => {
55
- it('renders <path> elements with valid d attribute for each series', () => {
56
- const { svg } = renderSpec(lineSpec);
57
- const paths = svg.querySelectorAll('.oc-mark-line path');
58
- expect(paths.length).toBeGreaterThan(0);
59
-
60
- for (const path of paths) {
61
- const d = path.getAttribute('d');
62
- expect(d).not.toBeNull();
63
- // d attribute should start with M (moveTo) and contain curve commands
64
- expect(d).toMatch(/^M/);
65
- expect(d!.length).toBeGreaterThan(5);
66
- }
67
- });
68
-
69
- it('creates a mark group per series in multi-series line chart', () => {
70
- const { svg } = renderSpec(lineSpec);
71
- const lineGroups = svg.querySelectorAll('.oc-mark-line');
72
- // lineSpec has 2 series (US and UK)
73
- expect(lineGroups.length).toBe(2);
74
- });
75
-
76
- it('each line mark group has a data-mark-id attribute', () => {
77
- const { svg } = renderSpec(lineSpec);
78
- const lineGroups = svg.querySelectorAll('.oc-mark-line');
79
- for (const group of lineGroups) {
80
- const markId = group.getAttribute('data-mark-id');
81
- expect(markId).not.toBeNull();
82
- expect(markId).toMatch(/^line-/);
83
- }
84
- });
85
-
86
- it('each line mark group has a data-series attribute', () => {
87
- const { svg } = renderSpec(lineSpec);
88
- const lineGroups = svg.querySelectorAll('.oc-mark-line');
89
- const seriesNames = new Set<string>();
90
- for (const group of lineGroups) {
91
- const series = group.getAttribute('data-series');
92
- expect(series).not.toBeNull();
93
- seriesNames.add(series!);
94
- }
95
- expect(seriesNames.has('US')).toBe(true);
96
- expect(seriesNames.has('UK')).toBe(true);
97
- });
98
-
99
- it('paths have stroke color and non-zero stroke width', () => {
100
- const { svg } = renderSpec(lineSpec);
101
- const paths = svg.querySelectorAll('.oc-mark-line path');
102
- for (const path of paths) {
103
- const stroke = path.getAttribute('stroke');
104
- expect(stroke).not.toBeNull();
105
- expect(stroke).not.toBe('none');
106
- const strokeWidth = Number(path.getAttribute('stroke-width'));
107
- expect(strokeWidth).toBeGreaterThan(0);
108
- }
109
- });
110
- });
111
-
112
- // ---------------------------------------------------------------------------
113
- // Bar chart marks
114
- // ---------------------------------------------------------------------------
115
-
116
- describe('bar chart SVG rendering', () => {
117
- it('renders <rect> elements for each data point', () => {
118
- const { svg } = renderSpec(barSpec);
119
- const rects = svg.querySelectorAll('.oc-mark-rect rect');
120
- // barSpec has 3 data points
121
- expect(rects.length).toBe(3);
122
- });
123
-
124
- it('rect elements have width and height > 0', () => {
125
- const { svg } = renderSpec(barSpec);
126
- const rects = svg.querySelectorAll('.oc-mark-rect rect');
127
- for (const rect of rects) {
128
- const width = Number(rect.getAttribute('width'));
129
- const height = Number(rect.getAttribute('height'));
130
- expect(width).toBeGreaterThan(0);
131
- expect(height).toBeGreaterThan(0);
132
- }
133
- });
134
-
135
- it('rect marks have data-mark-id attributes', () => {
136
- const { svg } = renderSpec(barSpec);
137
- const markGroups = svg.querySelectorAll('.oc-mark-rect');
138
- for (const group of markGroups) {
139
- const markId = group.getAttribute('data-mark-id');
140
- expect(markId).not.toBeNull();
141
- expect(markId).toMatch(/^rect-/);
142
- }
143
- });
144
-
145
- it('bar rects are oriented horizontally (width varies, y is categorical)', () => {
146
- const { svg } = renderSpec(barSpec);
147
- const rects = svg.querySelectorAll('.oc-mark-rect rect');
148
- const widths = Array.from(rects).map((r) => Number(r.getAttribute('width')));
149
- // Different data values should produce different widths
150
- const uniqueWidths = new Set(widths);
151
- expect(uniqueWidths.size).toBeGreaterThan(1);
152
- });
153
- });
154
-
155
- // ---------------------------------------------------------------------------
156
- // Column chart marks
157
- // ---------------------------------------------------------------------------
158
-
159
- describe('column chart SVG rendering', () => {
160
- it('renders <rect> elements oriented vertically', () => {
161
- const { svg } = renderSpec(columnSpec);
162
- const rects = svg.querySelectorAll('.oc-mark-rect rect');
163
- expect(rects.length).toBe(3);
164
- });
165
-
166
- it('column rects have varying heights (vertical orientation)', () => {
167
- const { svg } = renderSpec(columnSpec);
168
- const rects = svg.querySelectorAll('.oc-mark-rect rect');
169
- const heights = Array.from(rects).map((r) => Number(r.getAttribute('height')));
170
- // Different revenue values should produce different heights
171
- const uniqueHeights = new Set(heights);
172
- expect(uniqueHeights.size).toBeGreaterThan(1);
173
- // All heights should be positive
174
- for (const h of heights) {
175
- expect(h).toBeGreaterThan(0);
176
- }
177
- });
178
-
179
- it('column rects have positive width', () => {
180
- const { svg } = renderSpec(columnSpec);
181
- const rects = svg.querySelectorAll('.oc-mark-rect rect');
182
- for (const rect of rects) {
183
- const width = Number(rect.getAttribute('width'));
184
- expect(width).toBeGreaterThan(0);
185
- }
186
- });
187
- });
188
-
189
- // ---------------------------------------------------------------------------
190
- // Scatter chart marks
191
- // ---------------------------------------------------------------------------
192
-
193
- describe('scatter chart SVG rendering', () => {
194
- it('renders <circle> elements for each data point', () => {
195
- const { svg } = renderSpec(scatterSpec);
196
- const circles = svg.querySelectorAll('.oc-mark-point');
197
- // scatterSpec has 4 data points
198
- expect(circles.length).toBe(4);
199
- });
200
-
201
- it('circles have valid cx, cy, and r attributes', () => {
202
- const { svg } = renderSpec(scatterSpec);
203
- const circles = svg.querySelectorAll('.oc-mark-point');
204
- for (const circle of circles) {
205
- const cx = Number(circle.getAttribute('cx'));
206
- const cy = Number(circle.getAttribute('cy'));
207
- const r = Number(circle.getAttribute('r'));
208
- expect(cx).toBeTypeOf('number');
209
- expect(cy).toBeTypeOf('number');
210
- expect(r).toBeGreaterThan(0);
211
- // cx and cy should be within the SVG dimensions
212
- expect(cx).toBeGreaterThanOrEqual(0);
213
- expect(cy).toBeGreaterThanOrEqual(0);
214
- }
215
- });
216
-
217
- it('scatter marks have data-mark-id attributes', () => {
218
- const { svg } = renderSpec(scatterSpec);
219
- const circles = svg.querySelectorAll('.oc-mark-point');
220
- for (const circle of circles) {
221
- const markId = circle.getAttribute('data-mark-id');
222
- expect(markId).not.toBeNull();
223
- expect(markId).toMatch(/^point-/);
224
- }
225
- });
226
- });
227
-
228
- // ---------------------------------------------------------------------------
229
- // Pie chart marks
230
- // ---------------------------------------------------------------------------
231
-
232
- describe('pie chart SVG rendering', () => {
233
- it('renders <path> arc segments for each slice', () => {
234
- const { svg } = renderSpec(pieSpec);
235
- const arcGroups = svg.querySelectorAll('.oc-mark-arc');
236
- // pieSpec has 3 categories
237
- expect(arcGroups.length).toBe(3);
238
- });
239
-
240
- it('arc paths have valid d attribute with arc commands', () => {
241
- const { svg } = renderSpec(pieSpec);
242
- const paths = svg.querySelectorAll('.oc-mark-arc path');
243
- for (const path of paths) {
244
- const d = path.getAttribute('d');
245
- expect(d).not.toBeNull();
246
- // Arc paths should contain A (arc) commands
247
- expect(d).toMatch(/[AaLl]/);
248
- expect(d!.length).toBeGreaterThan(5);
249
- }
250
- });
251
-
252
- it('arc groups are translated to the pie center', () => {
253
- const { svg } = renderSpec(pieSpec);
254
- const arcGroups = svg.querySelectorAll('.oc-mark-arc');
255
- for (const group of arcGroups) {
256
- const transform = group.getAttribute('transform');
257
- expect(transform).not.toBeNull();
258
- expect(transform).toMatch(/translate\(\d+/);
259
- }
260
- });
261
-
262
- it('arc marks have fill colors', () => {
263
- const { svg } = renderSpec(pieSpec);
264
- const paths = svg.querySelectorAll('.oc-mark-arc path');
265
- for (const path of paths) {
266
- const fill = path.getAttribute('fill');
267
- expect(fill).not.toBeNull();
268
- expect(fill).not.toBe('none');
269
- }
270
- });
271
- });
272
-
273
- // ---------------------------------------------------------------------------
274
- // Multi-series: correct grouping and distinct colors
275
- // ---------------------------------------------------------------------------
276
-
277
- describe('multi-series rendering', () => {
278
- it('multi-series line chart has distinct stroke colors per series', () => {
279
- const { svg } = renderSpec(lineSpec);
280
- const paths = svg.querySelectorAll('.oc-mark-line path');
281
- const strokes = new Set<string>();
282
- for (const path of paths) {
283
- const stroke = path.getAttribute('stroke');
284
- if (stroke) strokes.add(stroke);
285
- }
286
- // US and UK should have different colors
287
- expect(strokes.size).toBe(2);
288
- });
289
-
290
- it('multi-series scatter chart has distinct fill colors per group', () => {
291
- const { svg } = renderSpec(scatterSpec);
292
- const circles = svg.querySelectorAll('.oc-mark-point');
293
- const fills = new Set<string>();
294
- for (const circle of circles) {
295
- const fill = circle.getAttribute('fill');
296
- if (fill) fills.add(fill);
297
- }
298
- // group A and B should have different fill colors
299
- expect(fills.size).toBe(2);
300
- });
301
-
302
- it('multi-series bar chart renders data-series attributes on rect marks', () => {
303
- const { svg } = renderSpec(multiSeriesBarSpec);
304
- const marks = svg.querySelectorAll('.oc-mark-rect[data-series]');
305
- const seriesNames = new Set<string>();
306
- for (const mark of marks) {
307
- const s = mark.getAttribute('data-series');
308
- if (s) seriesNames.add(s);
309
- }
310
- // Should have marks with series info
311
- expect(seriesNames.size).toBeGreaterThan(0);
312
- });
313
- });
314
-
315
- // ---------------------------------------------------------------------------
316
- // Chrome elements (title, subtitle, source)
317
- // ---------------------------------------------------------------------------
318
-
319
- describe('chart chrome rendering', () => {
320
- it('renders title text with correct content', () => {
321
- const { svg } = renderSpec(lineSpec);
322
- const title = svg.querySelector('.oc-title');
323
- expect(title).not.toBeNull();
324
- expect(title!.textContent).toBe('GDP Growth');
325
- });
326
-
327
- it('renders subtitle text with correct content', () => {
328
- const { svg } = renderSpec(lineSpec);
329
- const subtitle = svg.querySelector('.oc-subtitle');
330
- expect(subtitle).not.toBeNull();
331
- expect(subtitle!.textContent).toBe('US vs UK over time');
332
- });
333
-
334
- it('renders source text with correct content', () => {
335
- const { svg } = renderSpec(lineSpec);
336
- const source = svg.querySelector('.oc-source');
337
- expect(source).not.toBeNull();
338
- expect(source!.textContent).toBe('World Bank');
339
- });
340
-
341
- it('chrome elements are inside a .oc-chrome group', () => {
342
- const { svg } = renderSpec(lineSpec);
343
- const chromeGroup = svg.querySelector('.oc-chrome');
344
- expect(chromeGroup).not.toBeNull();
345
- expect(chromeGroup!.querySelector('.oc-title')).not.toBeNull();
346
- });
347
-
348
- it('title has font styling applied', () => {
349
- const { svg } = renderSpec(lineSpec);
350
- const title = svg.querySelector('.oc-title') as SVGElement & ElementCSSInlineStyle;
351
- expect(title).not.toBeNull();
352
- const fontFamily = title.style.getPropertyValue('font-family');
353
- const fontSize = title.style.getPropertyValue('font-size');
354
- expect(fontFamily).not.toBe('');
355
- expect(parseFloat(fontSize)).toBeGreaterThan(0);
356
- });
357
-
358
- it('wraps long title text into tspan elements at narrow widths', () => {
359
- const longTitleSpec: ChartSpec = {
360
- mark: 'bar',
361
- data: [
362
- { name: 'A', value: 10 },
363
- { name: 'B', value: 20 },
364
- ],
365
- encoding: {
366
- x: { field: 'value', type: 'quantitative' },
367
- y: { field: 'name', type: 'nominal' },
368
- },
369
- chrome: {
370
- title: 'This is a very long chart title that should definitely wrap at narrow widths',
371
- },
372
- };
373
- // Render at a very narrow width to force wrapping
374
- const { svg } = renderSpec(longTitleSpec, { width: 250, height: 300 });
375
- const title = svg.querySelector('.oc-title');
376
- expect(title).not.toBeNull();
377
- const tspans = title!.querySelectorAll('tspan');
378
- expect(tspans.length).toBeGreaterThan(1);
379
- // Full text should be preserved across tspans
380
- const fullText = Array.from(tspans)
381
- .map((t) => t.textContent)
382
- .join(' ');
383
- expect(fullText).toBe(
384
- 'This is a very long chart title that should definitely wrap at narrow widths',
385
- );
386
- });
387
-
388
- it('does not wrap short title text', () => {
389
- const { svg } = renderSpec(lineSpec);
390
- const title = svg.querySelector('.oc-title');
391
- expect(title).not.toBeNull();
392
- // Short title should have no tspan children, just direct textContent
393
- const tspans = title!.querySelectorAll('tspan');
394
- expect(tspans.length).toBe(0);
395
- expect(title!.textContent).toBe('GDP Growth');
396
- });
397
-
398
- it('splits subtitle on newline into multiple tspan lines', () => {
399
- const spec: ChartSpec = {
400
- mark: 'bar',
401
- data: [
402
- { name: 'A', value: 10 },
403
- { name: 'B', value: 20 },
404
- ],
405
- encoding: {
406
- x: { field: 'value', type: 'quantitative' },
407
- y: { field: 'name', type: 'nominal' },
408
- },
409
- chrome: {
410
- title: 'Title',
411
- subtitle: 'Line one\nLine two',
412
- },
413
- };
414
- const { svg } = renderSpec(spec, { width: 600, height: 400 });
415
- const subtitle = svg.querySelector('.oc-subtitle');
416
- expect(subtitle).not.toBeNull();
417
- const tspans = subtitle!.querySelectorAll('tspan');
418
- // Should produce at least 2 tspans for the two lines
419
- expect(tspans.length).toBeGreaterThanOrEqual(2);
420
- const fullText = Array.from(tspans)
421
- .map((t) => t.textContent)
422
- .join('\n');
423
- expect(fullText).toContain('Line one');
424
- expect(fullText).toContain('Line two');
425
- });
426
-
427
- it('handles short text with newline that would fit on one line without it', () => {
428
- const spec: ChartSpec = {
429
- mark: 'bar',
430
- data: [
431
- { name: 'A', value: 10 },
432
- { name: 'B', value: 20 },
433
- ],
434
- encoding: {
435
- x: { field: 'value', type: 'quantitative' },
436
- y: { field: 'name', type: 'nominal' },
437
- },
438
- chrome: {
439
- title: 'Hi\nThere',
440
- },
441
- };
442
- // Wide enough that "Hi There" would fit on one line, but \n forces two
443
- const { svg } = renderSpec(spec, { width: 600, height: 400 });
444
- const title = svg.querySelector('.oc-title');
445
- expect(title).not.toBeNull();
446
- const tspans = title!.querySelectorAll('tspan');
447
- expect(tspans.length).toBe(2);
448
- expect(tspans[0].textContent).toBe('Hi');
449
- expect(tspans[1].textContent).toBe('There');
450
- });
451
-
452
- it('handles consecutive newlines producing empty line segments', () => {
453
- const spec: ChartSpec = {
454
- mark: 'bar',
455
- data: [
456
- { name: 'A', value: 10 },
457
- { name: 'B', value: 20 },
458
- ],
459
- encoding: {
460
- x: { field: 'value', type: 'quantitative' },
461
- y: { field: 'name', type: 'nominal' },
462
- },
463
- chrome: {
464
- title: 'Above\n\nBelow',
465
- },
466
- };
467
- const { svg } = renderSpec(spec, { width: 600, height: 400 });
468
- const title = svg.querySelector('.oc-title');
469
- expect(title).not.toBeNull();
470
- const tspans = title!.querySelectorAll('tspan');
471
- // 3 segments: "Above", "", "Below"
472
- expect(tspans.length).toBe(3);
473
- expect(tspans[0].textContent).toBe('Above');
474
- expect(tspans[1].textContent).toBe('');
475
- expect(tspans[2].textContent).toBe('Below');
476
- });
477
-
478
- it('chart with no chrome specified renders no chrome text elements', () => {
479
- const noChrome: ChartSpec = {
480
- mark: 'bar',
481
- data: [{ name: 'A', value: 10 }],
482
- encoding: {
483
- x: { field: 'value', type: 'quantitative' },
484
- y: { field: 'name', type: 'nominal' },
485
- },
486
- };
487
- const { svg } = renderSpec(noChrome);
488
- const chromeGroup = svg.querySelector('.oc-chrome');
489
- expect(chromeGroup).not.toBeNull();
490
- // No title/subtitle/source should be in the chrome group
491
- expect(chromeGroup!.querySelector('.oc-title')).toBeNull();
492
- expect(chromeGroup!.querySelector('.oc-subtitle')).toBeNull();
493
- expect(chromeGroup!.querySelector('.oc-source')).toBeNull();
494
- });
495
- });
496
-
497
- // ---------------------------------------------------------------------------
498
- // Axes and tick labels
499
- // ---------------------------------------------------------------------------
500
-
501
- describe('axis rendering', () => {
502
- it('renders x-axis and y-axis groups', () => {
503
- const { svg } = renderSpec(lineSpec);
504
- const xAxis = svg.querySelector('.oc-axis-x');
505
- const yAxis = svg.querySelector('.oc-axis-y');
506
- expect(xAxis).not.toBeNull();
507
- expect(yAxis).not.toBeNull();
508
- });
509
-
510
- it('x-axis has tick labels as text elements', () => {
511
- const { svg } = renderSpec(lineSpec);
512
- const xAxis = svg.querySelector('.oc-axis-x');
513
- const labels = xAxis!.querySelectorAll('text');
514
- expect(labels.length).toBeGreaterThan(0);
515
- // Each label should have text content
516
- for (const label of labels) {
517
- expect(label.textContent!.length).toBeGreaterThan(0);
518
- }
519
- });
520
-
521
- it('y-axis has tick labels as text elements', () => {
522
- const { svg } = renderSpec(barSpec);
523
- const yAxis = svg.querySelector('.oc-axis-y');
524
- const labels = yAxis!.querySelectorAll('text');
525
- expect(labels.length).toBeGreaterThan(0);
526
- });
527
-
528
- it('x-axis has a baseline line element for grounded marks', () => {
529
- const { svg } = renderSpec(barSpec);
530
- const xAxis = svg.querySelector('.oc-axis-x');
531
- const line = xAxis!.querySelector('line');
532
- expect(line).not.toBeNull();
533
- });
534
-
535
- it('x-axis hides baseline for non-grounded marks', () => {
536
- const { svg } = renderSpec(lineSpec);
537
- const xAxis = svg.querySelector('.oc-axis-x');
538
- const line = xAxis!.querySelector('line');
539
- expect(line).toBeNull();
540
- });
541
-
542
- it('x-tick labels hang below the axis line by the label padding (no hugging)', () => {
543
- const { svg, layout } = renderSpec(barSpec);
544
- const xAxis = svg.querySelector('.oc-axis-x')!;
545
- const axisLineY = Number(xAxis.querySelector('line')!.getAttribute('y2'));
546
- const labels = Array.from(xAxis.querySelectorAll('text.oc-axis-tick'));
547
- expect(labels.length).toBeGreaterThan(0);
548
-
549
- const pad = layout.theme.spacing.xAxisLabelPadding;
550
- const fontSize = layout.axes.x.tickLabelStyle.fontSize;
551
- for (const label of labels) {
552
- // No dominant-baseline: the renderer positions the alphabetic baseline
553
- // directly (WebKit mishandles hanging). The label TOP still sits a full
554
- // padding gap below the axis line; the baseline is that top + ascent.
555
- expect(label.getAttribute('dominant-baseline')).toBeNull();
556
- expect(Number(label.getAttribute('y'))).toBeCloseTo(axisLineY + pad + fontSize * 0.8, 1);
557
- }
558
- });
559
- });
560
-
561
- // ---------------------------------------------------------------------------
562
- // Gridlines
563
- // ---------------------------------------------------------------------------
564
-
565
- describe('gridline rendering', () => {
566
- it('renders gridlines as line elements within axis groups', () => {
567
- const { svg } = renderSpec(lineSpec);
568
- // y-axis gridlines are horizontal lines
569
- const yAxis = svg.querySelector('.oc-axis-y');
570
- const gridlines = yAxis!.querySelectorAll('line');
571
- expect(gridlines.length).toBeGreaterThan(0);
572
- });
573
-
574
- it('gridlines have stroke-opacity for subtlety', () => {
575
- const { svg } = renderSpec(lineSpec);
576
- const yAxis = svg.querySelector('.oc-axis-y');
577
- const gridlines = yAxis!.querySelectorAll('line');
578
- for (const gl of gridlines) {
579
- const opacity = gl.getAttribute('stroke-opacity');
580
- if (opacity) {
581
- // Gridlines should be subtle (less than 1.0 opacity)
582
- expect(Number(opacity)).toBeLessThan(1);
583
- }
584
- }
585
- });
586
- });
587
-
588
- // ---------------------------------------------------------------------------
589
- // Legend
590
- // ---------------------------------------------------------------------------
591
-
592
- describe('legend rendering', () => {
593
- /** Legend is auto-suppressed for line charts with endpoint labels; force it on for these tests. */
594
- const lineSpecWithLegend = { ...lineSpec, legend: { show: true } };
595
-
596
- it('multi-series chart renders legend entries', () => {
597
- const { svg } = renderSpec(lineSpecWithLegend);
598
- const legend = svg.querySelector('.oc-legend');
599
- expect(legend).not.toBeNull();
600
- const entries = legend!.querySelectorAll('.oc-legend-entry');
601
- // lineSpec has US and UK series
602
- expect(entries.length).toBe(2);
603
- });
604
-
605
- it('legend entries have labels with series names', () => {
606
- const { svg } = renderSpec(lineSpecWithLegend);
607
- const entries = svg.querySelectorAll('.oc-legend-entry');
608
- const labels: string[] = [];
609
- for (const entry of entries) {
610
- const text = entry.querySelector('text');
611
- if (text?.textContent) labels.push(text.textContent);
612
- }
613
- expect(labels).toContain('US');
614
- expect(labels).toContain('UK');
615
- });
616
-
617
- it('legend entries have data-legend-label attribute', () => {
618
- const { svg } = renderSpec(lineSpecWithLegend);
619
- const entries = svg.querySelectorAll('.oc-legend-entry');
620
- for (const entry of entries) {
621
- expect(entry.getAttribute('data-legend-label')).not.toBeNull();
622
- }
623
- });
624
-
625
- it('legend has ARIA attributes for accessibility', () => {
626
- const { svg } = renderSpec(lineSpecWithLegend);
627
- const legend = svg.querySelector('.oc-legend');
628
- expect(legend!.getAttribute('role')).toBe('list');
629
- expect(legend!.getAttribute('aria-label')).toBe('Chart legend');
630
- const entries = legend!.querySelectorAll('.oc-legend-entry');
631
- for (const entry of entries) {
632
- expect(entry.getAttribute('role')).toBe('listitem');
633
- }
634
- });
635
-
636
- it('single-series chart has no legend entries', () => {
637
- const { svg } = renderSpec(singleSeriesLineSpec);
638
- const entries = svg.querySelectorAll('.oc-legend-entry');
639
- expect(entries.length).toBe(0);
640
- });
641
-
642
- it('pie chart renders legend entries for each slice', () => {
643
- const { svg } = renderSpec(pieSpec);
644
- const entries = svg.querySelectorAll('.oc-legend-entry');
645
- // pieSpec has 3 slices
646
- expect(entries.length).toBe(3);
647
- });
648
- });
649
-
650
- // ---------------------------------------------------------------------------
651
- // SVG root structure
652
- // ---------------------------------------------------------------------------
653
-
654
- describe('SVG root structure', () => {
655
- it('SVG has correct viewBox matching dimensions', () => {
656
- const { svg } = renderSpec(lineSpec);
657
- const viewBox = svg.getAttribute('viewBox');
658
- expect(viewBox).toBe('0 0 600 400');
659
- });
660
-
661
- it('SVG has accessibility role and aria-label', () => {
662
- const { svg } = renderSpec(lineSpec);
663
- expect(svg.getAttribute('role')).toBe('img');
664
- const ariaLabel = svg.getAttribute('aria-label');
665
- expect(ariaLabel).not.toBeNull();
666
- expect(ariaLabel!.length).toBeGreaterThan(0);
667
- });
668
-
669
- it('SVG has oc-chart class', () => {
670
- const { svg } = renderSpec(lineSpec);
671
- expect(svg.getAttribute('class')).toBe('oc-chart');
672
- });
673
-
674
- it('SVG has a background rect as first child', () => {
675
- const { svg } = renderSpec(lineSpec);
676
- const firstChild = svg.children[0];
677
- expect(firstChild.tagName.toLowerCase()).toBe('rect');
678
- expect(Number(firstChild.getAttribute('width'))).toBe(600);
679
- expect(Number(firstChild.getAttribute('height'))).toBe(400);
680
- });
681
-
682
- it('SVG has a defs element with clip path', () => {
683
- const { svg } = renderSpec(lineSpec);
684
- const defs = svg.querySelector('defs');
685
- expect(defs).not.toBeNull();
686
- const clipPath = defs!.querySelector('clipPath');
687
- expect(clipPath).not.toBeNull();
688
- expect(clipPath!.getAttribute('id')).toMatch(/^oc-clip-/);
689
- });
690
-
691
- it('marks group is clipped via clip-path attribute', () => {
692
- const { svg } = renderSpec(lineSpec);
693
- const clippedGroup = svg.querySelector('[clip-path]');
694
- expect(clippedGroup).not.toBeNull();
695
- expect(clippedGroup!.getAttribute('clip-path')).toMatch(/url\(#oc-clip-/);
696
- });
697
- });
698
-
699
- // ---------------------------------------------------------------------------
700
- // Inline snapshot for a critical mark element
701
- // ---------------------------------------------------------------------------
702
-
703
- describe('targeted mark snapshots', () => {
704
- it('line mark group has expected structure', () => {
705
- const { svg } = renderSpec(singleSeriesLineSpec);
706
- const lineGroup = svg.querySelector('.oc-mark-line');
707
- expect(lineGroup).not.toBeNull();
708
- expect(lineGroup!.getAttribute('class')).toBe('oc-mark oc-mark-line');
709
- expect(lineGroup!.getAttribute('data-mark-id')).toMatch(/^line-/);
710
-
711
- const path = lineGroup!.querySelector('path');
712
- expect(path).not.toBeNull();
713
- expect(path!.getAttribute('fill')).toBe('none');
714
- expect(path!.getAttribute('stroke')).not.toBeNull();
715
- expect(Number(path!.getAttribute('stroke-width'))).toBeGreaterThan(0);
716
- expect(path!.getAttribute('d')).toMatch(/^M/);
717
- });
718
-
719
- it('rect mark group has expected structure', () => {
720
- const { svg } = renderSpec(barSpec);
721
- const rectGroup = svg.querySelector('.oc-mark-rect');
722
- expect(rectGroup).not.toBeNull();
723
- expect(rectGroup!.getAttribute('class')).toBe('oc-mark oc-mark-rect');
724
- expect(rectGroup!.getAttribute('data-mark-id')).toMatch(/^rect-/);
725
-
726
- const rect = rectGroup!.querySelector('rect');
727
- expect(rect).not.toBeNull();
728
- expect(Number(rect!.getAttribute('width'))).toBeGreaterThan(0);
729
- expect(Number(rect!.getAttribute('height'))).toBeGreaterThan(0);
730
- expect(rect!.getAttribute('fill')).not.toBeNull();
731
- });
732
-
733
- it('point mark has expected attributes', () => {
734
- const { svg } = renderSpec(scatterSpec);
735
- const point = svg.querySelector('.oc-mark-point');
736
- expect(point).not.toBeNull();
737
- expect(point!.tagName.toLowerCase()).toBe('circle');
738
- expect(point!.getAttribute('class')).toBe('oc-mark oc-mark-point');
739
- expect(point!.getAttribute('data-mark-id')).toMatch(/^point-/);
740
- expect(Number(point!.getAttribute('r'))).toBeGreaterThan(0);
741
- expect(point!.getAttribute('fill')).not.toBeNull();
742
- });
743
-
744
- it('arc mark group has expected structure', () => {
745
- const { svg } = renderSpec(pieSpec);
746
- const arcGroup = svg.querySelector('.oc-mark-arc');
747
- expect(arcGroup).not.toBeNull();
748
- expect(arcGroup!.getAttribute('class')).toBe('oc-mark oc-mark-arc');
749
- expect(arcGroup!.getAttribute('data-mark-id')).toMatch(/^arc-/);
750
- expect(arcGroup!.getAttribute('transform')).toMatch(/translate\(/);
751
-
752
- const path = arcGroup!.querySelector('path');
753
- expect(path).not.toBeNull();
754
- expect(path!.getAttribute('fill')).not.toBeNull();
755
- expect(path!.getAttribute('d')).not.toBeNull();
756
- });
757
- });
758
-
759
- // ---------------------------------------------------------------------------
760
- // Brand watermark
761
- // ---------------------------------------------------------------------------
762
-
763
- describe('brand watermark', () => {
764
- it('renders "tryOpenData.ai" as a single text element with three tspans', () => {
765
- const { svg } = renderSpec(lineSpec);
766
- const brandLink = svg.querySelector('.oc-chrome-ref');
767
- expect(brandLink).not.toBeNull();
768
- const text = brandLink!.querySelector('text')!;
769
- expect(text.textContent).toBe('tryOpenData.ai');
770
- const tspans = text.querySelectorAll('tspan');
771
- expect(tspans.length).toBe(3);
772
- expect(tspans[0].textContent).toBe('try');
773
- expect(tspans[1].textContent).toBe('OpenData');
774
- expect(tspans[2].textContent).toBe('.ai');
775
- });
776
-
777
- it('links to tryopendata.ai', () => {
778
- const { svg } = renderSpec(lineSpec);
779
- const links = svg.querySelectorAll('a[href="https://tryopendata.ai"]');
780
- expect(links.length).toBe(1);
781
- });
782
-
783
- it('is a direct child of SVG root', () => {
784
- const { svg } = renderSpec(lineSpec);
785
- const brandLink = svg.querySelector('.oc-chrome-ref');
786
- expect(brandLink!.parentElement).toBe(svg);
787
- });
788
-
789
- it('renders after chrome (in the footer row)', () => {
790
- const { svg } = renderSpec(lineSpec);
791
- const children = Array.from(svg.children);
792
- const chromeIdx = children.findIndex((el) => el.classList.contains('oc-chrome'));
793
- const brandIdx = children.findIndex((el) => el.classList.contains('oc-chrome-ref'));
794
- expect(brandIdx).toBeGreaterThan(chromeIdx);
795
- });
796
-
797
- it('skips watermark on very small charts', () => {
798
- const { svg } = renderSpec(lineSpec, { width: 100, height: 80 });
799
- const brandLink = svg.querySelector('.oc-chrome-ref');
800
- expect(brandLink).toBeNull();
801
- });
802
-
803
- it('does not render brand when layout.watermark is false', () => {
804
- const spec: ChartSpec = { ...lineSpec, watermark: false };
805
- const { svg } = renderSpec(spec);
806
- const brandLink = svg.querySelector('.oc-chrome-ref');
807
- expect(brandLink).toBeNull();
808
- });
809
- });
810
-
811
- // ---------------------------------------------------------------------------
812
- // Y-axis title spacing (regression for the title overlapping the tick labels)
813
- // ---------------------------------------------------------------------------
814
-
815
- describe('left y-axis title spacing', () => {
816
- // Builds a chart with a left y-axis title and reads back the real geometry to
817
- // compute the horizontal clearance between the widest tick label's far (left)
818
- // edge and the rotated title's near (right) edge. A non-negative clearance
819
- // means they don't overlap; the bug was a negative one at large font sizes.
820
- const yTitleSpec = (axisTitleSize: number, values: number[]): ChartSpec => ({
821
- mark: { type: 'line' },
822
- data: values.map((v, i) => ({ year: String(2018 + i), pct: v })),
823
- encoding: {
824
- x: { field: 'year', type: 'ordinal' },
825
- y: {
826
- field: 'pct',
827
- type: 'quantitative',
828
- // Force gutter ticks: this suite measures title-vs-gutter-label
829
- // clearance, which only exists when tick labels sit in a left gutter.
830
- // Line charts default to inline ticks (rendered inside the plot), where
831
- // there is no gutter label to clear — that case is guarded separately by
832
- // the "inline y-axis title inside the container" test in the engine.
833
- axis: { title: 'Spring 2026 pass rate', format: '.0f%', tickPosition: 'gutter' },
834
- },
835
- },
836
- chrome: { title: 'Pass rate' },
837
- // The deck renders axis titles via theme.fonts.sizes.body; vary it to
838
- // reproduce the large-font overlap.
839
- theme: { fonts: { sizes: { body: axisTitleSize } } },
840
- });
841
-
842
- // Clearance = (widest tick label's left edge) - (title glyph's right edge).
843
- // Tick labels: text-anchor=end at x = area.x - 6, so left edge = anchorX - width.
844
- // Title: text-anchor=middle, rotated, center at the title's x attr; its glyph
845
- // box extends fontSize/2 toward the labels (the +x direction).
846
- const measureClearance = (axisTitleSize: number, values: number[]): number => {
847
- const container = createContainer(700, 450);
848
- const layout = compileChart(yTitleSpec(axisTitleSize, values), {
849
- width: 700,
850
- height: 450,
851
- });
852
- const svg = renderChartSVG(layout, container);
853
-
854
- const title = svg.querySelector('.oc-axis-title') as SVGTextElement | null;
855
- expect(title).not.toBeNull();
856
- const titleCenterX = Number(title!.getAttribute('x'));
857
- const titleNearEdge = titleCenterX + axisTitleSize / 2;
858
-
859
- const tickStyle = layout.axes.y!.tickLabelStyle;
860
- const tickAnchorX = layout.area.x - TICK_LABEL_OFFSET;
861
- let widestLeftEdge = tickAnchorX;
862
- for (const t of layout.axes.y!.ticks) {
863
- const w = estimateTextWidth(t.label, tickStyle.fontSize, tickStyle.fontWeight ?? 400);
864
- widestLeftEdge = Math.min(widestLeftEdge, tickAnchorX - w);
865
- }
866
-
867
- return widestLeftEdge - titleNearEdge;
868
- };
869
-
870
- const PASS_RATES = [38, 41, 46, 52, 49, 61]; // labels like "38%" ... "61%"
871
- // Require real breathing room, not just non-overlap. At the deck font size the
872
- // old code left only ~3.5px (glyphs visibly touching), which this floor rejects.
873
- const MIN_CLEARANCE = AXIS_TITLE_GAP - 1;
874
-
875
- it('keeps the title clear of the tick labels at the default font size', () => {
876
- expect(measureClearance(13, PASS_RATES)).toBeGreaterThanOrEqual(MIN_CLEARANCE);
877
- });
878
-
879
- it('keeps the title clear of the tick labels at a large (deck) font size', () => {
880
- // The slide deck uses body=21 for axis titles. Under the old fixed gap this
881
- // left only ~3.5px and the title glyphs touched the labels (the reported bug).
882
- expect(measureClearance(21, PASS_RATES)).toBeGreaterThanOrEqual(MIN_CLEARANCE);
883
- });
884
-
885
- it('does not lose clearance as the title font size grows', () => {
886
- // The original bug: clearance shrank ~1px per font-size point as the title's
887
- // half-glyph ate the fixed gap, so big titles overlapped (negative clearance
888
- // at body=36). Now the half-glyph is folded into the offset, so clearance
889
- // holds at ~AXIS_TITLE_GAP across the whole font range instead of collapsing.
890
- const sizes = [13, 18, 21, 28, 36];
891
- for (const s of sizes) {
892
- expect(measureClearance(s, PASS_RATES)).toBeGreaterThanOrEqual(MIN_CLEARANCE);
893
- }
894
- });
895
- });