@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.
Files changed (44) hide show
  1. package/dist/index.d.ts +327 -0
  2. package/dist/index.js +4745 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/simulation-worker.js +1196 -0
  5. package/package.json +58 -0
  6. package/src/__test-fixtures__/dom.ts +42 -0
  7. package/src/__test-fixtures__/specs.ts +187 -0
  8. package/src/__tests__/edit-events.test.ts +747 -0
  9. package/src/__tests__/events.test.ts +336 -0
  10. package/src/__tests__/export.test.ts +150 -0
  11. package/src/__tests__/mount.test.ts +219 -0
  12. package/src/__tests__/svg-renderer.test.ts +609 -0
  13. package/src/__tests__/table-mount.test.ts +484 -0
  14. package/src/__tests__/tooltip.test.ts +201 -0
  15. package/src/export.ts +105 -0
  16. package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
  17. package/src/graph/__tests__/graph-mount.test.ts +213 -0
  18. package/src/graph/__tests__/interaction.test.ts +205 -0
  19. package/src/graph/__tests__/keyboard.test.ts +653 -0
  20. package/src/graph/__tests__/search.test.ts +88 -0
  21. package/src/graph/__tests__/simulation.test.ts +233 -0
  22. package/src/graph/__tests__/spatial-index.test.ts +142 -0
  23. package/src/graph/__tests__/zoom.test.ts +195 -0
  24. package/src/graph/canvas-renderer.ts +660 -0
  25. package/src/graph/interaction.ts +359 -0
  26. package/src/graph/keyboard.ts +208 -0
  27. package/src/graph/search.ts +50 -0
  28. package/src/graph/simulation-worker-url.ts +30 -0
  29. package/src/graph/simulation-worker.ts +265 -0
  30. package/src/graph/simulation.ts +350 -0
  31. package/src/graph/spatial-index.ts +121 -0
  32. package/src/graph/types.ts +44 -0
  33. package/src/graph/worker-protocol.ts +67 -0
  34. package/src/graph/zoom.ts +104 -0
  35. package/src/graph-mount.ts +675 -0
  36. package/src/index.ts +56 -0
  37. package/src/mount.ts +1639 -0
  38. package/src/renderers/table-cells.ts +444 -0
  39. package/src/resize-observer.ts +46 -0
  40. package/src/svg-renderer.ts +914 -0
  41. package/src/table-keyboard.ts +266 -0
  42. package/src/table-mount.ts +532 -0
  43. package/src/table-renderer.ts +350 -0
  44. 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
+ });