@opendata-ai/openchart-vanilla 2.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/index.d.ts +327 -0
- package/dist/index.js +4745 -0
- package/dist/index.js.map +1 -0
- package/dist/simulation-worker.js +1196 -0
- package/package.json +58 -0
- package/src/__test-fixtures__/dom.ts +42 -0
- package/src/__test-fixtures__/specs.ts +187 -0
- package/src/__tests__/edit-events.test.ts +747 -0
- package/src/__tests__/events.test.ts +336 -0
- package/src/__tests__/export.test.ts +150 -0
- package/src/__tests__/mount.test.ts +219 -0
- package/src/__tests__/svg-renderer.test.ts +609 -0
- package/src/__tests__/table-mount.test.ts +484 -0
- package/src/__tests__/tooltip.test.ts +201 -0
- package/src/export.ts +105 -0
- package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
- package/src/graph/__tests__/graph-mount.test.ts +213 -0
- package/src/graph/__tests__/interaction.test.ts +205 -0
- package/src/graph/__tests__/keyboard.test.ts +653 -0
- package/src/graph/__tests__/search.test.ts +88 -0
- package/src/graph/__tests__/simulation.test.ts +233 -0
- package/src/graph/__tests__/spatial-index.test.ts +142 -0
- package/src/graph/__tests__/zoom.test.ts +195 -0
- package/src/graph/canvas-renderer.ts +660 -0
- package/src/graph/interaction.ts +359 -0
- package/src/graph/keyboard.ts +208 -0
- package/src/graph/search.ts +50 -0
- package/src/graph/simulation-worker-url.ts +30 -0
- package/src/graph/simulation-worker.ts +265 -0
- package/src/graph/simulation.ts +350 -0
- package/src/graph/spatial-index.ts +121 -0
- package/src/graph/types.ts +44 -0
- package/src/graph/worker-protocol.ts +67 -0
- package/src/graph/zoom.ts +104 -0
- package/src/graph-mount.ts +675 -0
- package/src/index.ts +56 -0
- package/src/mount.ts +1639 -0
- package/src/renderers/table-cells.ts +444 -0
- package/src/resize-observer.ts +46 -0
- package/src/svg-renderer.ts +914 -0
- package/src/table-keyboard.ts +266 -0
- package/src/table-mount.ts +532 -0
- package/src/table-renderer.ts +350 -0
- package/src/tooltip.ts +120 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
import type { ChartSpec, ElementEdit } 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
|
+
// Shared specs
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Line chart with text annotation + connector, range, refline, chrome, legend,
|
|
13
|
+
* and multi-series data (which produces series labels).
|
|
14
|
+
*/
|
|
15
|
+
const fullEditSpec: ChartSpec = {
|
|
16
|
+
...lineSpec,
|
|
17
|
+
labels: { show: true },
|
|
18
|
+
annotations: [
|
|
19
|
+
{
|
|
20
|
+
type: 'text',
|
|
21
|
+
x: '2020-01-01',
|
|
22
|
+
y: 10,
|
|
23
|
+
text: 'Peak',
|
|
24
|
+
offset: { dx: 10, dy: -20 },
|
|
25
|
+
connector: true,
|
|
26
|
+
},
|
|
27
|
+
{ type: 'range', x1: '2020-01-01', x2: '2021-01-01', label: 'Boom Period' },
|
|
28
|
+
{ type: 'refline', y: 30, label: 'Target' },
|
|
29
|
+
],
|
|
30
|
+
chrome: {
|
|
31
|
+
title: 'GDP Growth',
|
|
32
|
+
subtitle: 'US vs UK over time',
|
|
33
|
+
source: 'World Bank',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Simple text annotation spec for focused annotation tests. */
|
|
38
|
+
const textAnnotatedSpec: ChartSpec = {
|
|
39
|
+
...barSpec,
|
|
40
|
+
annotations: [
|
|
41
|
+
{ type: 'text', x: 10, y: 'A', text: 'Peak', offset: { dx: 10, dy: -20 }, connector: true },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Range-only annotation spec. */
|
|
46
|
+
const rangeAnnotatedSpec: ChartSpec = {
|
|
47
|
+
...lineSpec,
|
|
48
|
+
annotations: [
|
|
49
|
+
{
|
|
50
|
+
type: 'range',
|
|
51
|
+
x1: '2020-01-01',
|
|
52
|
+
x2: '2021-01-01',
|
|
53
|
+
label: 'Boom Period',
|
|
54
|
+
labelOffset: { dx: 5, dy: 3 },
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Refline-only annotation spec. */
|
|
60
|
+
const reflineAnnotatedSpec: ChartSpec = {
|
|
61
|
+
...lineSpec,
|
|
62
|
+
annotations: [{ type: 'refline', y: 30, label: 'Target', labelOffset: { dx: 2, dy: -4 } }],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Helpers
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Simulate a drag sequence: mousedown -> mousemove -> mouseup.
|
|
71
|
+
* Dispatches mousedown on the element, then mousemove/mouseup on document.
|
|
72
|
+
*/
|
|
73
|
+
function simulateDrag(
|
|
74
|
+
el: Element,
|
|
75
|
+
startX: number,
|
|
76
|
+
startY: number,
|
|
77
|
+
endX: number,
|
|
78
|
+
endY: number,
|
|
79
|
+
): void {
|
|
80
|
+
el.dispatchEvent(createMouseEvent('mousedown', startX, startY));
|
|
81
|
+
document.dispatchEvent(createMouseEvent('mousemove', endX, endY));
|
|
82
|
+
document.dispatchEvent(createMouseEvent('mouseup', endX, endY));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Tests
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
describe('edit events', () => {
|
|
90
|
+
let container: HTMLDivElement;
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
container = createContainer();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
document.body.innerHTML = '';
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// =========================================================================
|
|
101
|
+
// 1. createDragHandler utility (tested through annotation drag)
|
|
102
|
+
// =========================================================================
|
|
103
|
+
describe('createDragHandler utility (via annotation drag)', () => {
|
|
104
|
+
it('sets cursor:grab on the element', () => {
|
|
105
|
+
const onEdit = vi.fn();
|
|
106
|
+
const chart = createChart(container, textAnnotatedSpec, { onEdit });
|
|
107
|
+
|
|
108
|
+
const annotation = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
109
|
+
if (!annotation) {
|
|
110
|
+
chart.destroy();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
expect(annotation.style.cursor).toBe('grab');
|
|
115
|
+
chart.destroy();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('fires onEnd with correct dx/dy after drag', () => {
|
|
119
|
+
const onEdit = vi.fn();
|
|
120
|
+
const chart = createChart(container, textAnnotatedSpec, { onEdit });
|
|
121
|
+
|
|
122
|
+
const annotation = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
123
|
+
if (!annotation) {
|
|
124
|
+
chart.destroy();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
simulateDrag(annotation, 100, 100, 150, 130);
|
|
129
|
+
|
|
130
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
131
|
+
const edit: ElementEdit = onEdit.mock.calls[0][0];
|
|
132
|
+
expect(edit.type).toBe('annotation');
|
|
133
|
+
if (edit.type === 'annotation') {
|
|
134
|
+
// Original offset (10, -20) + drag delta (50, 30) = (60, 10)
|
|
135
|
+
expect(edit.offset.dx).toBe(60);
|
|
136
|
+
expect(edit.offset.dy).toBe(10);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
chart.destroy();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('does NOT fire onEnd when movement is below threshold (3px)', () => {
|
|
143
|
+
const onEdit = vi.fn();
|
|
144
|
+
const chart = createChart(container, textAnnotatedSpec, { onEdit });
|
|
145
|
+
|
|
146
|
+
const annotation = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
147
|
+
if (!annotation) {
|
|
148
|
+
chart.destroy();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Move less than 3px
|
|
153
|
+
simulateDrag(annotation, 100, 100, 101, 101);
|
|
154
|
+
|
|
155
|
+
expect(onEdit).not.toHaveBeenCalled();
|
|
156
|
+
chart.destroy();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('suppresses click event after real drag', () => {
|
|
160
|
+
const onAnnotationClick = vi.fn();
|
|
161
|
+
const onEdit = vi.fn();
|
|
162
|
+
const chart = createChart(container, textAnnotatedSpec, { onAnnotationClick, onEdit });
|
|
163
|
+
|
|
164
|
+
const annotation = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
165
|
+
if (!annotation) {
|
|
166
|
+
chart.destroy();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Drag beyond threshold
|
|
171
|
+
simulateDrag(annotation, 100, 100, 150, 130);
|
|
172
|
+
|
|
173
|
+
// Fire a click right after drag
|
|
174
|
+
annotation.dispatchEvent(createMouseEvent('click', 150, 130));
|
|
175
|
+
|
|
176
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
177
|
+
// Click should be suppressed
|
|
178
|
+
expect(onAnnotationClick).not.toHaveBeenCalled();
|
|
179
|
+
|
|
180
|
+
chart.destroy();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('sets cursor:grabbing during drag', () => {
|
|
184
|
+
const onEdit = vi.fn();
|
|
185
|
+
const chart = createChart(container, textAnnotatedSpec, { onEdit });
|
|
186
|
+
|
|
187
|
+
const annotation = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
188
|
+
if (!annotation) {
|
|
189
|
+
chart.destroy();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100));
|
|
194
|
+
// During drag (after mousedown, before mouseup) the cursor should be grabbing
|
|
195
|
+
expect(annotation.style.cursor).toBe('grabbing');
|
|
196
|
+
|
|
197
|
+
// Complete the drag to clean up
|
|
198
|
+
document.dispatchEvent(createMouseEvent('mousemove', 120, 110));
|
|
199
|
+
document.dispatchEvent(createMouseEvent('mouseup', 120, 110));
|
|
200
|
+
|
|
201
|
+
// After drag, cursor should revert to grab
|
|
202
|
+
expect(annotation.style.cursor).toBe('grab');
|
|
203
|
+
|
|
204
|
+
chart.destroy();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('cleanup function removes all listeners', () => {
|
|
208
|
+
const onEdit = vi.fn();
|
|
209
|
+
const chart = createChart(container, textAnnotatedSpec, { onEdit });
|
|
210
|
+
|
|
211
|
+
chart.destroy();
|
|
212
|
+
|
|
213
|
+
// After destroy, dispatching events should not fire the callback
|
|
214
|
+
document.dispatchEvent(createMouseEvent('mousemove', 200, 200));
|
|
215
|
+
document.dispatchEvent(createMouseEvent('mouseup', 200, 200));
|
|
216
|
+
|
|
217
|
+
expect(onEdit).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// =========================================================================
|
|
222
|
+
// 2. onEdit callback integration
|
|
223
|
+
// =========================================================================
|
|
224
|
+
describe('onEdit callback integration', () => {
|
|
225
|
+
it('when onEdit is NOT provided, no grab cursors appear on chrome/legend/labels', () => {
|
|
226
|
+
// Mount without onEdit
|
|
227
|
+
const chart = createChart(container, fullEditSpec, {});
|
|
228
|
+
|
|
229
|
+
// Chrome elements should not have grab cursor
|
|
230
|
+
const chromeTexts = container.querySelectorAll('.viz-chrome text[data-chrome-key]');
|
|
231
|
+
for (const el of chromeTexts) {
|
|
232
|
+
expect((el as HTMLElement).style.cursor).not.toBe('grab');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Legend should not have grab cursor
|
|
236
|
+
const legendG = container.querySelector('.viz-legend') as SVGGElement | null;
|
|
237
|
+
if (legendG) {
|
|
238
|
+
expect(legendG.style.cursor).not.toBe('grab');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Series labels should not have grab cursor
|
|
242
|
+
const seriesLabels = container.querySelectorAll('.viz-mark-label');
|
|
243
|
+
for (const el of seriesLabels) {
|
|
244
|
+
expect((el as HTMLElement).style.cursor).not.toBe('grab');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
chart.destroy();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('when onEdit IS provided, grab cursors appear on editable elements', () => {
|
|
251
|
+
const onEdit = vi.fn();
|
|
252
|
+
const chart = createChart(container, fullEditSpec, { onEdit });
|
|
253
|
+
|
|
254
|
+
// Chrome elements should have grab cursor
|
|
255
|
+
const chromeTexts = container.querySelectorAll('.viz-chrome text[data-chrome-key]');
|
|
256
|
+
for (const el of chromeTexts) {
|
|
257
|
+
expect((el as HTMLElement).style.cursor).toBe('grab');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Legend should have grab cursor (if present)
|
|
261
|
+
const legendG = container.querySelector('.viz-legend') as SVGGElement | null;
|
|
262
|
+
if (legendG) {
|
|
263
|
+
expect(legendG.style.cursor).toBe('grab');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Series labels should have grab cursor (if any rendered with data-series)
|
|
267
|
+
const seriesLabels = container.querySelectorAll('.viz-mark-label[data-series]');
|
|
268
|
+
for (const el of seriesLabels) {
|
|
269
|
+
expect((el as HTMLElement).style.cursor).toBe('grab');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
chart.destroy();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('dragging a text annotation fires BOTH onAnnotationEdit and onEdit when both are provided', () => {
|
|
276
|
+
const onAnnotationEdit = vi.fn();
|
|
277
|
+
const onEdit = vi.fn();
|
|
278
|
+
const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit, onEdit });
|
|
279
|
+
|
|
280
|
+
const annotation = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
281
|
+
if (!annotation) {
|
|
282
|
+
chart.destroy();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
simulateDrag(annotation, 100, 100, 150, 130);
|
|
287
|
+
|
|
288
|
+
expect(onAnnotationEdit).toHaveBeenCalledTimes(1);
|
|
289
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
290
|
+
|
|
291
|
+
// Verify onAnnotationEdit received the text annotation and offset
|
|
292
|
+
const [annotationArg, offsetArg] = onAnnotationEdit.mock.calls[0];
|
|
293
|
+
expect(annotationArg.type).toBe('text');
|
|
294
|
+
expect(annotationArg.text).toBe('Peak');
|
|
295
|
+
expect(offsetArg.dx).toBe(60);
|
|
296
|
+
expect(offsetArg.dy).toBe(10);
|
|
297
|
+
|
|
298
|
+
// Verify onEdit received an annotation edit
|
|
299
|
+
const edit: ElementEdit = onEdit.mock.calls[0][0];
|
|
300
|
+
expect(edit.type).toBe('annotation');
|
|
301
|
+
if (edit.type === 'annotation') {
|
|
302
|
+
expect(edit.annotation.text).toBe('Peak');
|
|
303
|
+
expect(edit.offset.dx).toBe(60);
|
|
304
|
+
expect(edit.offset.dy).toBe(10);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
chart.destroy();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('dragging a text annotation fires only onEdit when only onEdit is provided', () => {
|
|
311
|
+
const onEdit = vi.fn();
|
|
312
|
+
const chart = createChart(container, textAnnotatedSpec, { onEdit });
|
|
313
|
+
|
|
314
|
+
const annotation = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
315
|
+
if (!annotation) {
|
|
316
|
+
chart.destroy();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
simulateDrag(annotation, 100, 100, 150, 130);
|
|
321
|
+
|
|
322
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
323
|
+
const edit: ElementEdit = onEdit.mock.calls[0][0];
|
|
324
|
+
expect(edit.type).toBe('annotation');
|
|
325
|
+
|
|
326
|
+
chart.destroy();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// =========================================================================
|
|
331
|
+
// 3. wireAnnotationLabelDrag (range/refline)
|
|
332
|
+
// =========================================================================
|
|
333
|
+
describe('wireAnnotationLabelDrag', () => {
|
|
334
|
+
it('range annotation labels get cursor:grab when onEdit is provided', () => {
|
|
335
|
+
const onEdit = vi.fn();
|
|
336
|
+
const chart = createChart(container, rangeAnnotatedSpec, { onEdit });
|
|
337
|
+
|
|
338
|
+
const rangeLabel = container.querySelector(
|
|
339
|
+
'.viz-annotation-range .viz-annotation-label',
|
|
340
|
+
) as SVGTextElement | null;
|
|
341
|
+
if (!rangeLabel) {
|
|
342
|
+
// Range may not render a visible label in test env
|
|
343
|
+
chart.destroy();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
expect(rangeLabel.style.cursor).toBe('grab');
|
|
348
|
+
chart.destroy();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('dragging a range label fires onEdit with type range-label and accumulated labelOffset', () => {
|
|
352
|
+
const onEdit = vi.fn();
|
|
353
|
+
const chart = createChart(container, rangeAnnotatedSpec, { onEdit });
|
|
354
|
+
|
|
355
|
+
const rangeLabel = container.querySelector(
|
|
356
|
+
'.viz-annotation-range .viz-annotation-label',
|
|
357
|
+
) as SVGTextElement | null;
|
|
358
|
+
if (!rangeLabel) {
|
|
359
|
+
chart.destroy();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
simulateDrag(rangeLabel, 100, 100, 140, 120);
|
|
364
|
+
|
|
365
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
366
|
+
const edit: ElementEdit = onEdit.mock.calls[0][0];
|
|
367
|
+
expect(edit.type).toBe('range-label');
|
|
368
|
+
if (edit.type === 'range-label') {
|
|
369
|
+
expect(edit.annotation.type).toBe('range');
|
|
370
|
+
// Original labelOffset (5, 3) + drag delta (40, 20) = (45, 23)
|
|
371
|
+
expect(edit.labelOffset.dx).toBe(45);
|
|
372
|
+
expect(edit.labelOffset.dy).toBe(23);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
chart.destroy();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('refline annotation labels get cursor:grab when onEdit is provided', () => {
|
|
379
|
+
const onEdit = vi.fn();
|
|
380
|
+
const chart = createChart(container, reflineAnnotatedSpec, { onEdit });
|
|
381
|
+
|
|
382
|
+
const reflineLabel = container.querySelector(
|
|
383
|
+
'.viz-annotation-refline .viz-annotation-label',
|
|
384
|
+
) as SVGTextElement | null;
|
|
385
|
+
if (!reflineLabel) {
|
|
386
|
+
chart.destroy();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
expect(reflineLabel.style.cursor).toBe('grab');
|
|
391
|
+
chart.destroy();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('dragging a refline label fires onEdit with type refline-label and accumulated labelOffset', () => {
|
|
395
|
+
const onEdit = vi.fn();
|
|
396
|
+
const chart = createChart(container, reflineAnnotatedSpec, { onEdit });
|
|
397
|
+
|
|
398
|
+
const reflineLabel = container.querySelector(
|
|
399
|
+
'.viz-annotation-refline .viz-annotation-label',
|
|
400
|
+
) as SVGTextElement | null;
|
|
401
|
+
if (!reflineLabel) {
|
|
402
|
+
chart.destroy();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
simulateDrag(reflineLabel, 100, 100, 130, 115);
|
|
407
|
+
|
|
408
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
409
|
+
const edit: ElementEdit = onEdit.mock.calls[0][0];
|
|
410
|
+
expect(edit.type).toBe('refline-label');
|
|
411
|
+
if (edit.type === 'refline-label') {
|
|
412
|
+
expect(edit.annotation.type).toBe('refline');
|
|
413
|
+
// Original labelOffset (2, -4) + drag delta (30, 15) = (32, 11)
|
|
414
|
+
expect(edit.labelOffset.dx).toBe(32);
|
|
415
|
+
expect(edit.labelOffset.dy).toBe(11);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
chart.destroy();
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// =========================================================================
|
|
423
|
+
// 4. wireChromeDrag
|
|
424
|
+
// =========================================================================
|
|
425
|
+
describe('wireChromeDrag', () => {
|
|
426
|
+
it('chrome text elements get cursor:grab when onEdit is provided', () => {
|
|
427
|
+
const onEdit = vi.fn();
|
|
428
|
+
const chart = createChart(container, fullEditSpec, { onEdit });
|
|
429
|
+
|
|
430
|
+
const chromeTexts = container.querySelectorAll('.viz-chrome text[data-chrome-key]');
|
|
431
|
+
expect(chromeTexts.length).toBeGreaterThan(0);
|
|
432
|
+
|
|
433
|
+
for (const el of chromeTexts) {
|
|
434
|
+
expect((el as HTMLElement).style.cursor).toBe('grab');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
chart.destroy();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('dragging a chrome element fires onEdit with type chrome, correct key, text, and accumulated offset', () => {
|
|
441
|
+
const onEdit = vi.fn();
|
|
442
|
+
const chart = createChart(container, fullEditSpec, { onEdit });
|
|
443
|
+
|
|
444
|
+
const titleEl = container.querySelector(
|
|
445
|
+
'.viz-chrome text[data-chrome-key="title"]',
|
|
446
|
+
) as SVGTextElement | null;
|
|
447
|
+
if (!titleEl) {
|
|
448
|
+
chart.destroy();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
simulateDrag(titleEl, 100, 100, 130, 115);
|
|
453
|
+
|
|
454
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
455
|
+
const edit: ElementEdit = onEdit.mock.calls[0][0];
|
|
456
|
+
expect(edit.type).toBe('chrome');
|
|
457
|
+
if (edit.type === 'chrome') {
|
|
458
|
+
expect(edit.key).toBe('title');
|
|
459
|
+
expect(edit.text).toBeTruthy();
|
|
460
|
+
// No existing offset, so offset = delta (30, 15)
|
|
461
|
+
expect(edit.offset.dx).toBe(30);
|
|
462
|
+
expect(edit.offset.dy).toBe(15);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
chart.destroy();
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// =========================================================================
|
|
470
|
+
// 5. wireLegendDrag
|
|
471
|
+
// =========================================================================
|
|
472
|
+
describe('wireLegendDrag', () => {
|
|
473
|
+
it('legend group gets cursor:grab when onEdit is provided', () => {
|
|
474
|
+
const onEdit = vi.fn();
|
|
475
|
+
const chart = createChart(container, fullEditSpec, { onEdit });
|
|
476
|
+
|
|
477
|
+
const legendG = container.querySelector('.viz-legend') as SVGGElement | null;
|
|
478
|
+
if (!legendG) {
|
|
479
|
+
chart.destroy();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
expect(legendG.style.cursor).toBe('grab');
|
|
484
|
+
chart.destroy();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('dragging legend fires onEdit with type legend and accumulated offset', () => {
|
|
488
|
+
const onEdit = vi.fn();
|
|
489
|
+
const chart = createChart(container, fullEditSpec, { onEdit });
|
|
490
|
+
|
|
491
|
+
const legendG = container.querySelector('.viz-legend') as SVGGElement | null;
|
|
492
|
+
if (!legendG) {
|
|
493
|
+
chart.destroy();
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
simulateDrag(legendG, 200, 50, 250, 70);
|
|
498
|
+
|
|
499
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
500
|
+
const edit: ElementEdit = onEdit.mock.calls[0][0];
|
|
501
|
+
expect(edit.type).toBe('legend');
|
|
502
|
+
if (edit.type === 'legend') {
|
|
503
|
+
expect(edit.offset.dx).toBe(50);
|
|
504
|
+
expect(edit.offset.dy).toBe(20);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
chart.destroy();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('click event after drag is suppressed (does not fire onLegendToggle)', () => {
|
|
511
|
+
const onLegendToggle = vi.fn();
|
|
512
|
+
const onEdit = vi.fn();
|
|
513
|
+
const chart = createChart(container, fullEditSpec, { onLegendToggle, onEdit });
|
|
514
|
+
|
|
515
|
+
const legendG = container.querySelector('.viz-legend') as SVGGElement | null;
|
|
516
|
+
if (!legendG) {
|
|
517
|
+
chart.destroy();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Drag the legend
|
|
522
|
+
simulateDrag(legendG, 200, 50, 250, 70);
|
|
523
|
+
|
|
524
|
+
// Fire a click after drag
|
|
525
|
+
legendG.dispatchEvent(createMouseEvent('click', 250, 70));
|
|
526
|
+
|
|
527
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
528
|
+
// Click should have been suppressed by the drag handler
|
|
529
|
+
expect(onLegendToggle).not.toHaveBeenCalled();
|
|
530
|
+
|
|
531
|
+
chart.destroy();
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// =========================================================================
|
|
536
|
+
// 6. wireConnectorEndpointDrag
|
|
537
|
+
// =========================================================================
|
|
538
|
+
describe('wireConnectorEndpointDrag', () => {
|
|
539
|
+
it('connector handles are created dynamically when onEdit is provided', () => {
|
|
540
|
+
const onEdit = vi.fn();
|
|
541
|
+
const chart = createChart(container, textAnnotatedSpec, { onEdit });
|
|
542
|
+
|
|
543
|
+
const annotationG = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
544
|
+
if (!annotationG) {
|
|
545
|
+
chart.destroy();
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Check that connector handles exist within the annotation group
|
|
550
|
+
const handles = annotationG.querySelectorAll('.viz-connector-handle');
|
|
551
|
+
// There should be 2 handles (from and to) if a connector is present
|
|
552
|
+
const connector = annotationG.querySelector('.viz-annotation-connector');
|
|
553
|
+
if (connector) {
|
|
554
|
+
expect(handles.length).toBe(2);
|
|
555
|
+
// Verify they have the correct data-endpoint attributes
|
|
556
|
+
const endpoints = Array.from(handles).map((h) => h.getAttribute('data-endpoint'));
|
|
557
|
+
expect(endpoints).toContain('from');
|
|
558
|
+
expect(endpoints).toContain('to');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
chart.destroy();
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('handles appear on mouseenter of annotation group', () => {
|
|
565
|
+
const onEdit = vi.fn();
|
|
566
|
+
const chart = createChart(container, textAnnotatedSpec, { onEdit });
|
|
567
|
+
|
|
568
|
+
const annotationG = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
569
|
+
if (!annotationG) {
|
|
570
|
+
chart.destroy();
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const handles = annotationG.querySelectorAll('.viz-connector-handle');
|
|
575
|
+
if (handles.length === 0) {
|
|
576
|
+
chart.destroy();
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Initially handles should be invisible (opacity: 0)
|
|
581
|
+
for (const h of handles) {
|
|
582
|
+
expect(h.getAttribute('opacity')).toBe('0');
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Trigger mouseenter on the annotation group
|
|
586
|
+
annotationG.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
|
587
|
+
|
|
588
|
+
// Now handles should be visible
|
|
589
|
+
for (const h of handles) {
|
|
590
|
+
expect(h.getAttribute('opacity')).toBe('0.6');
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
chart.destroy();
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('handles disappear on mouseleave', () => {
|
|
597
|
+
const onEdit = vi.fn();
|
|
598
|
+
const chart = createChart(container, textAnnotatedSpec, { onEdit });
|
|
599
|
+
|
|
600
|
+
const annotationG = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
601
|
+
if (!annotationG) {
|
|
602
|
+
chart.destroy();
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const handles = annotationG.querySelectorAll('.viz-connector-handle');
|
|
607
|
+
if (handles.length === 0) {
|
|
608
|
+
chart.destroy();
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Show handles first
|
|
613
|
+
annotationG.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
|
614
|
+
// Then hide them
|
|
615
|
+
annotationG.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
|
616
|
+
|
|
617
|
+
for (const h of handles) {
|
|
618
|
+
expect(h.getAttribute('opacity')).toBe('0');
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
chart.destroy();
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('dragging a handle fires onEdit with type annotation-connector and correct endpoint/offset', () => {
|
|
625
|
+
const onEdit = vi.fn();
|
|
626
|
+
const chart = createChart(container, textAnnotatedSpec, { onEdit });
|
|
627
|
+
|
|
628
|
+
const annotationG = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
629
|
+
if (!annotationG) {
|
|
630
|
+
chart.destroy();
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Find the "from" handle
|
|
635
|
+
const fromHandle = annotationG.querySelector(
|
|
636
|
+
'.viz-connector-handle[data-endpoint="from"]',
|
|
637
|
+
) as SVGCircleElement | null;
|
|
638
|
+
|
|
639
|
+
if (!fromHandle) {
|
|
640
|
+
chart.destroy();
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
simulateDrag(fromHandle, 100, 100, 130, 115);
|
|
645
|
+
|
|
646
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
647
|
+
const edit: ElementEdit = onEdit.mock.calls[0][0];
|
|
648
|
+
expect(edit.type).toBe('annotation-connector');
|
|
649
|
+
if (edit.type === 'annotation-connector') {
|
|
650
|
+
expect(edit.endpoint).toBe('from');
|
|
651
|
+
expect(edit.annotation.type).toBe('text');
|
|
652
|
+
// No existing connectorOffset, so offset = delta (30, 15)
|
|
653
|
+
expect(edit.offset.dx).toBe(30);
|
|
654
|
+
expect(edit.offset.dy).toBe(15);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
chart.destroy();
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('dragging the "to" handle fires onEdit with endpoint "to"', () => {
|
|
661
|
+
const onEdit = vi.fn();
|
|
662
|
+
const chart = createChart(container, textAnnotatedSpec, { onEdit });
|
|
663
|
+
|
|
664
|
+
const annotationG = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
665
|
+
if (!annotationG) {
|
|
666
|
+
chart.destroy();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const toHandle = annotationG.querySelector(
|
|
671
|
+
'.viz-connector-handle[data-endpoint="to"]',
|
|
672
|
+
) as SVGCircleElement | null;
|
|
673
|
+
|
|
674
|
+
if (!toHandle) {
|
|
675
|
+
chart.destroy();
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
simulateDrag(toHandle, 100, 100, 140, 125);
|
|
680
|
+
|
|
681
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
682
|
+
const edit: ElementEdit = onEdit.mock.calls[0][0];
|
|
683
|
+
expect(edit.type).toBe('annotation-connector');
|
|
684
|
+
if (edit.type === 'annotation-connector') {
|
|
685
|
+
expect(edit.endpoint).toBe('to');
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
chart.destroy();
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// =========================================================================
|
|
693
|
+
// 7. wireSeriesLabelDrag
|
|
694
|
+
// =========================================================================
|
|
695
|
+
describe('wireSeriesLabelDrag', () => {
|
|
696
|
+
it('series labels get cursor:grab when onEdit is provided', () => {
|
|
697
|
+
const onEdit = vi.fn();
|
|
698
|
+
const chart = createChart(container, fullEditSpec, { onEdit });
|
|
699
|
+
|
|
700
|
+
const seriesLabels = container.querySelectorAll('.viz-mark-label[data-series]');
|
|
701
|
+
if (seriesLabels.length === 0) {
|
|
702
|
+
// Labels may not render in the test env depending on layout
|
|
703
|
+
chart.destroy();
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
for (const el of seriesLabels) {
|
|
708
|
+
expect((el as HTMLElement).style.cursor).toBe('grab');
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
chart.destroy();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('dragging fires onEdit with type series-label, correct series name, and accumulated offset', () => {
|
|
715
|
+
const onEdit = vi.fn();
|
|
716
|
+
const chart = createChart(container, fullEditSpec, { onEdit });
|
|
717
|
+
|
|
718
|
+
const seriesLabel = container.querySelector(
|
|
719
|
+
'.viz-mark-label[data-series]',
|
|
720
|
+
) as SVGTextElement | null;
|
|
721
|
+
if (!seriesLabel) {
|
|
722
|
+
chart.destroy();
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const seriesName = seriesLabel.getAttribute('data-series')!;
|
|
727
|
+
simulateDrag(seriesLabel, 100, 100, 135, 118);
|
|
728
|
+
|
|
729
|
+
// Find the onEdit call for a series-label (could have other edits from other elements)
|
|
730
|
+
const seriesLabelCall = onEdit.mock.calls.find(
|
|
731
|
+
(call: [ElementEdit]) => call[0].type === 'series-label',
|
|
732
|
+
);
|
|
733
|
+
expect(seriesLabelCall).toBeDefined();
|
|
734
|
+
|
|
735
|
+
const edit: ElementEdit = seriesLabelCall![0];
|
|
736
|
+
expect(edit.type).toBe('series-label');
|
|
737
|
+
if (edit.type === 'series-label') {
|
|
738
|
+
expect(edit.series).toBe(seriesName);
|
|
739
|
+
// No existing offset, so offset = delta (35, 18)
|
|
740
|
+
expect(edit.offset.dx).toBe(35);
|
|
741
|
+
expect(edit.offset.dy).toBe(18);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
chart.destroy();
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
});
|