@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.
Files changed (145) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/chunk-DCODCLWI.js +234 -0
  3. package/dist/chunk-DCODCLWI.js.map +1 -0
  4. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  5. package/dist/chunk-EKAUQAAX.js.map +1 -0
  6. package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
  7. package/dist/chunk-KNT5AZ2O.js.map +1 -0
  8. package/dist/chunk-RM5XS6NR.js +113 -0
  9. package/dist/chunk-RM5XS6NR.js.map +1 -0
  10. package/dist/chunk-T32UHMZA.js +282 -0
  11. package/dist/chunk-T32UHMZA.js.map +1 -0
  12. package/dist/export-gif.d.ts +113 -0
  13. package/dist/export-gif.js +21 -0
  14. package/dist/export-gif.js.map +1 -0
  15. package/dist/index.d.ts +283 -24
  16. package/dist/index.js +2012 -763
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.js +7 -69
  21. package/dist/static.js.map +1 -1
  22. package/dist/story/index.d.ts +3 -2
  23. package/dist/story/index.js +5 -3
  24. package/dist/story/index.js.map +1 -1
  25. package/dist/styles.css +1 -1
  26. package/dist/styles.css.map +1 -1
  27. package/package.json +14 -6
  28. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  29. package/dist/chunk-ENXSUXC5.js.map +0 -1
  30. package/src/__test-fixtures__/dom.ts +0 -70
  31. package/src/__test-fixtures__/specs.ts +0 -214
  32. package/src/__tests__/animation.test.ts +0 -358
  33. package/src/__tests__/annotations-render.test.ts +0 -201
  34. package/src/__tests__/compound-labels.test.ts +0 -122
  35. package/src/__tests__/continuous-legend.test.ts +0 -91
  36. package/src/__tests__/crosshair.test.ts +0 -130
  37. package/src/__tests__/edit-events.test.ts +0 -902
  38. package/src/__tests__/events.test.ts +0 -387
  39. package/src/__tests__/export.test.ts +0 -241
  40. package/src/__tests__/font-race.test.ts +0 -199
  41. package/src/__tests__/gradient-ids.test.ts +0 -159
  42. package/src/__tests__/gradient-utils.test.ts +0 -125
  43. package/src/__tests__/known-bugs-render.test.ts +0 -183
  44. package/src/__tests__/map.test.ts +0 -293
  45. package/src/__tests__/mount.test.ts +0 -357
  46. package/src/__tests__/pattern-fills.test.ts +0 -105
  47. package/src/__tests__/resize-timing.test.ts +0 -218
  48. package/src/__tests__/sankey.test.ts +0 -133
  49. package/src/__tests__/selection-events.test.ts +0 -869
  50. package/src/__tests__/series-search.test.ts +0 -311
  51. package/src/__tests__/static.test.ts +0 -141
  52. package/src/__tests__/svg-renderer.test.ts +0 -895
  53. package/src/__tests__/table-keyboard.test.ts +0 -361
  54. package/src/__tests__/table-mount.test.ts +0 -639
  55. package/src/__tests__/tilemap.test.ts +0 -158
  56. package/src/__tests__/tooltip.test.ts +0 -337
  57. package/src/__tests__/transition.test.ts +0 -1777
  58. package/src/__tests__/you-draw-it.test.ts +0 -273
  59. package/src/animation-vars.ts +0 -42
  60. package/src/animation.ts +0 -76
  61. package/src/barlist-mount.ts +0 -383
  62. package/src/barlist-renderer.ts +0 -267
  63. package/src/dom-helpers.ts +0 -17
  64. package/src/export.ts +0 -482
  65. package/src/gradient-utils.ts +0 -138
  66. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  67. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  68. package/src/graph/__tests__/interaction.test.ts +0 -205
  69. package/src/graph/__tests__/keyboard.test.ts +0 -653
  70. package/src/graph/__tests__/search.test.ts +0 -88
  71. package/src/graph/__tests__/simulation.test.ts +0 -233
  72. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  73. package/src/graph/__tests__/zoom.test.ts +0 -203
  74. package/src/graph/canvas-renderer.ts +0 -747
  75. package/src/graph/interaction.ts +0 -367
  76. package/src/graph/keyboard.ts +0 -208
  77. package/src/graph/search.ts +0 -50
  78. package/src/graph/simulation-worker-url.ts +0 -12
  79. package/src/graph/simulation-worker.ts +0 -278
  80. package/src/graph/simulation.ts +0 -422
  81. package/src/graph/spatial-index.ts +0 -121
  82. package/src/graph/types.ts +0 -47
  83. package/src/graph/worker-protocol.ts +0 -70
  84. package/src/graph/zoom.ts +0 -134
  85. package/src/graph-mount.ts +0 -888
  86. package/src/index.ts +0 -76
  87. package/src/interactions/chart-events.ts +0 -139
  88. package/src/interactions/crosshair.ts +0 -233
  89. package/src/interactions/drag-handler.ts +0 -175
  90. package/src/interactions/editing-drags.ts +0 -515
  91. package/src/interactions/index.ts +0 -25
  92. package/src/interactions/keyboard-nav.ts +0 -111
  93. package/src/interactions/legend-interaction.ts +0 -46
  94. package/src/interactions/selection.ts +0 -280
  95. package/src/interactions/tooltip-events.ts +0 -72
  96. package/src/map-camera.ts +0 -88
  97. package/src/map-mount.ts +0 -701
  98. package/src/map-renderer.ts +0 -338
  99. package/src/map-transition.ts +0 -83
  100. package/src/measure-text.ts +0 -134
  101. package/src/mount.ts +0 -1382
  102. package/src/pattern-utils.ts +0 -110
  103. package/src/renderers/annotations.ts +0 -388
  104. package/src/renderers/axes.ts +0 -351
  105. package/src/renderers/brand.ts +0 -87
  106. package/src/renderers/chrome.ts +0 -196
  107. package/src/renderers/endpoint-labels.ts +0 -150
  108. package/src/renderers/legend.ts +0 -311
  109. package/src/renderers/marks.ts +0 -561
  110. package/src/renderers/metrics.ts +0 -63
  111. package/src/renderers/svg-dom.ts +0 -40
  112. package/src/renderers/table-cells.ts +0 -446
  113. package/src/resize-observer.ts +0 -46
  114. package/src/sankey-mount.ts +0 -622
  115. package/src/sankey-renderer.ts +0 -614
  116. package/src/series-search.ts +0 -392
  117. package/src/static.ts +0 -251
  118. package/src/story/__tests__/camera-math.test.ts +0 -145
  119. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  120. package/src/story/__tests__/progress-math.test.ts +0 -95
  121. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  122. package/src/story/__tests__/tween.test.ts +0 -159
  123. package/src/story/camera-math.ts +0 -125
  124. package/src/story/create-chart-story.ts +0 -353
  125. package/src/story/crossfade.ts +0 -70
  126. package/src/story/index.ts +0 -39
  127. package/src/story/progress-math.ts +0 -85
  128. package/src/story/resolve-camera-target.ts +0 -119
  129. package/src/story/scroll-driver.ts +0 -206
  130. package/src/story/story-camera.ts +0 -49
  131. package/src/story/tween.ts +0 -112
  132. package/src/story/types.ts +0 -76
  133. package/src/svg-ids.ts +0 -23
  134. package/src/svg-renderer.ts +0 -383
  135. package/src/table-keyboard.ts +0 -266
  136. package/src/table-mount.ts +0 -567
  137. package/src/table-renderer.ts +0 -414
  138. package/src/text-edit-overlay.ts +0 -255
  139. package/src/theme-tokens.ts +0 -51
  140. package/src/tilemap-mount.ts +0 -511
  141. package/src/tilemap-renderer.ts +0 -396
  142. package/src/tooltip.ts +0 -177
  143. package/src/transition.ts +0 -2816
  144. package/src/you-draw-it.ts +0 -480
  145. /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
