@opendata-ai/openchart-vanilla 8.0.0-rc.9 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-SPILUE5S.js → chunk-2NAQRP66.js} +2 -2
- package/dist/{chunk-IZQBT6QM.js → chunk-6BUCYCLD.js} +7283 -5881
- package/dist/chunk-6BUCYCLD.js.map +1 -0
- package/dist/chunk-DCODCLWI.js +234 -0
- package/dist/chunk-DCODCLWI.js.map +1 -0
- package/dist/{chunk-QW7ZB6NE.js → chunk-EKAUQAAX.js} +49 -45
- package/dist/chunk-EKAUQAAX.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 +309 -27
- package/dist/index.js +2020 -836
- package/dist/index.js.map +1 -1
- package/dist/{mount-CUKQLeqD.d.ts → mount-CL-nA0f-.d.ts} +85 -2
- package/dist/simulation-worker.js +83 -2
- package/dist/static.d.ts +2 -0
- package/dist/static.js +8 -69
- package/dist/static.js.map +1 -1
- package/dist/story/index.d.ts +25 -9
- package/dist/story/index.js +9 -7
- 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-IZQBT6QM.js.map +0 -1
- package/dist/chunk-QW7ZB6NE.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 -1477
- package/src/__tests__/editable-prop.test.ts +0 -174
- 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__/invert.test.ts +0 -187
- package/src/__tests__/known-bugs-render.test.ts +0 -183
- package/src/__tests__/map.test.ts +0 -636
- 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 -1062
- 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 -1792
- package/src/__tests__/update-scrolly-matrix.test.ts +0 -563
- 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 -649
- package/src/interactions/index.ts +0 -27
- package/src/interactions/invert.ts +0 -54
- package/src/interactions/keyboard-nav.ts +0 -111
- package/src/interactions/legend-interaction.ts +0 -46
- package/src/interactions/selection.ts +0 -286
- package/src/interactions/tooltip-events.ts +0 -72
- package/src/map-camera.ts +0 -111
- package/src/map-mount.ts +0 -797
- package/src/map-renderer.ts +0 -476
- package/src/map-transition.ts +0 -99
- package/src/measure-text.ts +0 -134
- package/src/mount.ts +0 -1434
- 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 -312
- 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 -389
- 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-SPILUE5S.js.map → chunk-2NAQRP66.js.map} +0 -0
|
@@ -1,357 +0,0 @@
|
|
|
1
|
-
import type { ChartLayout } from '@opendata-ai/openchart-core';
|
|
2
|
-
import { legendGap } from '@opendata-ai/openchart-engine';
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
4
|
-
import { createAutoHeightContainer, createContainer } from '../__test-fixtures__/dom';
|
|
5
|
-
import {
|
|
6
|
-
barSpec,
|
|
7
|
-
lineSpec,
|
|
8
|
-
longChromeLineSpec,
|
|
9
|
-
singleSeriesLineSpec,
|
|
10
|
-
} from '../__test-fixtures__/specs';
|
|
11
|
-
import { createChart } from '../mount';
|
|
12
|
-
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// Tests
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
|
|
17
|
-
describe('createChart', () => {
|
|
18
|
-
let container: HTMLDivElement;
|
|
19
|
-
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
container = createContainer();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
afterEach(() => {
|
|
25
|
-
document.body.innerHTML = '';
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('creates an SVG element in the container', () => {
|
|
29
|
-
const chart = createChart(container, lineSpec);
|
|
30
|
-
|
|
31
|
-
const svg = container.querySelector('svg');
|
|
32
|
-
expect(svg).not.toBeNull();
|
|
33
|
-
expect(svg?.getAttribute('class')).toBe('oc-chart');
|
|
34
|
-
|
|
35
|
-
chart.destroy();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('SVG has correct viewBox dimensions', () => {
|
|
39
|
-
const chart = createChart(container, lineSpec);
|
|
40
|
-
|
|
41
|
-
const svg = container.querySelector('svg');
|
|
42
|
-
const viewBox = svg?.getAttribute('viewBox');
|
|
43
|
-
expect(viewBox).toBe('0 0 600 400');
|
|
44
|
-
|
|
45
|
-
chart.destroy();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('chrome text elements are present with correct content', () => {
|
|
49
|
-
const chart = createChart(container, lineSpec);
|
|
50
|
-
|
|
51
|
-
const title = container.querySelector('.oc-title');
|
|
52
|
-
expect(title).not.toBeNull();
|
|
53
|
-
expect(title?.textContent).toBe('GDP Growth');
|
|
54
|
-
|
|
55
|
-
const subtitle = container.querySelector('.oc-subtitle');
|
|
56
|
-
expect(subtitle).not.toBeNull();
|
|
57
|
-
expect(subtitle?.textContent).toBe('US vs UK over time');
|
|
58
|
-
|
|
59
|
-
const source = container.querySelector('.oc-source');
|
|
60
|
-
expect(source).not.toBeNull();
|
|
61
|
-
expect(source?.textContent).toBe('World Bank');
|
|
62
|
-
|
|
63
|
-
chart.destroy();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('update() re-renders with new data', () => {
|
|
67
|
-
const chart = createChart(container, lineSpec);
|
|
68
|
-
|
|
69
|
-
const titleBefore = container.querySelector('.oc-title');
|
|
70
|
-
expect(titleBefore?.textContent).toBe('GDP Growth');
|
|
71
|
-
|
|
72
|
-
chart.update(barSpec);
|
|
73
|
-
|
|
74
|
-
const titleAfter = container.querySelector('.oc-title');
|
|
75
|
-
expect(titleAfter?.textContent).toBe('Updated Chart');
|
|
76
|
-
|
|
77
|
-
chart.destroy();
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('destroy() removes all DOM elements and disconnects observer', () => {
|
|
81
|
-
const chart = createChart(container, lineSpec);
|
|
82
|
-
|
|
83
|
-
const svgBefore = container.querySelector('svg');
|
|
84
|
-
expect(svgBefore).not.toBeNull();
|
|
85
|
-
|
|
86
|
-
chart.destroy();
|
|
87
|
-
|
|
88
|
-
const svgAfter = container.querySelector('svg');
|
|
89
|
-
expect(svgAfter).toBeNull();
|
|
90
|
-
|
|
91
|
-
// Tooltip div should also be removed
|
|
92
|
-
const tooltip = container.querySelector('.oc-tooltip');
|
|
93
|
-
expect(tooltip).toBeNull();
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('has accessible ARIA attributes on the SVG', () => {
|
|
97
|
-
const chart = createChart(container, lineSpec);
|
|
98
|
-
|
|
99
|
-
const svg = container.querySelector('svg');
|
|
100
|
-
expect(svg?.getAttribute('role')).toBe('img');
|
|
101
|
-
expect(svg?.getAttribute('aria-label')).toContain('Line chart');
|
|
102
|
-
|
|
103
|
-
chart.destroy();
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('a11y.description overrides the SVG aria-label', () => {
|
|
107
|
-
const chart = createChart(container, {
|
|
108
|
-
...lineSpec,
|
|
109
|
-
a11y: { description: 'GDP growth for US and UK.' },
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
const svg = container.querySelector('svg');
|
|
113
|
-
expect(svg?.getAttribute('aria-label')).toBe('GDP growth for US and UK.');
|
|
114
|
-
|
|
115
|
-
chart.destroy();
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('a11y.hidden removes the chart from the accessibility tree', () => {
|
|
119
|
-
const chart = createChart(container, { ...lineSpec, a11y: { hidden: true } });
|
|
120
|
-
|
|
121
|
-
const svg = container.querySelector('svg');
|
|
122
|
-
expect(svg?.getAttribute('aria-hidden')).toBe('true');
|
|
123
|
-
expect(svg?.getAttribute('role')).toBeNull();
|
|
124
|
-
expect(svg?.getAttribute('aria-label')).toBeNull();
|
|
125
|
-
// No screen-reader table, no keyboard tab stop
|
|
126
|
-
expect(container.querySelector('.oc-sr-only')).toBeNull();
|
|
127
|
-
expect(container.getAttribute('tabindex')).toBeNull();
|
|
128
|
-
|
|
129
|
-
chart.destroy();
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('layout property returns the compiled layout', () => {
|
|
133
|
-
const chart = createChart(container, lineSpec);
|
|
134
|
-
|
|
135
|
-
expect(chart.layout).toBeDefined();
|
|
136
|
-
expect(chart.layout.dimensions.width).toBe(600);
|
|
137
|
-
expect(chart.layout.dimensions.height).toBe(400);
|
|
138
|
-
expect(chart.layout.chrome.title?.text).toBe('GDP Growth');
|
|
139
|
-
|
|
140
|
-
chart.destroy();
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('export("svg") returns a valid SVG string', () => {
|
|
144
|
-
const chart = createChart(container, lineSpec);
|
|
145
|
-
|
|
146
|
-
const svgString = chart.export('svg');
|
|
147
|
-
expect(svgString).toContain('<svg');
|
|
148
|
-
expect(svgString).toContain('viewBox');
|
|
149
|
-
expect(svgString).toContain('GDP Growth');
|
|
150
|
-
|
|
151
|
-
chart.destroy();
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('export("csv") returns CSV data', () => {
|
|
155
|
-
const chart = createChart(container, lineSpec);
|
|
156
|
-
|
|
157
|
-
const csv = chart.export('csv');
|
|
158
|
-
expect(csv).toContain('date');
|
|
159
|
-
expect(csv).toContain('value');
|
|
160
|
-
expect(csv).toContain('country');
|
|
161
|
-
|
|
162
|
-
chart.destroy();
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('renders axis elements', () => {
|
|
166
|
-
const chart = createChart(container, lineSpec);
|
|
167
|
-
|
|
168
|
-
const xAxis = container.querySelector('.oc-axis-x');
|
|
169
|
-
const yAxis = container.querySelector('.oc-axis-y');
|
|
170
|
-
expect(xAxis).not.toBeNull();
|
|
171
|
-
expect(yAxis).not.toBeNull();
|
|
172
|
-
|
|
173
|
-
chart.destroy();
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('renders chrome wrapper group', () => {
|
|
177
|
-
const chart = createChart(container, lineSpec);
|
|
178
|
-
|
|
179
|
-
const chromeGroup = container.querySelector('.oc-chrome');
|
|
180
|
-
expect(chromeGroup).not.toBeNull();
|
|
181
|
-
|
|
182
|
-
chart.destroy();
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it('passes responsive option to skip resize observer', () => {
|
|
186
|
-
// Should not throw with responsive: false
|
|
187
|
-
const chart = createChart(container, lineSpec, { responsive: false });
|
|
188
|
-
|
|
189
|
-
const svg = container.querySelector('svg');
|
|
190
|
-
expect(svg).not.toBeNull();
|
|
191
|
-
|
|
192
|
-
chart.destroy();
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('renders marks with data-mark-id attributes for data points', () => {
|
|
196
|
-
const chart = createChart(container, barSpec);
|
|
197
|
-
|
|
198
|
-
const marks = container.querySelectorAll('[data-mark-id]');
|
|
199
|
-
// barSpec has 3 data points, each should produce a mark
|
|
200
|
-
expect(marks.length).toBe(3);
|
|
201
|
-
|
|
202
|
-
for (const mark of marks) {
|
|
203
|
-
const id = mark.getAttribute('data-mark-id');
|
|
204
|
-
expect(id).toMatch(/^rect-\d+$/);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
chart.destroy();
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('multi-series marks have data-series attributes', () => {
|
|
211
|
-
const chart = createChart(container, lineSpec);
|
|
212
|
-
|
|
213
|
-
const marks = container.querySelectorAll('[data-series]');
|
|
214
|
-
expect(marks.length).toBeGreaterThan(0);
|
|
215
|
-
|
|
216
|
-
const seriesNames = new Set<string>();
|
|
217
|
-
for (const mark of marks) {
|
|
218
|
-
const series = mark.getAttribute('data-series');
|
|
219
|
-
expect(series).not.toBeNull();
|
|
220
|
-
seriesNames.add(series!);
|
|
221
|
-
}
|
|
222
|
-
// lineSpec has US and UK series
|
|
223
|
-
expect(seriesNames.has('US')).toBe(true);
|
|
224
|
-
expect(seriesNames.has('UK')).toBe(true);
|
|
225
|
-
|
|
226
|
-
chart.destroy();
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it('does NOT stamp data-display on default (full) charts', () => {
|
|
230
|
-
const chart = createChart(container, lineSpec);
|
|
231
|
-
const svg = container.querySelector('svg');
|
|
232
|
-
expect(svg?.hasAttribute('data-display')).toBe(false);
|
|
233
|
-
chart.destroy();
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it('stamps data-display="sparkline" when display is sparkline', () => {
|
|
237
|
-
const chart = createChart(container, {
|
|
238
|
-
mark: 'line',
|
|
239
|
-
data: lineSpec.data,
|
|
240
|
-
encoding: lineSpec.encoding,
|
|
241
|
-
display: 'sparkline',
|
|
242
|
-
});
|
|
243
|
-
const svg = container.querySelector('svg');
|
|
244
|
-
expect(svg?.getAttribute('data-display')).toBe('sparkline');
|
|
245
|
-
chart.destroy();
|
|
246
|
-
});
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
describe('resize observer integration', () => {
|
|
250
|
-
let container: HTMLDivElement;
|
|
251
|
-
|
|
252
|
-
beforeEach(() => {
|
|
253
|
-
container = createContainer();
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
afterEach(() => {
|
|
257
|
-
document.body.innerHTML = '';
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
it('resize() re-renders the chart', () => {
|
|
261
|
-
const chart = createChart(container, lineSpec);
|
|
262
|
-
|
|
263
|
-
// Should not throw
|
|
264
|
-
chart.resize();
|
|
265
|
-
|
|
266
|
-
const svg = container.querySelector('svg');
|
|
267
|
-
expect(svg).not.toBeNull();
|
|
268
|
-
|
|
269
|
-
chart.destroy();
|
|
270
|
-
});
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
describe('auto-height container growth', () => {
|
|
274
|
-
afterEach(() => {
|
|
275
|
-
document.body.innerHTML = '';
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
/** The chrome/legend/metrics blocks the mount grows the figure by. */
|
|
279
|
-
function overheadsOf(layout: ChartLayout, width: number): number {
|
|
280
|
-
const topLegendBlock =
|
|
281
|
-
layout.legend.position === 'top' &&
|
|
282
|
-
'entries' in layout.legend &&
|
|
283
|
-
layout.legend.entries.length > 0
|
|
284
|
-
? layout.legend.bounds.height + legendGap(width)
|
|
285
|
-
: 0;
|
|
286
|
-
return (
|
|
287
|
-
layout.chrome.topHeight +
|
|
288
|
-
layout.chrome.bottomHeight +
|
|
289
|
-
topLegendBlock +
|
|
290
|
-
(layout.metrics?.height ?? 0)
|
|
291
|
-
);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
it('grows the figure beyond the 400px budget by the measured overheads', () => {
|
|
295
|
-
const container = createAutoHeightContainer(390);
|
|
296
|
-
const chart = createChart(container, longChromeLineSpec);
|
|
297
|
-
|
|
298
|
-
const height = chart.layout.dimensions.height;
|
|
299
|
-
expect(height).toBeGreaterThan(400);
|
|
300
|
-
expect(height).toBeCloseTo(400 + overheadsOf(chart.layout, 390), 5);
|
|
301
|
-
|
|
302
|
-
// The rendered SVG is sized to the grown figure, not the 400 fallback.
|
|
303
|
-
const svg = container.querySelector('svg');
|
|
304
|
-
expect(svg?.getAttribute('viewBox')).toBe(`0 0 390 ${height}`);
|
|
305
|
-
expect((svg as SVGElement).style.height).toBe(`${height}px`);
|
|
306
|
-
|
|
307
|
-
chart.destroy();
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
it('keeps explicit-height containers at the host height', () => {
|
|
311
|
-
const container = createContainer(390, 500);
|
|
312
|
-
const chart = createChart(container, longChromeLineSpec);
|
|
313
|
-
|
|
314
|
-
// Host constrains the box; chrome must fit inside it, not grow it.
|
|
315
|
-
expect(chart.layout.dimensions.height).toBe(500);
|
|
316
|
-
|
|
317
|
-
chart.destroy();
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
it('re-renders against the 400 budget, not the grown height (no re-pin)', () => {
|
|
321
|
-
const container = createAutoHeightContainer(390);
|
|
322
|
-
const chart = createChart(container, longChromeLineSpec);
|
|
323
|
-
|
|
324
|
-
const grownHeight = chart.layout.dimensions.height;
|
|
325
|
-
expect(grownHeight).toBeGreaterThan(400);
|
|
326
|
-
|
|
327
|
-
// Same spec again: the container now measures at the grown SVG height,
|
|
328
|
-
// but the latched-auto mount keeps compiling against the 400 budget, so
|
|
329
|
-
// the figure height is stable.
|
|
330
|
-
chart.update(longChromeLineSpec);
|
|
331
|
-
expect(chart.layout.dimensions.height).toBe(grownHeight);
|
|
332
|
-
|
|
333
|
-
// Shorter chrome: the figure must shrink toward the budget instead of
|
|
334
|
-
// staying pinned at the previously grown container height.
|
|
335
|
-
chart.update(singleSeriesLineSpec);
|
|
336
|
-
const shrunkHeight = chart.layout.dimensions.height;
|
|
337
|
-
expect(shrunkHeight).toBeLessThan(grownHeight);
|
|
338
|
-
expect(shrunkHeight).toBeCloseTo(400 + overheadsOf(chart.layout, 390), 5);
|
|
339
|
-
|
|
340
|
-
chart.destroy();
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it('does not grow sparkline mounts', () => {
|
|
344
|
-
const container = createAutoHeightContainer(200);
|
|
345
|
-
const chart = createChart(container, {
|
|
346
|
-
mark: 'line',
|
|
347
|
-
data: lineSpec.data,
|
|
348
|
-
encoding: lineSpec.encoding,
|
|
349
|
-
display: 'sparkline',
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
// Sparkline auto-height fallback stays at the tiny 40px default.
|
|
353
|
-
expect(chart.layout.dimensions.height).toBe(40);
|
|
354
|
-
|
|
355
|
-
chart.destroy();
|
|
356
|
-
});
|
|
357
|
-
});
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fill pattern rendering: <pattern> defs lifecycle and mark fill references.
|
|
3
|
-
*
|
|
4
|
-
* Mirrors gradient-ids.test.ts for the pattern half of the defs system:
|
|
5
|
-
* pattern IDs come from the shared nextSvgId counter, so they are
|
|
6
|
-
* deterministic at generation time and globally unique across charts.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { ChartSpec } from '@opendata-ai/openchart-core';
|
|
10
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
11
|
-
import { createContainer } from '../__test-fixtures__/dom';
|
|
12
|
-
import { createChart } from '../mount';
|
|
13
|
-
|
|
14
|
-
function stackedSpec(): ChartSpec {
|
|
15
|
-
return {
|
|
16
|
-
mark: { type: 'bar', fillPattern: 'auto' },
|
|
17
|
-
data: [
|
|
18
|
-
{ quarter: 'Q1', source: 'Solar', twh: 30 },
|
|
19
|
-
{ quarter: 'Q1', source: 'Wind', twh: 45 },
|
|
20
|
-
{ quarter: 'Q1', source: 'Hydro', twh: 25 },
|
|
21
|
-
{ quarter: 'Q1', source: 'Gas', twh: 60 },
|
|
22
|
-
{ quarter: 'Q2', source: 'Solar', twh: 35 },
|
|
23
|
-
{ quarter: 'Q2', source: 'Wind', twh: 50 },
|
|
24
|
-
{ quarter: 'Q2', source: 'Hydro', twh: 22 },
|
|
25
|
-
{ quarter: 'Q2', source: 'Gas', twh: 55 },
|
|
26
|
-
],
|
|
27
|
-
encoding: {
|
|
28
|
-
x: { field: 'quarter', type: 'nominal' },
|
|
29
|
-
y: { field: 'twh', type: 'quantitative', stack: 'zero' },
|
|
30
|
-
color: { field: 'source', type: 'nominal' },
|
|
31
|
-
},
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function collectPatternIds(root: HTMLElement): string[] {
|
|
36
|
-
const ids: string[] = [];
|
|
37
|
-
for (const el of root.querySelectorAll('pattern')) {
|
|
38
|
-
const id = el.getAttribute('id');
|
|
39
|
-
if (id) ids.push(id);
|
|
40
|
-
}
|
|
41
|
-
return ids;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
describe('pattern fill rendering', () => {
|
|
45
|
-
let a: HTMLDivElement;
|
|
46
|
-
let b: HTMLDivElement;
|
|
47
|
-
|
|
48
|
-
beforeEach(() => {
|
|
49
|
-
a = createContainer();
|
|
50
|
-
b = createContainer();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
afterEach(() => {
|
|
54
|
-
document.body.innerHTML = '';
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('creates one pattern def per series and references them from bar fills', () => {
|
|
58
|
-
const chart = createChart(a, stackedSpec());
|
|
59
|
-
|
|
60
|
-
const ids = collectPatternIds(a);
|
|
61
|
-
expect(ids.length).toBe(4);
|
|
62
|
-
for (const id of ids) {
|
|
63
|
-
expect(id).toMatch(/^oc-pattern-\d+$/);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Each pattern tile carries a base rect plus pattern geometry
|
|
67
|
-
for (const pattern of a.querySelectorAll('pattern')) {
|
|
68
|
-
expect(pattern.querySelector('rect')).not.toBeNull();
|
|
69
|
-
expect(pattern.children.length).toBe(2);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Every large-enough bar segment fills from a pattern url
|
|
73
|
-
const rects = a.querySelectorAll('.oc-mark-rect rect, .oc-mark-rect path');
|
|
74
|
-
const patternFills = [...rects].filter((el) =>
|
|
75
|
-
(el.getAttribute('fill') ?? '').startsWith('url(#oc-pattern-'),
|
|
76
|
-
);
|
|
77
|
-
expect(patternFills.length).toBeGreaterThan(0);
|
|
78
|
-
|
|
79
|
-
chart.destroy();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('renders solid fills when fillPattern is absent', () => {
|
|
83
|
-
const spec = stackedSpec();
|
|
84
|
-
spec.mark = { type: 'bar' };
|
|
85
|
-
const chart = createChart(a, spec);
|
|
86
|
-
expect(collectPatternIds(a)).toEqual([]);
|
|
87
|
-
chart.destroy();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('two charts produce disjoint pattern IDs', () => {
|
|
91
|
-
const chartA = createChart(a, stackedSpec());
|
|
92
|
-
const chartB = createChart(b, stackedSpec());
|
|
93
|
-
|
|
94
|
-
const idsA = collectPatternIds(a);
|
|
95
|
-
const idsB = collectPatternIds(b);
|
|
96
|
-
expect(idsA.length).toBeGreaterThan(0);
|
|
97
|
-
expect(idsB.length).toBeGreaterThan(0);
|
|
98
|
-
|
|
99
|
-
const all = [...idsA, ...idsB];
|
|
100
|
-
expect(new Set(all).size).toBe(all.length);
|
|
101
|
-
|
|
102
|
-
chartA.destroy();
|
|
103
|
-
chartB.destroy();
|
|
104
|
-
});
|
|
105
|
-
});
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resize timing behavior.
|
|
3
|
-
*
|
|
4
|
-
* These tests verify that:
|
|
5
|
-
* 1. The inner observer's 16ms debounce coalesces a rapid burst of
|
|
6
|
-
* ResizeObserver entries into a single render (no thrash).
|
|
7
|
-
* 2. First ResizeObserver fire does not blank-flash (SVG stays mounted).
|
|
8
|
-
* 3. Resize events during an entrance animation are deferred and replayed
|
|
9
|
-
* on animation end.
|
|
10
|
-
*
|
|
11
|
-
* happy-dom ships a ResizeObserver stub that does not auto-fire on layout,
|
|
12
|
-
* so we override it with a controllable mock that lets us invoke the
|
|
13
|
-
* observer callback synchronously.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
17
|
-
import { createAutoHeightContainer, createContainer } from '../__test-fixtures__/dom';
|
|
18
|
-
import { lineSpec, longChromeLineSpec } from '../__test-fixtures__/specs';
|
|
19
|
-
import { createChart } from '../mount';
|
|
20
|
-
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
// Controllable ResizeObserver mock
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
type ObserverCallback = (entries: ResizeObserverEntry[]) => void;
|
|
26
|
-
|
|
27
|
-
interface FakeObserver {
|
|
28
|
-
element: Element | null;
|
|
29
|
-
callback: ObserverCallback;
|
|
30
|
-
disconnected: boolean;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const observers: FakeObserver[] = [];
|
|
34
|
-
|
|
35
|
-
class MockResizeObserver {
|
|
36
|
-
private readonly record: FakeObserver;
|
|
37
|
-
|
|
38
|
-
constructor(callback: ObserverCallback) {
|
|
39
|
-
this.record = { element: null, callback, disconnected: false };
|
|
40
|
-
observers.push(this.record);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
observe(element: Element): void {
|
|
44
|
-
this.record.element = element;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
disconnect(): void {
|
|
48
|
-
this.record.disconnected = true;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
unobserve(): void {
|
|
52
|
-
// no-op
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Fire a ResizeObserver callback for the given element with width/height. */
|
|
57
|
-
function fireResize(element: Element, width: number, height: number): void {
|
|
58
|
-
for (const o of observers) {
|
|
59
|
-
if (o.element === element && !o.disconnected) {
|
|
60
|
-
const entry = {
|
|
61
|
-
contentRect: { width, height, top: 0, left: 0, right: width, bottom: height, x: 0, y: 0 },
|
|
62
|
-
target: element,
|
|
63
|
-
} as unknown as ResizeObserverEntry;
|
|
64
|
-
o.callback([entry]);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// ---------------------------------------------------------------------------
|
|
70
|
-
// Tests
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
72
|
-
|
|
73
|
-
describe('chart resize timing', () => {
|
|
74
|
-
let container: HTMLDivElement;
|
|
75
|
-
let originalRO: typeof globalThis.ResizeObserver;
|
|
76
|
-
|
|
77
|
-
beforeEach(() => {
|
|
78
|
-
observers.length = 0;
|
|
79
|
-
originalRO = globalThis.ResizeObserver;
|
|
80
|
-
// Use MockResizeObserver so tests can drive observer fires deterministically.
|
|
81
|
-
// Cast via unknown to satisfy the stricter ResizeObserver type.
|
|
82
|
-
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
83
|
-
vi.useFakeTimers();
|
|
84
|
-
container = createContainer();
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
afterEach(() => {
|
|
88
|
-
vi.useRealTimers();
|
|
89
|
-
globalThis.ResizeObserver = originalRO;
|
|
90
|
-
document.body.innerHTML = '';
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('coalesces a rapid burst of resize events into a single render', async () => {
|
|
94
|
-
const chart = createChart(container, lineSpec);
|
|
95
|
-
const initialSvg = container.querySelector('svg');
|
|
96
|
-
expect(initialSvg).not.toBeNull();
|
|
97
|
-
|
|
98
|
-
// Track SVG node identity across renders: each render() replaces the SVG,
|
|
99
|
-
// so counting how many distinct SVG nodes appear tells us how many renders ran.
|
|
100
|
-
let renderCount = 0;
|
|
101
|
-
const mo = new MutationObserver((records) => {
|
|
102
|
-
for (const r of records) {
|
|
103
|
-
for (let i = 0; i < r.addedNodes.length; i++) {
|
|
104
|
-
const node = r.addedNodes[i];
|
|
105
|
-
if (node instanceof Element && node.tagName.toLowerCase() === 'svg') {
|
|
106
|
-
renderCount++;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
-
mo.observe(container, { childList: true });
|
|
112
|
-
|
|
113
|
-
// Fire 20 resize events within a tight window.
|
|
114
|
-
for (let i = 0; i < 20; i++) {
|
|
115
|
-
fireResize(container, 600 + i, 400);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Before the debounce window elapses, no new SVG has been appended.
|
|
119
|
-
expect(renderCount).toBe(0);
|
|
120
|
-
|
|
121
|
-
// Run all pending timers (16ms inner debounce + any outer delay).
|
|
122
|
-
await vi.runAllTimersAsync();
|
|
123
|
-
|
|
124
|
-
// MutationObserver records are delivered as microtasks; flush them.
|
|
125
|
-
mo.takeRecords().forEach((r) => {
|
|
126
|
-
for (let i = 0; i < r.addedNodes.length; i++) {
|
|
127
|
-
const node = r.addedNodes[i];
|
|
128
|
-
if (node instanceof Element && node.tagName.toLowerCase() === 'svg') {
|
|
129
|
-
renderCount++;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
// Exactly one re-render should have run for the whole burst (no thrash).
|
|
135
|
-
expect(renderCount).toBe(1);
|
|
136
|
-
|
|
137
|
-
mo.disconnect();
|
|
138
|
-
chart.destroy();
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('does not blank-flash: SVG remains mounted through first observer fire', async () => {
|
|
142
|
-
const chart = createChart(container, lineSpec);
|
|
143
|
-
|
|
144
|
-
// SVG is painted immediately on mount, before the observer fires.
|
|
145
|
-
const svgBefore = container.querySelector('svg');
|
|
146
|
-
expect(svgBefore).not.toBeNull();
|
|
147
|
-
|
|
148
|
-
// Fire the observer as it would on first layout.
|
|
149
|
-
fireResize(container, 600, 400);
|
|
150
|
-
|
|
151
|
-
// Even before the debounce elapses, the SVG must still be in the DOM.
|
|
152
|
-
expect(container.querySelector('svg')).not.toBeNull();
|
|
153
|
-
|
|
154
|
-
// And after timers flush, the SVG is still there (possibly a new node).
|
|
155
|
-
await vi.runAllTimersAsync();
|
|
156
|
-
expect(container.querySelector('svg')).not.toBeNull();
|
|
157
|
-
|
|
158
|
-
chart.destroy();
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('disconnects the observer on destroy', () => {
|
|
162
|
-
const chart = createChart(container, lineSpec);
|
|
163
|
-
expect(observers.length).toBe(1);
|
|
164
|
-
expect(observers[0].disconnected).toBe(false);
|
|
165
|
-
|
|
166
|
-
chart.destroy();
|
|
167
|
-
|
|
168
|
-
expect(observers[0].disconnected).toBe(true);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('clears any pending resize timer on destroy (no callback after teardown)', async () => {
|
|
172
|
-
const chart = createChart(container, lineSpec);
|
|
173
|
-
|
|
174
|
-
fireResize(container, 700, 500);
|
|
175
|
-
// Destroy before the debounce elapses.
|
|
176
|
-
chart.destroy();
|
|
177
|
-
|
|
178
|
-
await vi.runAllTimersAsync();
|
|
179
|
-
|
|
180
|
-
// destroy() removes the SVG; if the timer fired after destroy it would
|
|
181
|
-
// try to render a new one. No SVG should be present.
|
|
182
|
-
expect(container.querySelector('svg')).toBeNull();
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
describe('auto-height observer feedback guard', () => {
|
|
186
|
-
it('ignores the observer echo from the figure growing itself', async () => {
|
|
187
|
-
const autoContainer = createAutoHeightContainer(390);
|
|
188
|
-
const chart = createChart(autoContainer, longChromeLineSpec);
|
|
189
|
-
|
|
190
|
-
const grownHeight = chart.layout.dimensions.height;
|
|
191
|
-
expect(grownHeight).toBeGreaterThan(400);
|
|
192
|
-
const genBefore = autoContainer.dataset.ocRenderGen;
|
|
193
|
-
|
|
194
|
-
// Growing the SVG grows the container, which fires the observer with
|
|
195
|
-
// our own dimensions. That echo must not trigger a re-render (the
|
|
196
|
-
// re-render would grow again and loop).
|
|
197
|
-
fireResize(autoContainer, 390, grownHeight);
|
|
198
|
-
await vi.runAllTimersAsync();
|
|
199
|
-
expect(autoContainer.dataset.ocRenderGen).toBe(genBefore);
|
|
200
|
-
|
|
201
|
-
chart.destroy();
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('still re-renders when the width changes', async () => {
|
|
205
|
-
const autoContainer = createAutoHeightContainer(390);
|
|
206
|
-
const chart = createChart(autoContainer, longChromeLineSpec);
|
|
207
|
-
|
|
208
|
-
const grownHeight = chart.layout.dimensions.height;
|
|
209
|
-
const genBefore = autoContainer.dataset.ocRenderGen;
|
|
210
|
-
|
|
211
|
-
fireResize(autoContainer, 340, grownHeight);
|
|
212
|
-
await vi.runAllTimersAsync();
|
|
213
|
-
expect(autoContainer.dataset.ocRenderGen).not.toBe(genBefore);
|
|
214
|
-
|
|
215
|
-
chart.destroy();
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
});
|