@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.
- package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
- package/dist/chunk-DCODCLWI.js +234 -0
- package/dist/chunk-DCODCLWI.js.map +1 -0
- package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
- package/dist/chunk-EKAUQAAX.js.map +1 -0
- package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
- package/dist/chunk-KNT5AZ2O.js.map +1 -0
- package/dist/chunk-RM5XS6NR.js +113 -0
- package/dist/chunk-RM5XS6NR.js.map +1 -0
- package/dist/chunk-T32UHMZA.js +282 -0
- package/dist/chunk-T32UHMZA.js.map +1 -0
- package/dist/export-gif.d.ts +113 -0
- package/dist/export-gif.js +21 -0
- package/dist/export-gif.js.map +1 -0
- package/dist/index.d.ts +283 -24
- package/dist/index.js +2012 -763
- package/dist/index.js.map +1 -1
- package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
- package/dist/simulation-worker.js +83 -2
- package/dist/static.js +7 -69
- package/dist/static.js.map +1 -1
- package/dist/story/index.d.ts +3 -2
- package/dist/story/index.js +5 -3
- package/dist/story/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +14 -6
- package/dist/chunk-5EOV5JQZ.js.map +0 -1
- package/dist/chunk-ENXSUXC5.js.map +0 -1
- package/src/__test-fixtures__/dom.ts +0 -70
- package/src/__test-fixtures__/specs.ts +0 -214
- package/src/__tests__/animation.test.ts +0 -358
- package/src/__tests__/annotations-render.test.ts +0 -201
- package/src/__tests__/compound-labels.test.ts +0 -122
- package/src/__tests__/continuous-legend.test.ts +0 -91
- package/src/__tests__/crosshair.test.ts +0 -130
- package/src/__tests__/edit-events.test.ts +0 -902
- package/src/__tests__/events.test.ts +0 -387
- package/src/__tests__/export.test.ts +0 -241
- package/src/__tests__/font-race.test.ts +0 -199
- package/src/__tests__/gradient-ids.test.ts +0 -159
- package/src/__tests__/gradient-utils.test.ts +0 -125
- package/src/__tests__/known-bugs-render.test.ts +0 -183
- package/src/__tests__/map.test.ts +0 -293
- package/src/__tests__/mount.test.ts +0 -357
- package/src/__tests__/pattern-fills.test.ts +0 -105
- package/src/__tests__/resize-timing.test.ts +0 -218
- package/src/__tests__/sankey.test.ts +0 -133
- package/src/__tests__/selection-events.test.ts +0 -869
- package/src/__tests__/series-search.test.ts +0 -311
- package/src/__tests__/static.test.ts +0 -141
- package/src/__tests__/svg-renderer.test.ts +0 -895
- package/src/__tests__/table-keyboard.test.ts +0 -361
- package/src/__tests__/table-mount.test.ts +0 -639
- package/src/__tests__/tilemap.test.ts +0 -158
- package/src/__tests__/tooltip.test.ts +0 -337
- package/src/__tests__/transition.test.ts +0 -1777
- package/src/__tests__/you-draw-it.test.ts +0 -273
- package/src/animation-vars.ts +0 -42
- package/src/animation.ts +0 -76
- package/src/barlist-mount.ts +0 -383
- package/src/barlist-renderer.ts +0 -267
- package/src/dom-helpers.ts +0 -17
- package/src/export.ts +0 -482
- package/src/gradient-utils.ts +0 -138
- package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
- package/src/graph/__tests__/graph-mount.test.ts +0 -213
- package/src/graph/__tests__/interaction.test.ts +0 -205
- package/src/graph/__tests__/keyboard.test.ts +0 -653
- package/src/graph/__tests__/search.test.ts +0 -88
- package/src/graph/__tests__/simulation.test.ts +0 -233
- package/src/graph/__tests__/spatial-index.test.ts +0 -142
- package/src/graph/__tests__/zoom.test.ts +0 -203
- package/src/graph/canvas-renderer.ts +0 -747
- package/src/graph/interaction.ts +0 -367
- package/src/graph/keyboard.ts +0 -208
- package/src/graph/search.ts +0 -50
- package/src/graph/simulation-worker-url.ts +0 -12
- package/src/graph/simulation-worker.ts +0 -278
- package/src/graph/simulation.ts +0 -422
- package/src/graph/spatial-index.ts +0 -121
- package/src/graph/types.ts +0 -47
- package/src/graph/worker-protocol.ts +0 -70
- package/src/graph/zoom.ts +0 -134
- package/src/graph-mount.ts +0 -888
- package/src/index.ts +0 -76
- package/src/interactions/chart-events.ts +0 -139
- package/src/interactions/crosshair.ts +0 -233
- package/src/interactions/drag-handler.ts +0 -175
- package/src/interactions/editing-drags.ts +0 -515
- package/src/interactions/index.ts +0 -25
- package/src/interactions/keyboard-nav.ts +0 -111
- package/src/interactions/legend-interaction.ts +0 -46
- package/src/interactions/selection.ts +0 -280
- package/src/interactions/tooltip-events.ts +0 -72
- package/src/map-camera.ts +0 -88
- package/src/map-mount.ts +0 -701
- package/src/map-renderer.ts +0 -338
- package/src/map-transition.ts +0 -83
- package/src/measure-text.ts +0 -134
- package/src/mount.ts +0 -1382
- package/src/pattern-utils.ts +0 -110
- package/src/renderers/annotations.ts +0 -388
- package/src/renderers/axes.ts +0 -351
- package/src/renderers/brand.ts +0 -87
- package/src/renderers/chrome.ts +0 -196
- package/src/renderers/endpoint-labels.ts +0 -150
- package/src/renderers/legend.ts +0 -311
- package/src/renderers/marks.ts +0 -561
- package/src/renderers/metrics.ts +0 -63
- package/src/renderers/svg-dom.ts +0 -40
- package/src/renderers/table-cells.ts +0 -446
- package/src/resize-observer.ts +0 -46
- package/src/sankey-mount.ts +0 -622
- package/src/sankey-renderer.ts +0 -614
- package/src/series-search.ts +0 -392
- package/src/static.ts +0 -251
- package/src/story/__tests__/camera-math.test.ts +0 -145
- package/src/story/__tests__/create-chart-story.test.ts +0 -317
- package/src/story/__tests__/progress-math.test.ts +0 -95
- package/src/story/__tests__/scroll-driver.test.ts +0 -130
- package/src/story/__tests__/tween.test.ts +0 -159
- package/src/story/camera-math.ts +0 -125
- package/src/story/create-chart-story.ts +0 -353
- package/src/story/crossfade.ts +0 -70
- package/src/story/index.ts +0 -39
- package/src/story/progress-math.ts +0 -85
- package/src/story/resolve-camera-target.ts +0 -119
- package/src/story/scroll-driver.ts +0 -206
- package/src/story/story-camera.ts +0 -49
- package/src/story/tween.ts +0 -112
- package/src/story/types.ts +0 -76
- package/src/svg-ids.ts +0 -23
- package/src/svg-renderer.ts +0 -383
- package/src/table-keyboard.ts +0 -266
- package/src/table-mount.ts +0 -567
- package/src/table-renderer.ts +0 -414
- package/src/text-edit-overlay.ts +0 -255
- package/src/theme-tokens.ts +0 -51
- package/src/tilemap-mount.ts +0 -511
- package/src/tilemap-renderer.ts +0 -396
- package/src/tooltip.ts +0 -177
- package/src/transition.ts +0 -2816
- package/src/you-draw-it.ts +0 -480
- /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
|
-
});
|