@@ -1,902 +0,0 @@
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
- /**
46
- * Two annotations whose `zIndex` REVERSES their spec order.
47
- *
48
- * The engine sorts `layout.annotations` by zIndex before handing them to the
49
- * renderer, so render position 0 here is spec annotation 1. Every other spec in
50
- * this file has exactly one annotation, which makes `index === specIndex`
51
- * trivially true and hides the difference entirely.
52
- */
53
- const reorderedAnnotatedSpec: ChartSpec = {
54
- ...barSpec,
55
- annotations: [
56
- { type: 'text', x: 10, y: 'A', text: 'First in the spec', id: 'first', zIndex: 5 },
57
- { type: 'text', x: 30, y: 'C', text: 'Second in the spec', id: 'second', zIndex: 1 },
58
- ],
59
- };
60
-
61
- /**
62
- * Curved connector, which is the ONLY path that draws an arrowhead — and the only
63
- * one where edit mode has to find the connector's tip by parsing the arrowhead
64
- * polyline (`wireConnectorEndpointDrag`'s curved branch). Every other spec in this
65
- * file uses `connector: true` (straight, no arrow), so without this the arrowhead
66
- * selector and the tip-parsing code never execute in any test.
67
- */
68
- const curvedAnnotatedSpec: ChartSpec = {
69
- ...barSpec,
70
- annotations: [
71
- {
72
- type: 'text',
73
- x: 10,
74
- y: 'A',
75
- text: 'Peak',
76
- offset: { dx: 40, dy: -50 },
77
- connector: 'curve',
78
- },
79
- ],
80
- };
81
-
82
- /** Range-only annotation spec. */
83
- const rangeAnnotatedSpec: ChartSpec = {
84
- ...lineSpec,
85
- annotations: [
86
- {
87
- type: 'range',
88
- x1: '2020-01-01',
89
- x2: '2021-01-01',
90
- label: 'Boom Period',
91
- labelOffset: { dx: 5, dy: 3 },
92
- },
93
- ],
94
- };
95
-
96
- /** Refline-only annotation spec. */
97
- const reflineAnnotatedSpec: ChartSpec = {
98
- ...lineSpec,
99
- annotations: [{ type: 'refline', y: 30, label: 'Target', labelOffset: { dx: 2, dy: -4 } }],
100
- };
101
-
102
- // ---------------------------------------------------------------------------
103
- // Helpers
104
- // ---------------------------------------------------------------------------
105
-
106
- /**
107
- * Simulate a drag sequence: mousedown -> mousemove -> mouseup.
108
- * Dispatches mousedown on the element, then mousemove/mouseup on document.
109
- */
110
- function simulateDrag(
111
- el: Element,
112
- startX: number,
113
- startY: number,
114
- endX: number,
115
- endY: number,
116
- ): void {
117
- el.dispatchEvent(createMouseEvent('mousedown', startX, startY));
118
- document.dispatchEvent(createMouseEvent('mousemove', endX, endY));
119
- document.dispatchEvent(createMouseEvent('mouseup', endX, endY));
120
- }
121
-
122
- // ---------------------------------------------------------------------------
123
- // Tests
124
- // ---------------------------------------------------------------------------
125
-
126
- describe('edit events', () => {
127
- let container: HTMLDivElement;
128
-
129
- beforeEach(() => {
130
- container = createContainer();
131
- });
132
-
133
- afterEach(() => {
134
- document.body.innerHTML = '';
135
- });
136
-
137
- // =========================================================================
138
- // 1. createDragHandler utility (tested through annotation drag)
139
- // =========================================================================
140
- describe('createDragHandler utility (via annotation drag)', () => {
141
- it('sets cursor:grab on the element', () => {
142
- const onEdit = vi.fn();
143
- const chart = createChart(container, textAnnotatedSpec, { onEdit });
144
-
145
- const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null;
146
- if (!annotation) {
147
- chart.destroy();
148
- return;
149
- }
150
-
151
- expect(annotation.style.cursor).toBe('grab');
152
- chart.destroy();
153
- });
154
-
155
- it('fires onEnd with correct dx/dy after drag', () => {
156
- const onEdit = vi.fn();
157
- const chart = createChart(container, textAnnotatedSpec, { onEdit });
158
-
159
- const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null;
160
- if (!annotation) {
161
- chart.destroy();
162
- return;
163
- }
164
-
165
- simulateDrag(annotation, 100, 100, 150, 130);
166
-
167
- expect(onEdit).toHaveBeenCalledTimes(1);
168
- const edit: ElementEdit = onEdit.mock.calls[0][0];
169
- expect(edit.type).toBe('annotation');
170
- if (edit.type === 'annotation') {
171
- // Original offset (10, -20) + drag delta (50, 30) = (60, 10)
172
- expect(edit.offset.dx).toBe(60);
173
- expect(edit.offset.dy).toBe(10);
174
- }
175
-
176
- chart.destroy();
177
- });
178
-
179
- it('does NOT fire onEnd when movement is below threshold (3px)', () => {
180
- const onEdit = vi.fn();
181
- const chart = createChart(container, textAnnotatedSpec, { onEdit });
182
-
183
- const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null;
184
- if (!annotation) {
185
- chart.destroy();
186
- return;
187
- }
188
-
189
- // Move less than 3px
190
- simulateDrag(annotation, 100, 100, 101, 101);
191
-
192
- expect(onEdit).not.toHaveBeenCalled();
193
- chart.destroy();
194
- });
195
-
196
- it('suppresses click event after real drag', () => {
197
- const onAnnotationClick = vi.fn();
198
- const onEdit = vi.fn();
199
- const chart = createChart(container, textAnnotatedSpec, { onAnnotationClick, onEdit });
200
-
201
- const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null;
202
- if (!annotation) {
203
- chart.destroy();
204
- return;
205
- }
206
-
207
- // Drag beyond threshold
208
- simulateDrag(annotation, 100, 100, 150, 130);
209
-
210
- // Fire a click right after drag
211
- annotation.dispatchEvent(createMouseEvent('click', 150, 130));
212
-
213
- expect(onEdit).toHaveBeenCalledTimes(1);
214
- // Click should be suppressed
215
- expect(onAnnotationClick).not.toHaveBeenCalled();
216
-
217
- chart.destroy();
218
- });
219
-
220
- it('sets cursor:grabbing during drag', () => {
221
- const onEdit = vi.fn();
222
- const chart = createChart(container, textAnnotatedSpec, { onEdit });
223
-
224
- const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null;
225
- if (!annotation) {
226
- chart.destroy();
227
- return;
228
- }
229
-
230
- annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100));
231
- // During drag (after mousedown, before mouseup) the cursor should be grabbing
232
- expect(annotation.style.cursor).toBe('grabbing');
233
-
234
- // Complete the drag to clean up
235
- document.dispatchEvent(createMouseEvent('mousemove', 120, 110));
236
- document.dispatchEvent(createMouseEvent('mouseup', 120, 110));
237
-
238
- // After drag, cursor should revert to grab
239
- expect(annotation.style.cursor).toBe('grab');
240
-
241
- chart.destroy();
242
- });
243
-
244
- it('cleanup function removes all listeners', () => {
245
- const onEdit = vi.fn();
246
- const chart = createChart(container, textAnnotatedSpec, { onEdit });
247
-
248
- chart.destroy();
249
-
250
- // After destroy, dispatching events should not fire the callback
251
- document.dispatchEvent(createMouseEvent('mousemove', 200, 200));
252
- document.dispatchEvent(createMouseEvent('mouseup', 200, 200));
253
-
254
- expect(onEdit).not.toHaveBeenCalled();
255
- });
256
- });
257
-
258
- // =========================================================================
259
- // 2. onEdit callback integration
260
- // =========================================================================
261
- describe('onEdit callback integration', () => {
262
- it('when onEdit is NOT provided, no grab cursors appear on chrome/legend/labels', () => {
263
- // Mount without onEdit
264
- const chart = createChart(container, fullEditSpec, {});
265
-
266
- // Chrome elements should not have grab cursor
267
- const chromeTexts = container.querySelectorAll('.oc-chrome text[data-chrome-key]');
268
- for (const el of chromeTexts) {
269
- expect((el as HTMLElement).style.cursor).not.toBe('grab');
270
- }
271
-
272
- // Legend should not have grab cursor
273
- const legendG = container.querySelector('.oc-legend') as SVGGElement | null;
274
- if (legendG) {
275
- expect(legendG.style.cursor).not.toBe('grab');
276
- }
277
-
278
- // Series labels should not have grab cursor
279
- const seriesLabels = container.querySelectorAll('.oc-mark-label');
280
- for (const el of seriesLabels) {
281
- expect((el as HTMLElement).style.cursor).not.toBe('grab');
282
- }
283
-
284
- chart.destroy();
285
- });
286
-
287
- it('when onEdit IS provided, grab cursors appear on editable elements', () => {
288
- const onEdit = vi.fn();
289
- const chart = createChart(container, fullEditSpec, { onEdit });
290
-
291
- // Chrome elements should have grab cursor
292
- const chromeTexts = container.querySelectorAll('.oc-chrome text[data-chrome-key]');
293
- for (const el of chromeTexts) {
294
- expect((el as HTMLElement).style.cursor).toBe('grab');
295
- }
296
-
297
- // Legend should have grab cursor (if present)
298
- const legendG = container.querySelector('.oc-legend') as SVGGElement | null;
299
- if (legendG) {
300
- expect(legendG.style.cursor).toBe('grab');
301
- }
302
-
303
- // Series labels should have grab cursor (if any rendered with data-series)
304
- const seriesLabels = container.querySelectorAll('.oc-mark-label[data-series]');
305
- for (const el of seriesLabels) {
306
- expect((el as HTMLElement).style.cursor).toBe('grab');
307
- }
308
-
309
- chart.destroy();
310
- });
311
-
312
- it('dragging a text annotation fires BOTH onAnnotationEdit and onEdit when both are provided', () => {
313
- const onAnnotationEdit = vi.fn();
314
- const onEdit = vi.fn();
315
- const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit, onEdit });
316
-
317
- const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null;
318
- if (!annotation) {
319
- chart.destroy();
320
- return;
321
- }
322
-
323
- simulateDrag(annotation, 100, 100, 150, 130);
324
-
325
- expect(onAnnotationEdit).toHaveBeenCalledTimes(1);
326
- expect(onEdit).toHaveBeenCalledTimes(1);
327
-
328
- // Verify onAnnotationEdit received the text annotation and offset
329
- const [annotationArg, offsetArg] = onAnnotationEdit.mock.calls[0];
330
- expect(annotationArg.type).toBe('text');
331
- expect(annotationArg.text).toBe('Peak');
332
- expect(offsetArg.dx).toBe(60);
333
- expect(offsetArg.dy).toBe(10);
334
-
335
- // Verify onEdit received an annotation edit
336
- const edit: ElementEdit = onEdit.mock.calls[0][0];
337
- expect(edit.type).toBe('annotation');
338
- if (edit.type === 'annotation') {
339
- expect(edit.annotation.text).toBe('Peak');
340
- expect(edit.offset.dx).toBe(60);
341
- expect(edit.offset.dy).toBe(10);
342
- }
343
-
344
- chart.destroy();
345
- });
346
-
347
- it('dragging a text annotation fires only onEdit when only onEdit is provided', () => {
348
- const onEdit = vi.fn();
349
- const chart = createChart(container, textAnnotatedSpec, { onEdit });
350
-
351
- const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null;
352
- if (!annotation) {
353
- chart.destroy();
354
- return;
355
- }
356
-
357
- simulateDrag(annotation, 100, 100, 150, 130);
358
-
359
- expect(onEdit).toHaveBeenCalledTimes(1);
360
- const edit: ElementEdit = onEdit.mock.calls[0][0];
361
- expect(edit.type).toBe('annotation');
362
-
363
- chart.destroy();
364
- });
365
- });
366
-
367
- // =========================================================================
368
- // 3. wireAnnotationLabelDrag (range/refline)
369
- // =========================================================================
370
- describe('wireAnnotationLabelDrag', () => {
371
- it('range annotation labels get cursor:grab when onEdit is provided', () => {
372
- const onEdit = vi.fn();
373
- const chart = createChart(container, rangeAnnotatedSpec, { onEdit });
374
-
375
- const rangeLabel = container.querySelector(
376
- '.oc-annotation-range .oc-annotation-label',
377
- ) as SVGTextElement | null;
378
- if (!rangeLabel) {
379
- // Range may not render a visible label in test env
380
- chart.destroy();
381
- return;
382
- }
383
-
384
- expect(rangeLabel.style.cursor).toBe('grab');
385
- chart.destroy();
386
- });
387
-
388
- it('dragging a range label fires onEdit with type range-label and accumulated labelOffset', () => {
389
- const onEdit = vi.fn();
390
- const chart = createChart(container, rangeAnnotatedSpec, { onEdit });
391
-
392
- const rangeLabel = container.querySelector(
393
- '.oc-annotation-range .oc-annotation-label',
394
- ) as SVGTextElement | null;
395
- if (!rangeLabel) {
396
- chart.destroy();
397
- return;
398
- }
399
-
400
- simulateDrag(rangeLabel, 100, 100, 140, 120);
401
-
402
- expect(onEdit).toHaveBeenCalledTimes(1);
403
- const edit: ElementEdit = onEdit.mock.calls[0][0];
404
- expect(edit.type).toBe('range-label');
405
- if (edit.type === 'range-label') {
406
- expect(edit.annotation.type).toBe('range');
407
- // Original labelOffset (5, 3) + drag delta (40, 20) = (45, 23)
408
- expect(edit.labelOffset.dx).toBe(45);
409
- expect(edit.labelOffset.dy).toBe(23);
410
- }
411
-
412
- chart.destroy();
413
- });
414
-
415
- it('refline annotation labels get cursor:grab when onEdit is provided', () => {
416
- const onEdit = vi.fn();
417
- const chart = createChart(container, reflineAnnotatedSpec, { onEdit });
418
-
419
- const reflineLabel = container.querySelector(
420
- '.oc-annotation-refline .oc-annotation-label',
421
- ) as SVGTextElement | null;
422
- if (!reflineLabel) {
423
- chart.destroy();
424
- return;
425
- }
426
-
427
- expect(reflineLabel.style.cursor).toBe('grab');
428
- chart.destroy();
429
- });
430
-
431
- it('dragging a refline label fires onEdit with type refline-label and accumulated labelOffset', () => {
432
- const onEdit = vi.fn();
433
- const chart = createChart(container, reflineAnnotatedSpec, { onEdit });
434
-
435
- const reflineLabel = container.querySelector(
436
- '.oc-annotation-refline .oc-annotation-label',
437
- ) as SVGTextElement | null;
438
- if (!reflineLabel) {
439
- chart.destroy();
440
- return;
441
- }
442
-
443
- simulateDrag(reflineLabel, 100, 100, 130, 115);
444
-
445
- expect(onEdit).toHaveBeenCalledTimes(1);
446
- const edit: ElementEdit = onEdit.mock.calls[0][0];
447
- expect(edit.type).toBe('refline-label');
448
- if (edit.type === 'refline-label') {
449
- expect(edit.annotation.type).toBe('refline');
450
- // Original labelOffset (2, -4) + drag delta (30, 15) = (32, 11)
451
- expect(edit.labelOffset.dx).toBe(32);
452
- expect(edit.labelOffset.dy).toBe(11);
453
- }
454
-
455
- chart.destroy();
456
- });
457
- });
458
-
459
- // =========================================================================
460
- // 4. wireChromeDrag
461
- // =========================================================================
462
- describe('wireChromeDrag', () => {
463
- it('chrome text elements get cursor:grab when onEdit is provided', () => {
464
- const onEdit = vi.fn();
465
- const chart = createChart(container, fullEditSpec, { onEdit });
466
-
467
- const chromeTexts = container.querySelectorAll('.oc-chrome text[data-chrome-key]');
468
- expect(chromeTexts.length).toBeGreaterThan(0);
469
-
470
- for (const el of chromeTexts) {
471
- expect((el as HTMLElement).style.cursor).toBe('grab');
472
- }
473
-
474
- chart.destroy();
475
- });
476
-
477
- it('dragging a chrome element fires onEdit with type chrome, correct key, text, and accumulated offset', () => {
478
- const onEdit = vi.fn();
479
- const chart = createChart(container, fullEditSpec, { onEdit });
480
-
481
- const titleEl = container.querySelector(
482
- '.oc-chrome text[data-chrome-key="title"]',
483
- ) as SVGTextElement | null;
484
- if (!titleEl) {
485
- chart.destroy();
486
- return;
487
- }
488
-
489
- simulateDrag(titleEl, 100, 100, 130, 115);
490
-
491
- expect(onEdit).toHaveBeenCalledTimes(1);
492
- const edit: ElementEdit = onEdit.mock.calls[0][0];
493
- expect(edit.type).toBe('chrome');
494
- if (edit.type === 'chrome') {
495
- expect(edit.key).toBe('title');
496
- expect(edit.text).toBeTruthy();
497
- // No existing offset, so offset = delta (30, 15)
498
- expect(edit.offset.dx).toBe(30);
499
- expect(edit.offset.dy).toBe(15);
500
- }
501
-
502
- chart.destroy();
503
- });
504
- });
505
-
506
- // =========================================================================
507
- // 5. wireLegendDrag
508
- // =========================================================================
509
- describe('wireLegendDrag', () => {
510
- it('legend group gets cursor:grab when onEdit is provided', () => {
511
- const onEdit = vi.fn();
512
- const chart = createChart(container, fullEditSpec, { onEdit });
513
-
514
- const legendG = container.querySelector('.oc-legend') as SVGGElement | null;
515
- if (!legendG) {
516
- chart.destroy();
517
- return;
518
- }
519
-
520
- expect(legendG.style.cursor).toBe('grab');
521
- chart.destroy();
522
- });
523
-
524
- it('dragging legend fires onEdit with type legend and accumulated offset', () => {
525
- const onEdit = vi.fn();
526
- const chart = createChart(container, fullEditSpec, { onEdit });
527
-
528
- const legendG = container.querySelector('.oc-legend') as SVGGElement | null;
529
- if (!legendG) {
530
- chart.destroy();
531
- return;
532
- }
533
-
534
- simulateDrag(legendG, 200, 50, 250, 70);
535
-
536
- expect(onEdit).toHaveBeenCalledTimes(1);
537
- const edit: ElementEdit = onEdit.mock.calls[0][0];
538
- expect(edit.type).toBe('legend');
539
- if (edit.type === 'legend') {
540
- expect(edit.offset.dx).toBe(50);
541
- expect(edit.offset.dy).toBe(20);
542
- }
543
-
544
- chart.destroy();
545
- });
546
-
547
- it('click event after drag is suppressed (does not fire onLegendToggle)', () => {
548
- const onLegendToggle = vi.fn();
549
- const onEdit = vi.fn();
550
- const chart = createChart(container, fullEditSpec, { onLegendToggle, onEdit });
551
-
552
- const legendG = container.querySelector('.oc-legend') as SVGGElement | null;
553
- if (!legendG) {
554
- chart.destroy();
555
- return;
556
- }
557
-
558
- // Drag the legend
559
- simulateDrag(legendG, 200, 50, 250, 70);
560
-
561
- // Fire a click after drag
562
- legendG.dispatchEvent(createMouseEvent('click', 250, 70));
563
-
564
- expect(onEdit).toHaveBeenCalledTimes(1);
565
- // Click should have been suppressed by the drag handler
566
- expect(onLegendToggle).not.toHaveBeenCalled();
567
-
568
- chart.destroy();
569
- });
570
- });
571
-
572
- // =========================================================================
573
- // 6. wireConnectorEndpointDrag
574
- // =========================================================================
575
- describe('wireConnectorEndpointDrag', () => {
576
- it('connector handles are created dynamically when onEdit is provided', () => {
577
- const onEdit = vi.fn();
578
- const chart = createChart(container, textAnnotatedSpec, { onEdit });
579
-
580
- const annotationG = container.querySelector('.oc-annotation-text') as SVGGElement | null;
581
- if (!annotationG) {
582
- chart.destroy();
583
- return;
584
- }
585
-
586
- // Check that connector handles exist within the annotation group
587
- const handles = annotationG.querySelectorAll('.oc-connector-handle');
588
- // There should be 2 handles (from and to) if a connector is present
589
- const connector = annotationG.querySelector('.oc-annotation-connector');
590
- if (connector) {
591
- expect(handles.length).toBe(2);
592
- // Verify they have the correct data-endpoint attributes
593
- const endpoints = Array.from(handles).map((h) => h.getAttribute('data-endpoint'));
594
- expect(endpoints).toContain('from');
595
- expect(endpoints).toContain('to');
596
- }
597
-
598
- chart.destroy();
599
- });
600
-
601
- it('handles appear on mouseenter of annotation group', () => {
602
- const onEdit = vi.fn();
603
- const chart = createChart(container, textAnnotatedSpec, { onEdit });
604
-
605
- const annotationG = container.querySelector('.oc-annotation-text') as SVGGElement | null;
606
- if (!annotationG) {
607
- chart.destroy();
608
- return;
609
- }
610
-
611
- const handles = annotationG.querySelectorAll('.oc-connector-handle');
612
- if (handles.length === 0) {
613
- chart.destroy();
614
- return;
615
- }
616
-
617
- // Initially handles should be invisible (opacity: 0)
618
- for (const h of handles) {
619
- expect(h.getAttribute('opacity')).toBe('0');
620
- }
621
-
622
- // Trigger mouseenter on the annotation group
623
- annotationG.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
624
-
625
- // Now handles should be visible
626
- for (const h of handles) {
627
- expect(h.getAttribute('opacity')).toBe('0.6');
628
- }
629
-
630
- chart.destroy();
631
- });
632
-
633
- it('handles disappear on mouseleave', () => {
634
- const onEdit = vi.fn();
635
- const chart = createChart(container, textAnnotatedSpec, { onEdit });
636
-
637
- const annotationG = container.querySelector('.oc-annotation-text') as SVGGElement | null;
638
- if (!annotationG) {
639
- chart.destroy();
640
- return;
641
- }
642
-
643
- const handles = annotationG.querySelectorAll('.oc-connector-handle');
644
- if (handles.length === 0) {
645
- chart.destroy();
646
- return;
647
- }
648
-
649
- // Show handles first
650
- annotationG.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
651
- // Then hide them
652
- annotationG.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
653
-
654
- for (const h of handles) {
655
- expect(h.getAttribute('opacity')).toBe('0');
656
- }
657
-
658
- chart.destroy();
659
- });
660
-
661
- it('dragging a handle fires onEdit with type annotation-connector and correct endpoint/offset', () => {
662
- const onEdit = vi.fn();
663
- const chart = createChart(container, textAnnotatedSpec, { onEdit });
664
-
665
- const annotationG = container.querySelector('.oc-annotation-text') as SVGGElement | null;
666
- if (!annotationG) {
667
- chart.destroy();
668
- return;
669
- }
670
-
671
- // Find the "from" handle
672
- const fromHandle = annotationG.querySelector(
673
- '.oc-connector-handle[data-endpoint="from"]',
674
- ) as SVGCircleElement | null;
675
-
676
- if (!fromHandle) {
677
- chart.destroy();
678
- return;
679
- }
680
-
681
- simulateDrag(fromHandle, 100, 100, 130, 115);
682
-
683
- expect(onEdit).toHaveBeenCalledTimes(1);
684
- const edit: ElementEdit = onEdit.mock.calls[0][0];
685
- expect(edit.type).toBe('annotation-connector');
686
- if (edit.type === 'annotation-connector') {
687
- expect(edit.endpoint).toBe('from');
688
- expect(edit.annotation.type).toBe('text');
689
- // No existing connectorOffset, so offset = delta (30, 15)
690
- expect(edit.offset.dx).toBe(30);
691
- expect(edit.offset.dy).toBe(15);
692
- }
693
-
694
- chart.destroy();
695
- });
696
-
697
- it('dragging the "to" handle fires onEdit with endpoint "to"', () => {
698
- const onEdit = vi.fn();
699
- const chart = createChart(container, textAnnotatedSpec, { onEdit });
700
-
701
- const annotationG = container.querySelector('.oc-annotation-text') as SVGGElement | null;
702
- if (!annotationG) {
703
- chart.destroy();
704
- return;
705
- }
706
-
707
- const toHandle = annotationG.querySelector(
708
- '.oc-connector-handle[data-endpoint="to"]',
709
- ) as SVGCircleElement | null;
710
-
711
- if (!toHandle) {
712
- chart.destroy();
713
- return;
714
- }
715
-
716
- simulateDrag(toHandle, 100, 100, 140, 125);
717
-
718
- expect(onEdit).toHaveBeenCalledTimes(1);
719
- const edit: ElementEdit = onEdit.mock.calls[0][0];
720
- expect(edit.type).toBe('annotation-connector');
721
- if (edit.type === 'annotation-connector') {
722
- expect(edit.endpoint).toBe('to');
723
- }
724
-
725
- chart.destroy();
726
- });
727
-
728
- // The engine sorts annotations by zIndex, so the order the renderer draws them
729
- // in is NOT the order they appear in the spec. Edit mode maps a dragged label
730
- // back to its spec annotation, and if it maps by render position it edits the
731
- // wrong one -- you drag one callout and a different one jumps.
732
- describe('drags map to the right spec annotation, whatever the render order', () => {
733
- it('drags the annotation you grabbed, not the one that shares its slot', () => {
734
- const onEdit = vi.fn();
735
- const chart = createChart(container, reorderedAnnotatedSpec, { onEdit });
736
-
737
- // Grab by identity, not by position: this is the one the user clicked.
738
- const secondG = container.querySelector(
739
- '.oc-annotation-text[data-annotation-id="second"]',
740
- ) as SVGGElement;
741
- expect(secondG).toBeTruthy();
742
-
743
- simulateDrag(secondG, 100, 100, 120, 110);
744
-
745
- expect(onEdit).toHaveBeenCalledTimes(1);
746
- const edit: ElementEdit = onEdit.mock.calls[0][0];
747
- expect(edit.type).toBe('annotation');
748
- if (edit.type === 'annotation') {
749
- // The edit must carry the annotation whose label was actually dragged.
750
- // Pre-fix this returned the *other* one (zIndex had swapped their slots).
751
- expect(edit.annotation.id).toBe('second');
752
- }
753
-
754
- chart.destroy();
755
- });
756
-
757
- it('each label carries the index of its own spec annotation', () => {
758
- const chart = createChart(container, reorderedAnnotatedSpec, { onEdit: vi.fn() });
759
-
760
- const groups = Array.from(
761
- container.querySelectorAll('.oc-annotation-text'),
762
- ) as SVGGElement[];
763
- expect(groups).toHaveLength(2);
764
-
765
- for (const g of groups) {
766
- const id = g.getAttribute('data-annotation-id');
767
- const index = Number(g.getAttribute('data-annotation-index'));
768
- const expected = reorderedAnnotatedSpec.annotations!.findIndex(
769
- (a) => 'id' in a && a.id === id,
770
- );
771
- expect(index).toBe(expected);
772
- }
773
-
774
- chart.destroy();
775
- });
776
- });
777
-
778
- // A curved connector has no <line> to read x2/y2 from, so edit mode recovers
779
- // the tip by parsing the arrowhead polyline (`polyline.oc-annotation-arrowhead`,
780
- // whose middle point is the tip). If that selector ever stops matching what the
781
- // renderer emits, `querySelector` returns null, the tip silently falls back to
782
- // (0, 0), and the "to" handle jumps to the SVG origin. Nothing else in this file
783
- // renders an arrowed connector, so these are the only tests that can catch it.
784
- describe('curved connector (the arrowhead-tip path)', () => {
785
- it('renders exactly one arrowhead polyline that edit mode can find', () => {
786
- const chart = createChart(container, curvedAnnotatedSpec, { onEdit: vi.fn() });
787
-
788
- const annotationG = container.querySelector('.oc-annotation-text') as SVGGElement;
789
- expect(annotationG).toBeTruthy();
790
- // The path the renderer draws...
791
- expect(annotationG.querySelector('path.oc-annotation-connector')).toBeTruthy();
792
- // ...and the exact selector the drag code uses to find its tip.
793
- const arrowheads = annotationG.querySelectorAll('polyline.oc-annotation-arrowhead');
794
- expect(arrowheads).toHaveLength(1);
795
-
796
- chart.destroy();
797
- });
798
-
799
- it('puts the "to" handle on the arrowhead tip, not at the origin', () => {
800
- const chart = createChart(container, curvedAnnotatedSpec, { onEdit: vi.fn() });
801
-
802
- const annotationG = container.querySelector('.oc-annotation-text') as SVGGElement;
803
- const arrowhead = annotationG.querySelector(
804
- 'polyline.oc-annotation-arrowhead',
805
- ) as SVGPolylineElement;
806
- const toHandle = annotationG.querySelector(
807
- '.oc-connector-handle[data-endpoint="to"]',
808
- ) as SVGCircleElement;
809
- expect(toHandle).toBeTruthy();
810
-
811
- // "baseLeft tip baseRight" -- the tip is the middle point.
812
- const [tipX, tipY] = (arrowhead.getAttribute('points')?.split(' ')[1] ?? '').split(',');
813
- expect(Number(tipX)).toBeGreaterThan(0);
814
-
815
- expect(Number(toHandle.getAttribute('cx'))).toBeCloseTo(Number(tipX), 3);
816
- expect(Number(toHandle.getAttribute('cy'))).toBeCloseTo(Number(tipY), 3);
817
-
818
- chart.destroy();
819
- });
820
-
821
- it('dragging the "to" handle of a curved connector fires a real edit', () => {
822
- const onEdit = vi.fn();
823
- const chart = createChart(container, curvedAnnotatedSpec, { onEdit });
824
-
825
- const annotationG = container.querySelector('.oc-annotation-text') as SVGGElement;
826
- const toHandle = annotationG.querySelector(
827
- '.oc-connector-handle[data-endpoint="to"]',
828
- ) as SVGCircleElement;
829
- expect(toHandle).toBeTruthy();
830
-
831
- simulateDrag(toHandle, 100, 100, 130, 115);
832
-
833
- expect(onEdit).toHaveBeenCalledTimes(1);
834
- const edit: ElementEdit = onEdit.mock.calls[0][0];
835
- expect(edit.type).toBe('annotation-connector');
836
- if (edit.type === 'annotation-connector') {
837
- expect(edit.endpoint).toBe('to');
838
- expect(edit.offset.dx).toBe(30);
839
- expect(edit.offset.dy).toBe(15);
840
- }
841
-
842
- chart.destroy();
843
- });
844
- });
845
- });
846
-
847
- // =========================================================================
848
- // 7. wireSeriesLabelDrag
849
- // =========================================================================
850
- describe('wireSeriesLabelDrag', () => {
851
- it('series labels get cursor:grab when onEdit is provided', () => {
852
- const onEdit = vi.fn();
853
- const chart = createChart(container, fullEditSpec, { onEdit });
854
-
855
- const seriesLabels = container.querySelectorAll('.oc-mark-label[data-series]');
856
- if (seriesLabels.length === 0) {
857
- // Labels may not render in the test env depending on layout
858
- chart.destroy();
859
- return;
860
- }
861
-
862
- for (const el of seriesLabels) {
863
- expect((el as HTMLElement).style.cursor).toBe('grab');
864
- }
865
-
866
- chart.destroy();
867
- });
868
-
869
- it('dragging fires onEdit with type series-label, correct series name, and accumulated offset', () => {
870
- const onEdit = vi.fn();
871
- const chart = createChart(container, fullEditSpec, { onEdit });
872
-
873
- const seriesLabel = container.querySelector(
874
- '.oc-mark-label[data-series]',
875
- ) as SVGTextElement | null;
876
- if (!seriesLabel) {
877
- chart.destroy();
878
- return;
879
- }
880
-
881
- const seriesName = seriesLabel.getAttribute('data-series')!;
882
- simulateDrag(seriesLabel, 100, 100, 135, 118);
883
-
884
- // Find the onEdit call for a series-label (could have other edits from other elements)
885
- const seriesLabelCall = onEdit.mock.calls.find(
886
- (call: [ElementEdit]) => call[0].type === 'series-label',
887
- );
888
- expect(seriesLabelCall).toBeDefined();
889
-
890
- const edit: ElementEdit = seriesLabelCall![0];
891
- expect(edit.type).toBe('series-label');
892
- if (edit.type === 'series-label') {
893
- expect(edit.series).toBe(seriesName);
894
- // No existing offset, so offset = delta (35, 18)
895
- expect(edit.offset.dx).toBe(35);
896
- expect(edit.offset.dy).toBe(18);
897
- }
898
-
899
- chart.destroy();
900
- });
901
- });
902
- });