@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,387 +0,0 @@
|
|
|
1
|
-
import type { ChartSpec } from '@opendata-ai/openchart-engine';
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
-
import { createContainer, createMouseEvent } from '../__test-fixtures__/dom';
|
|
4
|
-
import { barSpec, lineSpec } from '../__test-fixtures__/specs';
|
|
5
|
-
import { createChart } from '../mount';
|
|
6
|
-
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
// Test data
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
|
|
11
|
-
const annotatedSpec: ChartSpec = {
|
|
12
|
-
...barSpec,
|
|
13
|
-
annotations: [{ type: 'refline', y: 0, label: 'Zero' }],
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const textAnnotatedSpec: ChartSpec = {
|
|
17
|
-
...barSpec,
|
|
18
|
-
annotations: [{ type: 'text', x: 10, y: 'A', text: 'Peak', offset: { dx: 10, dy: -20 } }],
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
// Tests
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
describe('chart event handlers', () => {
|
|
26
|
-
let container: HTMLDivElement;
|
|
27
|
-
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
container = createContainer();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
afterEach(() => {
|
|
33
|
-
document.body.innerHTML = '';
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe('onMarkClick', () => {
|
|
37
|
-
it('fires with correct data when a mark element is clicked', () => {
|
|
38
|
-
const onMarkClick = vi.fn();
|
|
39
|
-
const chart = createChart(container, barSpec, { onMarkClick });
|
|
40
|
-
|
|
41
|
-
// Find a rect mark element (bar chart renders rect marks)
|
|
42
|
-
const mark = container.querySelector('[data-mark-id]');
|
|
43
|
-
expect(mark).not.toBeNull();
|
|
44
|
-
|
|
45
|
-
mark!.dispatchEvent(createMouseEvent('click', 150, 200));
|
|
46
|
-
|
|
47
|
-
expect(onMarkClick).toHaveBeenCalledTimes(1);
|
|
48
|
-
const event = onMarkClick.mock.calls[0][0];
|
|
49
|
-
expect(event.datum).toBeDefined();
|
|
50
|
-
expect(event.position).toBeDefined();
|
|
51
|
-
expect(event.position.x).toBeTypeOf('number');
|
|
52
|
-
expect(event.position.y).toBeTypeOf('number');
|
|
53
|
-
expect(event.event).toBeInstanceOf(MouseEvent);
|
|
54
|
-
|
|
55
|
-
chart.destroy();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('includes series info for multi-series charts', () => {
|
|
59
|
-
const onMarkClick = vi.fn();
|
|
60
|
-
const chart = createChart(container, lineSpec, { onMarkClick });
|
|
61
|
-
|
|
62
|
-
// Line charts have marks with data-series attribute
|
|
63
|
-
const marks = container.querySelectorAll('[data-mark-id]');
|
|
64
|
-
expect(marks.length).toBeGreaterThan(0);
|
|
65
|
-
|
|
66
|
-
marks[0].dispatchEvent(createMouseEvent('click'));
|
|
67
|
-
|
|
68
|
-
expect(onMarkClick).toHaveBeenCalledTimes(1);
|
|
69
|
-
const event = onMarkClick.mock.calls[0][0];
|
|
70
|
-
// Line marks should have series info
|
|
71
|
-
expect(event.series).toBeDefined();
|
|
72
|
-
|
|
73
|
-
chart.destroy();
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe('onMarkHover', () => {
|
|
78
|
-
it('fires on mouseenter of a mark element', () => {
|
|
79
|
-
const onMarkHover = vi.fn();
|
|
80
|
-
const chart = createChart(container, barSpec, { onMarkHover });
|
|
81
|
-
|
|
82
|
-
const mark = container.querySelector('[data-mark-id]');
|
|
83
|
-
expect(mark).not.toBeNull();
|
|
84
|
-
|
|
85
|
-
mark!.dispatchEvent(createMouseEvent('mouseenter', 120, 180));
|
|
86
|
-
|
|
87
|
-
expect(onMarkHover).toHaveBeenCalledTimes(1);
|
|
88
|
-
const event = onMarkHover.mock.calls[0][0];
|
|
89
|
-
expect(event.datum).toBeDefined();
|
|
90
|
-
expect(event.position).toBeDefined();
|
|
91
|
-
|
|
92
|
-
chart.destroy();
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe('onMarkLeave', () => {
|
|
97
|
-
it('fires on mouseleave of a mark element', () => {
|
|
98
|
-
const onMarkLeave = vi.fn();
|
|
99
|
-
const chart = createChart(container, barSpec, { onMarkLeave });
|
|
100
|
-
|
|
101
|
-
const mark = container.querySelector('[data-mark-id]');
|
|
102
|
-
expect(mark).not.toBeNull();
|
|
103
|
-
|
|
104
|
-
mark!.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
|
105
|
-
|
|
106
|
-
expect(onMarkLeave).toHaveBeenCalledTimes(1);
|
|
107
|
-
// onMarkLeave receives no arguments
|
|
108
|
-
expect(onMarkLeave.mock.calls[0]).toHaveLength(0);
|
|
109
|
-
|
|
110
|
-
chart.destroy();
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe('onLegendToggle', () => {
|
|
115
|
-
it('fires when a legend entry is clicked, and toggling back restores the same series', () => {
|
|
116
|
-
const onLegendToggle = vi.fn();
|
|
117
|
-
const chart = createChart(
|
|
118
|
-
container,
|
|
119
|
-
{ ...lineSpec, legend: { show: true } },
|
|
120
|
-
{
|
|
121
|
-
onLegendToggle,
|
|
122
|
-
},
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
const firstEntry = container.querySelector('[data-legend-index]') as HTMLElement | null;
|
|
126
|
-
expect(firstEntry).not.toBeNull();
|
|
127
|
-
const targetLabel = firstEntry!.getAttribute('data-legend-label')!;
|
|
128
|
-
expect(targetLabel).toBeTypeOf('string');
|
|
129
|
-
|
|
130
|
-
firstEntry!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
131
|
-
|
|
132
|
-
expect(onLegendToggle).toHaveBeenCalledTimes(1);
|
|
133
|
-
const [hiddenSeries, visibleAfterFirst] = onLegendToggle.mock.calls[0];
|
|
134
|
-
expect(hiddenSeries).toBe(targetLabel);
|
|
135
|
-
expect(visibleAfterFirst).toBe(false);
|
|
136
|
-
|
|
137
|
-
// Toggling triggers a recompile (so the y-scale rebalances and any
|
|
138
|
-
// endpoint labels for the hidden series clear), which replaces the
|
|
139
|
-
// legend DOM. Find the same entry by label and click it again.
|
|
140
|
-
const sameEntry = container.querySelector(
|
|
141
|
-
`[data-legend-label="${targetLabel}"]`,
|
|
142
|
-
) as HTMLElement | null;
|
|
143
|
-
expect(sameEntry).not.toBeNull();
|
|
144
|
-
sameEntry!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
145
|
-
|
|
146
|
-
expect(onLegendToggle).toHaveBeenCalledTimes(2);
|
|
147
|
-
const [shownSeries, visibleAfterSecond] = onLegendToggle.mock.calls[1];
|
|
148
|
-
expect(shownSeries).toBe(targetLabel); // same series, not the other one
|
|
149
|
-
expect(visibleAfterSecond).toBe(true);
|
|
150
|
-
|
|
151
|
-
chart.destroy();
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('refuses to hide the last visible series', () => {
|
|
155
|
-
// Two-series chart: hide one, then attempt to hide the other.
|
|
156
|
-
// The second click should be a no-op (no toggle callback for the hide).
|
|
157
|
-
const onLegendToggle = vi.fn();
|
|
158
|
-
const chart = createChart(
|
|
159
|
-
container,
|
|
160
|
-
{ ...lineSpec, legend: { show: true } },
|
|
161
|
-
{ onLegendToggle },
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
const entries = container.querySelectorAll('[data-legend-label]');
|
|
165
|
-
expect(entries.length).toBeGreaterThanOrEqual(2);
|
|
166
|
-
const firstLabel = (entries[0] as HTMLElement).getAttribute('data-legend-label')!;
|
|
167
|
-
|
|
168
|
-
// Hide the first one.
|
|
169
|
-
(entries[0] as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
170
|
-
expect(onLegendToggle).toHaveBeenCalledTimes(1);
|
|
171
|
-
|
|
172
|
-
// Click the *other* (still-visible) series. The guard should refuse.
|
|
173
|
-
const remaining = Array.from(container.querySelectorAll('[data-legend-label]')).find(
|
|
174
|
-
(el) => (el as HTMLElement).getAttribute('data-legend-label') !== firstLabel,
|
|
175
|
-
) as HTMLElement | undefined;
|
|
176
|
-
expect(remaining).toBeTruthy();
|
|
177
|
-
remaining!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
178
|
-
|
|
179
|
-
// The handler still fires (the click bubbled and the wired listener ran),
|
|
180
|
-
// but the visible flag should report that nothing was actually hidden.
|
|
181
|
-
// Implementation detail: we expect the second call to report visible=true
|
|
182
|
-
// (the series is still visible because the toggle was refused).
|
|
183
|
-
expect(onLegendToggle).toHaveBeenCalledTimes(2);
|
|
184
|
-
const [, visibleAfter] = onLegendToggle.mock.calls[1];
|
|
185
|
-
expect(visibleAfter).toBe(true);
|
|
186
|
-
|
|
187
|
-
chart.destroy();
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
describe('onAnnotationClick', () => {
|
|
192
|
-
it('fires when an annotation element is clicked', () => {
|
|
193
|
-
const onAnnotationClick = vi.fn();
|
|
194
|
-
const chart = createChart(container, annotatedSpec, { onAnnotationClick });
|
|
195
|
-
|
|
196
|
-
const annotation = container.querySelector('.oc-annotation');
|
|
197
|
-
if (!annotation) {
|
|
198
|
-
// Refline at y=0 may resolve outside the chart area in test env
|
|
199
|
-
chart.destroy();
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
annotation.dispatchEvent(createMouseEvent('click'));
|
|
204
|
-
|
|
205
|
-
expect(onAnnotationClick).toHaveBeenCalledTimes(1);
|
|
206
|
-
const [annotationArg, mouseEvent] = onAnnotationClick.mock.calls[0];
|
|
207
|
-
expect(annotationArg).toBeDefined();
|
|
208
|
-
expect(annotationArg.type).toBe('refline');
|
|
209
|
-
expect(mouseEvent).toBeInstanceOf(MouseEvent);
|
|
210
|
-
|
|
211
|
-
chart.destroy();
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
describe('onAnnotationEdit', () => {
|
|
216
|
-
it('sets cursor:grab on text annotations when onAnnotationEdit is provided', () => {
|
|
217
|
-
const onAnnotationEdit = vi.fn();
|
|
218
|
-
const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit });
|
|
219
|
-
|
|
220
|
-
const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null;
|
|
221
|
-
if (!annotation) {
|
|
222
|
-
chart.destroy();
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
expect(annotation.style.cursor).toBe('grab');
|
|
227
|
-
chart.destroy();
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it('does not set cursor:grab on non-text annotations', () => {
|
|
231
|
-
const onAnnotationEdit = vi.fn();
|
|
232
|
-
const chart = createChart(container, annotatedSpec, { onAnnotationEdit });
|
|
233
|
-
|
|
234
|
-
const annotation = container.querySelector('.oc-annotation-refline') as SVGGElement | null;
|
|
235
|
-
if (!annotation) {
|
|
236
|
-
chart.destroy();
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
expect(annotation.style.cursor).not.toBe('grab');
|
|
241
|
-
chart.destroy();
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it('fires with original annotation and updated offset after drag', () => {
|
|
245
|
-
const onAnnotationEdit = vi.fn();
|
|
246
|
-
const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit });
|
|
247
|
-
|
|
248
|
-
const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null;
|
|
249
|
-
if (!annotation) {
|
|
250
|
-
chart.destroy();
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Simulate drag: mousedown -> mousemove -> mouseup
|
|
255
|
-
annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100));
|
|
256
|
-
document.dispatchEvent(createMouseEvent('mousemove', 150, 130));
|
|
257
|
-
document.dispatchEvent(createMouseEvent('mouseup', 150, 130));
|
|
258
|
-
|
|
259
|
-
expect(onAnnotationEdit).toHaveBeenCalledTimes(1);
|
|
260
|
-
const [annotationArg, offsetArg] = onAnnotationEdit.mock.calls[0];
|
|
261
|
-
expect(annotationArg.type).toBe('text');
|
|
262
|
-
expect(annotationArg.text).toBe('Peak');
|
|
263
|
-
// Original offset (10, -20) + drag delta (50, 30) = (60, 10)
|
|
264
|
-
expect(offsetArg.dx).toBe(60);
|
|
265
|
-
expect(offsetArg.dy).toBe(10);
|
|
266
|
-
|
|
267
|
-
chart.destroy();
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it('does not fire when drag movement is below threshold', () => {
|
|
271
|
-
const onAnnotationEdit = vi.fn();
|
|
272
|
-
const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit });
|
|
273
|
-
|
|
274
|
-
const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null;
|
|
275
|
-
if (!annotation) {
|
|
276
|
-
chart.destroy();
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Simulate drag with minimal movement (< 3px)
|
|
281
|
-
annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100));
|
|
282
|
-
document.dispatchEvent(createMouseEvent('mousemove', 101, 101));
|
|
283
|
-
document.dispatchEvent(createMouseEvent('mouseup', 101, 101));
|
|
284
|
-
|
|
285
|
-
expect(onAnnotationEdit).not.toHaveBeenCalled();
|
|
286
|
-
chart.destroy();
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it('suppresses click after drag', () => {
|
|
290
|
-
const onAnnotationClick = vi.fn();
|
|
291
|
-
const onAnnotationEdit = vi.fn();
|
|
292
|
-
const chart = createChart(container, textAnnotatedSpec, {
|
|
293
|
-
onAnnotationClick,
|
|
294
|
-
onAnnotationEdit,
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null;
|
|
298
|
-
if (!annotation) {
|
|
299
|
-
chart.destroy();
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Drag > 3px
|
|
304
|
-
annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100));
|
|
305
|
-
document.dispatchEvent(createMouseEvent('mousemove', 150, 130));
|
|
306
|
-
document.dispatchEvent(createMouseEvent('mouseup', 150, 130));
|
|
307
|
-
|
|
308
|
-
// Fire a click after the drag
|
|
309
|
-
annotation.dispatchEvent(createMouseEvent('click', 150, 130));
|
|
310
|
-
|
|
311
|
-
expect(onAnnotationEdit).toHaveBeenCalledTimes(1);
|
|
312
|
-
// Click should have been suppressed by the one-time capture handler
|
|
313
|
-
expect(onAnnotationClick).not.toHaveBeenCalled();
|
|
314
|
-
|
|
315
|
-
chart.destroy();
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
it('applies transform during drag', () => {
|
|
319
|
-
const onAnnotationEdit = vi.fn();
|
|
320
|
-
const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit });
|
|
321
|
-
|
|
322
|
-
const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null;
|
|
323
|
-
if (!annotation) {
|
|
324
|
-
chart.destroy();
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100));
|
|
329
|
-
document.dispatchEvent(createMouseEvent('mousemove', 140, 120));
|
|
330
|
-
|
|
331
|
-
// During drag, the annotation group should have a transform
|
|
332
|
-
const transform = annotation.getAttribute('transform');
|
|
333
|
-
expect(transform).toContain('translate');
|
|
334
|
-
|
|
335
|
-
// Complete the drag to clean up
|
|
336
|
-
document.dispatchEvent(createMouseEvent('mouseup', 140, 120));
|
|
337
|
-
chart.destroy();
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
it('cleans up listeners on destroy', () => {
|
|
341
|
-
const onAnnotationEdit = vi.fn();
|
|
342
|
-
const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit });
|
|
343
|
-
|
|
344
|
-
chart.destroy();
|
|
345
|
-
|
|
346
|
-
// After destroy, dispatching events should not cause errors
|
|
347
|
-
document.dispatchEvent(createMouseEvent('mousemove', 200, 200));
|
|
348
|
-
document.dispatchEvent(createMouseEvent('mouseup', 200, 200));
|
|
349
|
-
|
|
350
|
-
expect(onAnnotationEdit).not.toHaveBeenCalled();
|
|
351
|
-
});
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
describe('cleanup', () => {
|
|
355
|
-
it('removes event listeners on destroy', () => {
|
|
356
|
-
const onMarkClick = vi.fn();
|
|
357
|
-
const chart = createChart(container, barSpec, { onMarkClick });
|
|
358
|
-
|
|
359
|
-
const mark = container.querySelector('[data-mark-id]');
|
|
360
|
-
expect(mark).not.toBeNull();
|
|
361
|
-
|
|
362
|
-
chart.destroy();
|
|
363
|
-
|
|
364
|
-
// After destroy, clicking should not fire the handler
|
|
365
|
-
// (the SVG is removed, but we verify the handler reference is cleaned up)
|
|
366
|
-
expect(onMarkClick).not.toHaveBeenCalled();
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
it('removes event listeners on update', () => {
|
|
370
|
-
const onMarkClick = vi.fn();
|
|
371
|
-
const chart = createChart(container, barSpec, { onMarkClick });
|
|
372
|
-
|
|
373
|
-
// Update triggers a re-render which should clean up old listeners
|
|
374
|
-
chart.update(lineSpec);
|
|
375
|
-
|
|
376
|
-
// Find mark in the new render
|
|
377
|
-
const mark = container.querySelector('[data-mark-id]');
|
|
378
|
-
expect(mark).not.toBeNull();
|
|
379
|
-
|
|
380
|
-
// Click the new mark - should still fire since new listeners were wired
|
|
381
|
-
mark!.dispatchEvent(createMouseEvent('click'));
|
|
382
|
-
expect(onMarkClick).toHaveBeenCalledTimes(1);
|
|
383
|
-
|
|
384
|
-
chart.destroy();
|
|
385
|
-
});
|
|
386
|
-
});
|
|
387
|
-
});
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Export utility tests.
|
|
3
|
-
*
|
|
4
|
-
* Tests exportSVG, exportSVGWithFonts, exportCSV, exportPNG, and exportJPG
|
|
5
|
-
* functions directly, verifying SVG string validity, font embedding,
|
|
6
|
-
* dimension parsing, CSV formatting, and raster export interfaces.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { CompileOptions } from '@opendata-ai/openchart-engine';
|
|
10
|
-
import { compileChart } from '@opendata-ai/openchart-engine';
|
|
11
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
12
|
-
import { createContainer } from '../__test-fixtures__/dom';
|
|
13
|
-
import { barSpec, lineSpec } from '../__test-fixtures__/specs';
|
|
14
|
-
import { exportCSV, exportJPG, exportPNG, exportSVG, exportSVGWithFonts } from '../export';
|
|
15
|
-
import { renderChartSVG } from '../svg-renderer';
|
|
16
|
-
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// Helpers
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
const COMPILE_OPTS: CompileOptions = { width: 600, height: 400 };
|
|
22
|
-
|
|
23
|
-
function renderToSVG(spec = lineSpec) {
|
|
24
|
-
const container = createContainer();
|
|
25
|
-
const layout = compileChart(spec, COMPILE_OPTS);
|
|
26
|
-
return renderChartSVG(layout, container);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
afterEach(() => {
|
|
30
|
-
document.body.innerHTML = '';
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
// exportSVG
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
|
|
37
|
-
describe('exportSVG', () => {
|
|
38
|
-
it('returns a string starting with <svg', () => {
|
|
39
|
-
const svg = renderToSVG();
|
|
40
|
-
const result = exportSVG(svg);
|
|
41
|
-
expect(result.startsWith('<svg')).toBe(true);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('returned string contains viewBox attribute', () => {
|
|
45
|
-
const svg = renderToSVG();
|
|
46
|
-
const result = exportSVG(svg);
|
|
47
|
-
expect(result).toContain('viewBox="0 0 600 400"');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('returned string contains chart chrome text content', () => {
|
|
51
|
-
const svg = renderToSVG(lineSpec);
|
|
52
|
-
const result = exportSVG(svg);
|
|
53
|
-
expect(result).toContain('GDP Growth');
|
|
54
|
-
expect(result).toContain('US vs UK over time');
|
|
55
|
-
expect(result).toContain('World Bank');
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('returned string contains SVG namespace', () => {
|
|
59
|
-
const svg = renderToSVG();
|
|
60
|
-
const result = exportSVG(svg);
|
|
61
|
-
expect(result).toContain('xmlns');
|
|
62
|
-
expect(result).toContain('http://www.w3.org/2000/svg');
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('returned string includes mark elements', () => {
|
|
66
|
-
const svg = renderToSVG(barSpec);
|
|
67
|
-
const result = exportSVG(svg);
|
|
68
|
-
// Bar chart should have rect elements
|
|
69
|
-
expect(result).toContain('<rect');
|
|
70
|
-
expect(result).toContain('oc-mark-rect');
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('returned string is well-formed XML (ends with closing svg tag)', () => {
|
|
74
|
-
const svg = renderToSVG();
|
|
75
|
-
const result = exportSVG(svg);
|
|
76
|
-
expect(result.endsWith('</svg>')).toBe(true);
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
// exportSVGWithFonts
|
|
82
|
-
// ---------------------------------------------------------------------------
|
|
83
|
-
|
|
84
|
-
describe('exportSVGWithFonts', () => {
|
|
85
|
-
it('returns a promise', () => {
|
|
86
|
-
const svg = renderToSVG();
|
|
87
|
-
const result = exportSVGWithFonts(svg);
|
|
88
|
-
expect(result).toBeInstanceOf(Promise);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('resolves to a valid SVG string', async () => {
|
|
92
|
-
const svg = renderToSVG();
|
|
93
|
-
const result = await exportSVGWithFonts(svg);
|
|
94
|
-
expect(result.startsWith('<svg')).toBe(true);
|
|
95
|
-
expect(result.endsWith('</svg>')).toBe(true);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('skips font embedding when embedFonts is false', async () => {
|
|
99
|
-
const svg = renderToSVG();
|
|
100
|
-
const result = await exportSVGWithFonts(svg, { embedFonts: false });
|
|
101
|
-
// Should not contain @font-face (no stylesheets in test env anyway)
|
|
102
|
-
expect(result).not.toContain('@font-face');
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('produces valid SVG even without stylesheets in the document', async () => {
|
|
106
|
-
const svg = renderToSVG();
|
|
107
|
-
// In test env, no Google Fonts stylesheets exist, so font collection
|
|
108
|
-
// should gracefully return nothing and the export should still work
|
|
109
|
-
const result = await exportSVGWithFonts(svg);
|
|
110
|
-
expect(result).toContain('viewBox="0 0 600 400"');
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// ---------------------------------------------------------------------------
|
|
115
|
-
// Dimension parsing (via exportPNG which uses getSVGDimensions internally)
|
|
116
|
-
// ---------------------------------------------------------------------------
|
|
117
|
-
|
|
118
|
-
describe('dimension parsing', () => {
|
|
119
|
-
it('exportPNG reads dimensions from viewBox when width/height are absent', () => {
|
|
120
|
-
const svg = renderToSVG();
|
|
121
|
-
// Verify the SVG has viewBox but no explicit width/height
|
|
122
|
-
expect(svg.getAttribute('viewBox')).toBe('0 0 600 400');
|
|
123
|
-
expect(svg.getAttribute('width')).toBeNull();
|
|
124
|
-
// exportPNG should still work (not fall back to 600x400 by accident)
|
|
125
|
-
const result = exportPNG(svg, { dpi: 1, embedFonts: false });
|
|
126
|
-
expect(result).toBeInstanceOf(Promise);
|
|
127
|
-
result.catch(() => {}); // happy-dom canvas limitations
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
// exportCSV
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
|
|
135
|
-
describe('exportCSV', () => {
|
|
136
|
-
it('returns empty string for empty data', () => {
|
|
137
|
-
const result = exportCSV([]);
|
|
138
|
-
expect(result).toBe('');
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('first line contains column headers from data keys', () => {
|
|
142
|
-
const data = [
|
|
143
|
-
{ name: 'Alice', age: 30, city: 'Portland' },
|
|
144
|
-
{ name: 'Bob', age: 25, city: 'Seattle' },
|
|
145
|
-
];
|
|
146
|
-
const result = exportCSV(data);
|
|
147
|
-
const firstLine = result.split('\n')[0];
|
|
148
|
-
expect(firstLine).toBe('name,age,city');
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('data rows follow header line', () => {
|
|
152
|
-
const data = [
|
|
153
|
-
{ name: 'Alice', age: 30 },
|
|
154
|
-
{ name: 'Bob', age: 25 },
|
|
155
|
-
];
|
|
156
|
-
const result = exportCSV(data);
|
|
157
|
-
const lines = result.split('\n');
|
|
158
|
-
expect(lines.length).toBe(3); // 1 header + 2 data rows
|
|
159
|
-
expect(lines[1]).toBe('Alice,30');
|
|
160
|
-
expect(lines[2]).toBe('Bob,25');
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('escapes values containing commas', () => {
|
|
164
|
-
const data = [{ name: 'Doe, John', value: 42 }];
|
|
165
|
-
const result = exportCSV(data);
|
|
166
|
-
const lines = result.split('\n');
|
|
167
|
-
// Comma in value should be wrapped in double quotes
|
|
168
|
-
expect(lines[1]).toBe('"Doe, John",42');
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('escapes values containing double quotes', () => {
|
|
172
|
-
const data = [{ name: 'Say "hi"', value: 1 }];
|
|
173
|
-
const result = exportCSV(data);
|
|
174
|
-
const lines = result.split('\n');
|
|
175
|
-
// Double quotes should be doubled and the field wrapped in quotes
|
|
176
|
-
expect(lines[1]).toBe('"Say ""hi""",1');
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('escapes values containing newlines', () => {
|
|
180
|
-
const data = [{ name: 'Line1\nLine2', value: 1 }];
|
|
181
|
-
const result = exportCSV(data);
|
|
182
|
-
// The entire field should be quoted since it contains a newline
|
|
183
|
-
expect(result).toContain('"Line1\nLine2"');
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it('handles undefined and null values as empty strings', () => {
|
|
187
|
-
const data = [{ a: undefined, b: null, c: 'ok' }];
|
|
188
|
-
const result = exportCSV(data);
|
|
189
|
-
const lines = result.split('\n');
|
|
190
|
-
// undefined and null should render as empty
|
|
191
|
-
expect(lines[1]).toBe(',,ok');
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('uses keys from first row as headers', () => {
|
|
195
|
-
const data = [
|
|
196
|
-
{ x: 1, y: 2, z: 3 },
|
|
197
|
-
{ x: 4, y: 5, z: 6 },
|
|
198
|
-
];
|
|
199
|
-
const result = exportCSV(data);
|
|
200
|
-
expect(result.split('\n')[0]).toBe('x,y,z');
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
// ---------------------------------------------------------------------------
|
|
205
|
-
// exportPNG
|
|
206
|
-
// ---------------------------------------------------------------------------
|
|
207
|
-
|
|
208
|
-
describe('exportPNG', () => {
|
|
209
|
-
it('returns a Promise when called with a rendered SVG element', () => {
|
|
210
|
-
const svg = renderToSVG();
|
|
211
|
-
const result = exportPNG(svg, { embedFonts: false });
|
|
212
|
-
expect(result).toBeInstanceOf(Promise);
|
|
213
|
-
result.catch(() => {});
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
// ---------------------------------------------------------------------------
|
|
218
|
-
// exportJPG
|
|
219
|
-
// ---------------------------------------------------------------------------
|
|
220
|
-
|
|
221
|
-
describe('exportJPG', () => {
|
|
222
|
-
it('is a function that accepts an SVG element and options', () => {
|
|
223
|
-
expect(typeof exportJPG).toBe('function');
|
|
224
|
-
expect(exportJPG.length).toBeGreaterThanOrEqual(1);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it('returns a Promise when called with a rendered SVG element', () => {
|
|
228
|
-
const svg = renderToSVG();
|
|
229
|
-
const result = exportJPG(svg, { embedFonts: false });
|
|
230
|
-
expect(result).toBeInstanceOf(Promise);
|
|
231
|
-
// Clean up: catch any rejection from happy-dom canvas limitations
|
|
232
|
-
result.catch(() => {});
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it('accepts quality option between 0 and 1', () => {
|
|
236
|
-
const svg = renderToSVG();
|
|
237
|
-
const result = exportJPG(svg, { quality: 0.5, dpi: 1, embedFonts: false });
|
|
238
|
-
expect(result).toBeInstanceOf(Promise);
|
|
239
|
-
result.catch(() => {});
|
|
240
|
-
});
|
|
241
|
-
});
|