@opendata-ai/openchart-vanilla 6.28.6 → 7.0.2
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 +13 -8
- package/dist/index.js +2800 -2349
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +3 -3
- package/src/__tests__/crosshair.test.ts +11 -2
- package/src/__tests__/events.test.ts +55 -10
- package/src/barlist-mount.ts +15 -1
- package/src/graph/__tests__/canvas-renderer.test.ts +1 -0
- package/src/interactions/chart-events.ts +139 -0
- package/src/interactions/crosshair.ts +233 -0
- package/src/interactions/drag-handler.ts +175 -0
- package/src/interactions/editing-drags.ts +512 -0
- package/src/interactions/index.ts +25 -0
- package/src/interactions/keyboard-nav.ts +111 -0
- package/src/interactions/legend-interaction.ts +38 -0
- package/src/interactions/selection.ts +271 -0
- package/src/interactions/tooltip-events.ts +72 -0
- package/src/mount.ts +182 -1761
- package/src/renderers/annotations.ts +82 -2
- package/src/renderers/axes.ts +18 -1
- package/src/renderers/brand.ts +7 -1
- package/src/renderers/chrome.ts +50 -3
- package/src/renderers/endpoint-labels.ts +164 -0
- package/src/renderers/legend.ts +32 -27
- package/src/renderers/marks.ts +65 -17
- package/src/renderers/metrics.ts +50 -0
- package/src/svg-renderer.ts +80 -20
- package/src/tilemap-mount.ts +6 -6
- package/src/tilemap-renderer.ts +0 -2
- package/src/tooltip.ts +27 -7
package/src/mount.ts
CHANGED
|
@@ -9,24 +9,18 @@
|
|
|
9
9
|
|
|
10
10
|
import type {
|
|
11
11
|
Annotation,
|
|
12
|
-
AnnotationOffset,
|
|
13
12
|
ChartEventHandlers,
|
|
14
13
|
ChartLayout,
|
|
15
14
|
ChartSpec,
|
|
16
|
-
ChromeKey,
|
|
17
15
|
CompileOptions,
|
|
18
16
|
DarkMode,
|
|
19
|
-
|
|
17
|
+
DataRow,
|
|
20
18
|
ElementRef,
|
|
21
19
|
GraphSpec,
|
|
22
20
|
LayerSpec,
|
|
23
|
-
RangeAnnotation,
|
|
24
|
-
RefLineAnnotation,
|
|
25
|
-
TextAnnotation,
|
|
26
21
|
ThemeConfig,
|
|
27
|
-
TooltipContent,
|
|
28
22
|
} from '@opendata-ai/openchart-core';
|
|
29
|
-
import {
|
|
23
|
+
import { isLayerSpec } from '@opendata-ai/openchart-core';
|
|
30
24
|
import { compileChart, compileLayer } from '@opendata-ai/openchart-engine';
|
|
31
25
|
import { cancelAnimations, setupAnimationCleanup } from './animation';
|
|
32
26
|
import {
|
|
@@ -38,6 +32,27 @@ import {
|
|
|
38
32
|
type JPGExportOptions,
|
|
39
33
|
type SVGExportOptions,
|
|
40
34
|
} from './export';
|
|
35
|
+
import {
|
|
36
|
+
buildElementRef,
|
|
37
|
+
createScreenReaderTable,
|
|
38
|
+
findElementByRef,
|
|
39
|
+
getEditableElements,
|
|
40
|
+
getElementText,
|
|
41
|
+
isTextEditable,
|
|
42
|
+
refsEqual,
|
|
43
|
+
renderSelectionOverlay,
|
|
44
|
+
wireAnnotationDrag,
|
|
45
|
+
wireAnnotationLabelDrag,
|
|
46
|
+
wireChartEvents,
|
|
47
|
+
wireChromeDrag,
|
|
48
|
+
wireConnectorEndpointDrag,
|
|
49
|
+
wireKeyboardNav,
|
|
50
|
+
wireLegendDrag,
|
|
51
|
+
wireLegendInteraction,
|
|
52
|
+
wireSeriesLabelDrag,
|
|
53
|
+
wireTooltipEvents,
|
|
54
|
+
wireVoronoiTooltipEvents,
|
|
55
|
+
} from './interactions';
|
|
41
56
|
import { createMeasureText } from './measure-text';
|
|
42
57
|
import { observeResize } from './resize-observer';
|
|
43
58
|
import { renderChartSVG } from './svg-renderer';
|
|
@@ -108,1405 +123,16 @@ export interface ChartInstance {
|
|
|
108
123
|
function resolveDarkMode(mode?: DarkMode): boolean {
|
|
109
124
|
if (mode === 'force') return true;
|
|
110
125
|
if (mode === 'off' || mode === undefined) return false;
|
|
111
|
-
// "auto": check system preference
|
|
112
126
|
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
113
127
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
114
128
|
}
|
|
115
129
|
return false;
|
|
116
130
|
}
|
|
117
131
|
|
|
118
|
-
// ---------------------------------------------------------------------------
|
|
119
|
-
// Tooltip event wiring
|
|
120
|
-
// ---------------------------------------------------------------------------
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Wire tooltip events on mark elements inside an SVG.
|
|
124
|
-
* Returns a cleanup function to remove all listeners.
|
|
125
|
-
*/
|
|
126
|
-
function wireTooltipEvents(
|
|
127
|
-
svg: SVGElement,
|
|
128
|
-
tooltipDescriptors: Map<string, TooltipContent>,
|
|
129
|
-
tooltipManager: TooltipManager,
|
|
130
|
-
): () => void {
|
|
131
|
-
const markElements = svg.querySelectorAll('[data-mark-id]');
|
|
132
|
-
const cleanups: Array<() => void> = [];
|
|
133
|
-
|
|
134
|
-
for (const el of markElements) {
|
|
135
|
-
const markId = el.getAttribute('data-mark-id');
|
|
136
|
-
if (!markId) continue;
|
|
137
|
-
|
|
138
|
-
const content = tooltipDescriptors.get(markId);
|
|
139
|
-
if (!content) continue;
|
|
140
|
-
|
|
141
|
-
// Mouse enter -> show tooltip
|
|
142
|
-
const handleMouseEnter = (e: Event) => {
|
|
143
|
-
const mouseEvent = e as MouseEvent;
|
|
144
|
-
const svgRect = svg.getBoundingClientRect();
|
|
145
|
-
const x = mouseEvent.clientX - svgRect.left;
|
|
146
|
-
const y = mouseEvent.clientY - svgRect.top;
|
|
147
|
-
tooltipManager.show(content, x, y);
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
// Mouse move -> reposition tooltip
|
|
151
|
-
const handleMouseMove = (e: Event) => {
|
|
152
|
-
const mouseEvent = e as MouseEvent;
|
|
153
|
-
const svgRect = svg.getBoundingClientRect();
|
|
154
|
-
const x = mouseEvent.clientX - svgRect.left;
|
|
155
|
-
const y = mouseEvent.clientY - svgRect.top;
|
|
156
|
-
tooltipManager.show(content, x, y);
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
// Mouse leave -> hide tooltip
|
|
160
|
-
const handleMouseLeave = () => {
|
|
161
|
-
tooltipManager.hide();
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
// Touch: tap to show
|
|
165
|
-
const handleTouchStart = (e: Event) => {
|
|
166
|
-
const touchEvent = e as TouchEvent;
|
|
167
|
-
if (touchEvent.touches.length > 0) {
|
|
168
|
-
const touch = touchEvent.touches[0];
|
|
169
|
-
const svgRect = svg.getBoundingClientRect();
|
|
170
|
-
const x = touch.clientX - svgRect.left;
|
|
171
|
-
const y = touch.clientY - svgRect.top;
|
|
172
|
-
tooltipManager.show(content, x, y);
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
el.addEventListener('mouseenter', handleMouseEnter);
|
|
177
|
-
el.addEventListener('mousemove', handleMouseMove);
|
|
178
|
-
el.addEventListener('mouseleave', handleMouseLeave);
|
|
179
|
-
el.addEventListener('touchstart', handleTouchStart);
|
|
180
|
-
|
|
181
|
-
cleanups.push(() => {
|
|
182
|
-
el.removeEventListener('mouseenter', handleMouseEnter);
|
|
183
|
-
el.removeEventListener('mousemove', handleMouseMove);
|
|
184
|
-
el.removeEventListener('mouseleave', handleMouseLeave);
|
|
185
|
-
el.removeEventListener('touchstart', handleTouchStart);
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return () => {
|
|
190
|
-
for (const cleanup of cleanups) {
|
|
191
|
-
cleanup();
|
|
192
|
-
}
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ---------------------------------------------------------------------------
|
|
197
|
-
// Voronoi overlay tooltip wiring (nearest-point lookup for line/area charts)
|
|
198
|
-
// ---------------------------------------------------------------------------
|
|
199
|
-
|
|
200
|
-
/** A single data point with pixel coordinates, datum, and pre-computed tooltip. */
|
|
201
|
-
interface VoronoiPoint {
|
|
202
|
-
x: number;
|
|
203
|
-
y: number;
|
|
204
|
-
datum: Record<string, unknown>;
|
|
205
|
-
tooltip?: TooltipContent;
|
|
206
|
-
color: string;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Collect all dataPoints from line and area marks for nearest-point lookup.
|
|
211
|
-
*/
|
|
212
|
-
function collectVoronoiPoints(layout: ChartLayout): VoronoiPoint[] {
|
|
213
|
-
const points: VoronoiPoint[] = [];
|
|
214
|
-
for (const mark of layout.marks) {
|
|
215
|
-
if ((mark.type === 'line' || mark.type === 'area') && mark.dataPoints) {
|
|
216
|
-
const color = mark.type === 'line' ? mark.stroke : getRepresentativeColor(mark.fill);
|
|
217
|
-
for (const dp of mark.dataPoints) {
|
|
218
|
-
points.push({ ...dp, color });
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
return points;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Find the nearest VoronoiPoint to a given (x, y) position using linear scan.
|
|
227
|
-
* Returns null if no points exist.
|
|
228
|
-
*/
|
|
229
|
-
function findNearestPoint(points: VoronoiPoint[], x: number, y: number): VoronoiPoint | null {
|
|
230
|
-
if (points.length === 0) return null;
|
|
231
|
-
|
|
232
|
-
let nearest = points[0];
|
|
233
|
-
let minDist = (points[0].x - x) ** 2 + (points[0].y - y) ** 2;
|
|
234
|
-
|
|
235
|
-
for (let i = 1; i < points.length; i++) {
|
|
236
|
-
const dist = (points[i].x - x) ** 2 + (points[i].y - y) ** 2;
|
|
237
|
-
if (dist < minDist) {
|
|
238
|
-
minDist = dist;
|
|
239
|
-
nearest = points[i];
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
return nearest;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Wire voronoi overlay tooltip events for line/area charts.
|
|
248
|
-
* Uses a transparent overlay rect with nearest-point lookup instead of
|
|
249
|
-
* per-point event listeners, eliminating DOM bloat.
|
|
250
|
-
* Returns a cleanup function.
|
|
251
|
-
*/
|
|
252
|
-
function wireVoronoiTooltipEvents(
|
|
253
|
-
svg: SVGElement,
|
|
254
|
-
layout: ChartLayout,
|
|
255
|
-
tooltipManager: TooltipManager,
|
|
256
|
-
): () => void {
|
|
257
|
-
const overlay = svg.querySelector('[data-voronoi-overlay]');
|
|
258
|
-
if (!overlay) return () => {};
|
|
259
|
-
|
|
260
|
-
const voronoiPoints = collectVoronoiPoints(layout);
|
|
261
|
-
if (voronoiPoints.length === 0) return () => {};
|
|
262
|
-
|
|
263
|
-
const crosshair = svg.querySelector('[data-crosshair]') as SVGLineElement | null;
|
|
264
|
-
const cleanups: Array<() => void> = [];
|
|
265
|
-
|
|
266
|
-
const handleMouseMove = (e: Event) => {
|
|
267
|
-
const mouseEvent = e as MouseEvent;
|
|
268
|
-
const svgEl = svg as unknown as SVGSVGElement;
|
|
269
|
-
const svgRect = svgEl.getBoundingClientRect();
|
|
270
|
-
const viewBox = svgEl.viewBox?.baseVal;
|
|
271
|
-
|
|
272
|
-
// Convert client coordinates to SVG viewBox coordinates
|
|
273
|
-
const scaleX = viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1;
|
|
274
|
-
const scaleY = viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1;
|
|
275
|
-
const svgX = (mouseEvent.clientX - svgRect.left) * scaleX;
|
|
276
|
-
const svgY = (mouseEvent.clientY - svgRect.top) * scaleY;
|
|
277
|
-
|
|
278
|
-
const nearest = findNearestPoint(voronoiPoints, svgX, svgY);
|
|
279
|
-
if (!nearest?.tooltip) return;
|
|
280
|
-
|
|
281
|
-
// Update crosshair position to match the nearest data point's x
|
|
282
|
-
if (crosshair) {
|
|
283
|
-
crosshair.setAttribute('x1', String(nearest.x));
|
|
284
|
-
crosshair.setAttribute('x2', String(nearest.x));
|
|
285
|
-
crosshair.style.display = '';
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Show tooltip at the mouse position (relative to container, not SVG viewBox)
|
|
289
|
-
const containerX = mouseEvent.clientX - svgRect.left;
|
|
290
|
-
const containerY = mouseEvent.clientY - svgRect.top;
|
|
291
|
-
tooltipManager.show(nearest.tooltip, containerX, containerY);
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
const handleMouseLeave = () => {
|
|
295
|
-
if (crosshair) crosshair.style.display = 'none';
|
|
296
|
-
tooltipManager.hide();
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
// Touch support
|
|
300
|
-
const handleTouchStart = (e: Event) => {
|
|
301
|
-
const touchEvent = e as TouchEvent;
|
|
302
|
-
if (touchEvent.touches.length > 0) {
|
|
303
|
-
const touch = touchEvent.touches[0];
|
|
304
|
-
const svgEl = svg as unknown as SVGSVGElement;
|
|
305
|
-
const svgRect = svgEl.getBoundingClientRect();
|
|
306
|
-
const viewBox = svgEl.viewBox?.baseVal;
|
|
307
|
-
|
|
308
|
-
const scaleX = viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1;
|
|
309
|
-
const scaleY = viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1;
|
|
310
|
-
const svgX = (touch.clientX - svgRect.left) * scaleX;
|
|
311
|
-
const svgY = (touch.clientY - svgRect.top) * scaleY;
|
|
312
|
-
|
|
313
|
-
const nearest = findNearestPoint(voronoiPoints, svgX, svgY);
|
|
314
|
-
if (!nearest?.tooltip) return;
|
|
315
|
-
|
|
316
|
-
// Update crosshair position on touch
|
|
317
|
-
if (crosshair) {
|
|
318
|
-
crosshair.setAttribute('x1', String(nearest.x));
|
|
319
|
-
crosshair.setAttribute('x2', String(nearest.x));
|
|
320
|
-
crosshair.style.display = '';
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const containerX = touch.clientX - svgRect.left;
|
|
324
|
-
const containerY = touch.clientY - svgRect.top;
|
|
325
|
-
tooltipManager.show(nearest.tooltip, containerX, containerY);
|
|
326
|
-
}
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
overlay.addEventListener('mousemove', handleMouseMove);
|
|
330
|
-
overlay.addEventListener('mouseleave', handleMouseLeave);
|
|
331
|
-
overlay.addEventListener('touchstart', handleTouchStart);
|
|
332
|
-
|
|
333
|
-
cleanups.push(() => {
|
|
334
|
-
overlay.removeEventListener('mousemove', handleMouseMove);
|
|
335
|
-
overlay.removeEventListener('mouseleave', handleMouseLeave);
|
|
336
|
-
overlay.removeEventListener('touchstart', handleTouchStart);
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
return () => {
|
|
340
|
-
for (const cleanup of cleanups) {
|
|
341
|
-
cleanup();
|
|
342
|
-
}
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// ---------------------------------------------------------------------------
|
|
347
|
-
// Chart event wiring (click, hover, leave on marks; legend toggle; annotation click)
|
|
348
|
-
// ---------------------------------------------------------------------------
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Build a map from data-mark-id to { datum, series } so event handlers
|
|
352
|
-
* can look up the data row associated with a clicked/hovered mark element.
|
|
353
|
-
*/
|
|
354
|
-
function buildMarkDataMap(
|
|
355
|
-
layout: ChartLayout,
|
|
356
|
-
): Map<string, { datum: Record<string, unknown>; series?: string }> {
|
|
357
|
-
const map = new Map<string, { datum: Record<string, unknown>; series?: string }>();
|
|
358
|
-
|
|
359
|
-
for (let i = 0; i < layout.marks.length; i++) {
|
|
360
|
-
const mark = layout.marks[i];
|
|
361
|
-
switch (mark.type) {
|
|
362
|
-
case 'line':
|
|
363
|
-
map.set(`line-${mark.seriesKey ?? i}`, {
|
|
364
|
-
// For line marks, data is an array. Use the first row as representative.
|
|
365
|
-
datum: mark.data[0] ?? {},
|
|
366
|
-
series: mark.seriesKey,
|
|
367
|
-
});
|
|
368
|
-
break;
|
|
369
|
-
case 'area':
|
|
370
|
-
map.set(`area-${mark.seriesKey ?? i}`, {
|
|
371
|
-
datum: mark.data[0] ?? {},
|
|
372
|
-
series: mark.seriesKey,
|
|
373
|
-
});
|
|
374
|
-
break;
|
|
375
|
-
case 'rect':
|
|
376
|
-
map.set(`rect-${i}`, { datum: mark.data });
|
|
377
|
-
break;
|
|
378
|
-
case 'arc':
|
|
379
|
-
map.set(`arc-${i}`, { datum: mark.data });
|
|
380
|
-
break;
|
|
381
|
-
case 'point':
|
|
382
|
-
map.set(`point-${i}`, { datum: mark.data });
|
|
383
|
-
break;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
return map;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Wire chart event handlers (onMarkClick, onMarkHover, onMarkLeave) to mark
|
|
392
|
-
* elements, onLegendToggle to legend entries, and onAnnotationClick to annotation
|
|
393
|
-
* elements inside an SVG.
|
|
394
|
-
*
|
|
395
|
-
* Returns a cleanup function to remove all listeners.
|
|
396
|
-
*/
|
|
397
|
-
function wireChartEvents(
|
|
398
|
-
svg: SVGElement,
|
|
399
|
-
layout: ChartLayout,
|
|
400
|
-
specAnnotations: import('@opendata-ai/openchart-core').Annotation[],
|
|
401
|
-
handlers: ChartEventHandlers,
|
|
402
|
-
): () => void {
|
|
403
|
-
const cleanups: Array<() => void> = [];
|
|
404
|
-
const markDataMap = buildMarkDataMap(layout);
|
|
405
|
-
|
|
406
|
-
// Wire mark click/hover/leave events
|
|
407
|
-
if (handlers.onMarkClick || handlers.onMarkHover || handlers.onMarkLeave) {
|
|
408
|
-
const markElements = svg.querySelectorAll('[data-mark-id]');
|
|
409
|
-
|
|
410
|
-
for (const el of markElements) {
|
|
411
|
-
const markId = el.getAttribute('data-mark-id');
|
|
412
|
-
if (!markId) continue;
|
|
413
|
-
|
|
414
|
-
const markInfo = markDataMap.get(markId);
|
|
415
|
-
if (!markInfo) continue;
|
|
416
|
-
|
|
417
|
-
const series = markInfo.series ?? el.getAttribute('data-series') ?? undefined;
|
|
418
|
-
|
|
419
|
-
if (handlers.onMarkClick) {
|
|
420
|
-
const handleClick = (e: Event) => {
|
|
421
|
-
const mouseEvent = e as MouseEvent;
|
|
422
|
-
const svgRect = svg.getBoundingClientRect();
|
|
423
|
-
handlers.onMarkClick!({
|
|
424
|
-
datum: markInfo.datum,
|
|
425
|
-
series,
|
|
426
|
-
position: {
|
|
427
|
-
x: mouseEvent.clientX - svgRect.left,
|
|
428
|
-
y: mouseEvent.clientY - svgRect.top,
|
|
429
|
-
},
|
|
430
|
-
event: mouseEvent,
|
|
431
|
-
});
|
|
432
|
-
};
|
|
433
|
-
el.addEventListener('click', handleClick);
|
|
434
|
-
cleanups.push(() => el.removeEventListener('click', handleClick));
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
if (handlers.onMarkHover) {
|
|
438
|
-
const handleEnter = (e: Event) => {
|
|
439
|
-
const mouseEvent = e as MouseEvent;
|
|
440
|
-
const svgRect = svg.getBoundingClientRect();
|
|
441
|
-
handlers.onMarkHover!({
|
|
442
|
-
datum: markInfo.datum,
|
|
443
|
-
series,
|
|
444
|
-
position: {
|
|
445
|
-
x: mouseEvent.clientX - svgRect.left,
|
|
446
|
-
y: mouseEvent.clientY - svgRect.top,
|
|
447
|
-
},
|
|
448
|
-
event: mouseEvent,
|
|
449
|
-
});
|
|
450
|
-
};
|
|
451
|
-
el.addEventListener('mouseenter', handleEnter);
|
|
452
|
-
cleanups.push(() => el.removeEventListener('mouseenter', handleEnter));
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
if (handlers.onMarkLeave) {
|
|
456
|
-
const handleLeave = () => {
|
|
457
|
-
handlers.onMarkLeave!();
|
|
458
|
-
};
|
|
459
|
-
el.addEventListener('mouseleave', handleLeave);
|
|
460
|
-
cleanups.push(() => el.removeEventListener('mouseleave', handleLeave));
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// Wire annotation click events
|
|
466
|
-
if (handlers.onAnnotationClick) {
|
|
467
|
-
const annotationElements = svg.querySelectorAll('.oc-annotation');
|
|
468
|
-
|
|
469
|
-
for (let i = 0; i < annotationElements.length; i++) {
|
|
470
|
-
const el = annotationElements[i];
|
|
471
|
-
const specAnnotation = specAnnotations[i];
|
|
472
|
-
if (!specAnnotation) continue;
|
|
473
|
-
|
|
474
|
-
const handleClick = (e: Event) => {
|
|
475
|
-
const mouseEvent = e as MouseEvent;
|
|
476
|
-
handlers.onAnnotationClick!(specAnnotation, mouseEvent);
|
|
477
|
-
};
|
|
478
|
-
|
|
479
|
-
el.addEventListener('click', handleClick);
|
|
480
|
-
cleanups.push(() => el.removeEventListener('click', handleClick));
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
return () => {
|
|
485
|
-
for (const cleanup of cleanups) {
|
|
486
|
-
cleanup();
|
|
487
|
-
}
|
|
488
|
-
};
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// ---------------------------------------------------------------------------
|
|
492
|
-
// Shared drag handler utility
|
|
493
|
-
// ---------------------------------------------------------------------------
|
|
494
|
-
|
|
495
|
-
interface DragConfig {
|
|
496
|
-
element: SVGElement;
|
|
497
|
-
svg: SVGSVGElement;
|
|
498
|
-
onMove: (dx: number, dy: number) => void;
|
|
499
|
-
onEnd: (dx: number, dy: number, moved: boolean) => void;
|
|
500
|
-
setDragging: (dragging: boolean) => void;
|
|
501
|
-
threshold?: number; // default: 3
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Reusable drag handler for SVG elements.
|
|
506
|
-
* Handles mouse and touch events, viewBox scaling, threshold detection,
|
|
507
|
-
* click suppression after drag, and cursor state.
|
|
508
|
-
*
|
|
509
|
-
* Returns a cleanup function that removes all listeners.
|
|
510
|
-
*/
|
|
511
|
-
function createDragHandler(config: DragConfig): () => void {
|
|
512
|
-
const { element, svg, onMove, onEnd, setDragging, threshold = 3 } = config;
|
|
513
|
-
const cleanups: Array<() => void> = [];
|
|
514
|
-
|
|
515
|
-
// Track active document listeners so cleanup can remove them mid-drag
|
|
516
|
-
let activeDocMouseMove: ((e: MouseEvent) => void) | null = null;
|
|
517
|
-
let activeDocMouseUp: ((e: MouseEvent) => void) | null = null;
|
|
518
|
-
let activeDocTouchMove: ((e: TouchEvent) => void) | null = null;
|
|
519
|
-
let activeDocTouchEnd: ((e: TouchEvent) => void) | null = null;
|
|
520
|
-
let activeDocTouchCancel: ((e: TouchEvent) => void) | null = null;
|
|
521
|
-
|
|
522
|
-
function getScale(): { scaleX: number; scaleY: number } {
|
|
523
|
-
const viewBox = svg.viewBox?.baseVal;
|
|
524
|
-
const svgRect = svg.getBoundingClientRect();
|
|
525
|
-
return {
|
|
526
|
-
scaleX: viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1,
|
|
527
|
-
scaleY: viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1,
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
function startDrag(startX: number, startY: number): void {
|
|
532
|
-
setDragging(true);
|
|
533
|
-
const { scaleX, scaleY } = getScale();
|
|
534
|
-
|
|
535
|
-
element.style.cursor = 'grabbing';
|
|
536
|
-
// Prevent text selection during drag
|
|
537
|
-
svg.style.userSelect = 'none';
|
|
538
|
-
|
|
539
|
-
const handleMove = (clientX: number, clientY: number) => {
|
|
540
|
-
const dx = (clientX - startX) * scaleX;
|
|
541
|
-
const dy = (clientY - startY) * scaleY;
|
|
542
|
-
onMove(dx, dy);
|
|
543
|
-
};
|
|
544
|
-
|
|
545
|
-
const cleanupDocListeners = () => {
|
|
546
|
-
if (activeDocMouseMove) {
|
|
547
|
-
document.removeEventListener('mousemove', activeDocMouseMove);
|
|
548
|
-
activeDocMouseMove = null;
|
|
549
|
-
}
|
|
550
|
-
if (activeDocMouseUp) {
|
|
551
|
-
document.removeEventListener('mouseup', activeDocMouseUp);
|
|
552
|
-
activeDocMouseUp = null;
|
|
553
|
-
}
|
|
554
|
-
if (activeDocTouchMove) {
|
|
555
|
-
document.removeEventListener('touchmove', activeDocTouchMove);
|
|
556
|
-
activeDocTouchMove = null;
|
|
557
|
-
}
|
|
558
|
-
if (activeDocTouchEnd) {
|
|
559
|
-
document.removeEventListener('touchend', activeDocTouchEnd);
|
|
560
|
-
activeDocTouchEnd = null;
|
|
561
|
-
}
|
|
562
|
-
if (activeDocTouchCancel) {
|
|
563
|
-
document.removeEventListener('touchcancel', activeDocTouchCancel);
|
|
564
|
-
activeDocTouchCancel = null;
|
|
565
|
-
}
|
|
566
|
-
};
|
|
567
|
-
|
|
568
|
-
const handleEnd = (clientX: number, clientY: number) => {
|
|
569
|
-
const dx = (clientX - startX) * scaleX;
|
|
570
|
-
const dy = (clientY - startY) * scaleY;
|
|
571
|
-
const moved = Math.abs(dx) > threshold || Math.abs(dy) > threshold;
|
|
572
|
-
|
|
573
|
-
onEnd(dx, dy, moved);
|
|
574
|
-
|
|
575
|
-
// Suppress click if drag actually moved
|
|
576
|
-
if (moved) {
|
|
577
|
-
element.addEventListener(
|
|
578
|
-
'click',
|
|
579
|
-
(clickE) => {
|
|
580
|
-
clickE.stopPropagation();
|
|
581
|
-
},
|
|
582
|
-
{ capture: true, once: true },
|
|
583
|
-
);
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
element.style.cursor = 'grab';
|
|
587
|
-
svg.style.userSelect = '';
|
|
588
|
-
|
|
589
|
-
cleanupDocListeners();
|
|
590
|
-
setDragging(false);
|
|
591
|
-
};
|
|
592
|
-
|
|
593
|
-
// Mouse listeners
|
|
594
|
-
const onMouseMove = (moveEvent: MouseEvent) => {
|
|
595
|
-
handleMove(moveEvent.clientX, moveEvent.clientY);
|
|
596
|
-
};
|
|
597
|
-
const onMouseUp = (upEvent: MouseEvent) => {
|
|
598
|
-
handleEnd(upEvent.clientX, upEvent.clientY);
|
|
599
|
-
};
|
|
600
|
-
document.addEventListener('mousemove', onMouseMove);
|
|
601
|
-
document.addEventListener('mouseup', onMouseUp);
|
|
602
|
-
activeDocMouseMove = onMouseMove;
|
|
603
|
-
activeDocMouseUp = onMouseUp;
|
|
604
|
-
|
|
605
|
-
// Touch listeners
|
|
606
|
-
const onTouchMove = (moveEvent: TouchEvent) => {
|
|
607
|
-
if (moveEvent.touches.length > 0) {
|
|
608
|
-
moveEvent.preventDefault();
|
|
609
|
-
handleMove(moveEvent.touches[0].clientX, moveEvent.touches[0].clientY);
|
|
610
|
-
}
|
|
611
|
-
};
|
|
612
|
-
const onTouchEnd = (endEvent: TouchEvent) => {
|
|
613
|
-
const touch = endEvent.changedTouches[0];
|
|
614
|
-
if (touch) {
|
|
615
|
-
handleEnd(touch.clientX, touch.clientY);
|
|
616
|
-
} else {
|
|
617
|
-
handleEnd(startX, startY);
|
|
618
|
-
}
|
|
619
|
-
};
|
|
620
|
-
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
621
|
-
document.addEventListener('touchend', onTouchEnd);
|
|
622
|
-
document.addEventListener('touchcancel', onTouchEnd);
|
|
623
|
-
activeDocTouchMove = onTouchMove;
|
|
624
|
-
activeDocTouchEnd = onTouchEnd;
|
|
625
|
-
activeDocTouchCancel = onTouchEnd;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// Mouse down handler
|
|
629
|
-
const handleMouseDown = (e: Event) => {
|
|
630
|
-
const mouseEvent = e as MouseEvent;
|
|
631
|
-
mouseEvent.preventDefault();
|
|
632
|
-
startDrag(mouseEvent.clientX, mouseEvent.clientY);
|
|
633
|
-
};
|
|
634
|
-
|
|
635
|
-
// Touch start handler
|
|
636
|
-
const handleTouchStart = (e: Event) => {
|
|
637
|
-
const touchEvent = e as TouchEvent;
|
|
638
|
-
if (touchEvent.touches.length === 1) {
|
|
639
|
-
touchEvent.preventDefault();
|
|
640
|
-
startDrag(touchEvent.touches[0].clientX, touchEvent.touches[0].clientY);
|
|
641
|
-
}
|
|
642
|
-
};
|
|
643
|
-
|
|
644
|
-
element.addEventListener('mousedown', handleMouseDown);
|
|
645
|
-
element.addEventListener('touchstart', handleTouchStart, { passive: false });
|
|
646
|
-
cleanups.push(() => {
|
|
647
|
-
element.removeEventListener('mousedown', handleMouseDown);
|
|
648
|
-
element.removeEventListener('touchstart', handleTouchStart);
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
return () => {
|
|
652
|
-
for (const cleanup of cleanups) {
|
|
653
|
-
cleanup();
|
|
654
|
-
}
|
|
655
|
-
// Clean up any active document listeners (mid-drag unmount)
|
|
656
|
-
if (activeDocMouseMove) {
|
|
657
|
-
document.removeEventListener('mousemove', activeDocMouseMove);
|
|
658
|
-
activeDocMouseMove = null;
|
|
659
|
-
}
|
|
660
|
-
if (activeDocMouseUp) {
|
|
661
|
-
document.removeEventListener('mouseup', activeDocMouseUp);
|
|
662
|
-
activeDocMouseUp = null;
|
|
663
|
-
}
|
|
664
|
-
if (activeDocTouchMove) {
|
|
665
|
-
document.removeEventListener('touchmove', activeDocTouchMove);
|
|
666
|
-
activeDocTouchMove = null;
|
|
667
|
-
}
|
|
668
|
-
if (activeDocTouchEnd) {
|
|
669
|
-
document.removeEventListener('touchend', activeDocTouchEnd);
|
|
670
|
-
activeDocTouchEnd = null;
|
|
671
|
-
}
|
|
672
|
-
if (activeDocTouchCancel) {
|
|
673
|
-
document.removeEventListener('touchcancel', activeDocTouchCancel);
|
|
674
|
-
activeDocTouchCancel = null;
|
|
675
|
-
}
|
|
676
|
-
// Restore user-select in case of mid-drag cleanup
|
|
677
|
-
svg.style.userSelect = '';
|
|
678
|
-
};
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
// ---------------------------------------------------------------------------
|
|
682
|
-
// Annotation drag editing
|
|
683
|
-
// ---------------------------------------------------------------------------
|
|
684
|
-
|
|
685
|
-
/**
|
|
686
|
-
* Wire drag-to-reposition on text annotation labels.
|
|
687
|
-
* Only activates for text annotations (not range or refline).
|
|
688
|
-
* During drag, applies a CSS transform for real-time visual feedback and
|
|
689
|
-
* counter-adjusts straight connector endpoints so the data-point end stays fixed.
|
|
690
|
-
* On mouseup, fires the callback with the updated offset values.
|
|
691
|
-
*
|
|
692
|
-
* Returns a cleanup function to remove all listeners.
|
|
693
|
-
*/
|
|
694
|
-
function wireAnnotationDrag(
|
|
695
|
-
svg: SVGElement,
|
|
696
|
-
specAnnotations: Annotation[],
|
|
697
|
-
onAnnotationEdit:
|
|
698
|
-
| ((annotation: TextAnnotation, updatedOffset: AnnotationOffset) => void)
|
|
699
|
-
| undefined,
|
|
700
|
-
onEdit: ((edit: ElementEdit) => void) | undefined,
|
|
701
|
-
setDragging: (dragging: boolean) => void,
|
|
702
|
-
): () => void {
|
|
703
|
-
const annotationElements = svg.querySelectorAll('.oc-annotation-text');
|
|
704
|
-
const cleanups: Array<() => void> = [];
|
|
705
|
-
|
|
706
|
-
for (const el of annotationElements) {
|
|
707
|
-
const indexStr = el.getAttribute('data-annotation-index');
|
|
708
|
-
if (indexStr === null) continue;
|
|
709
|
-
|
|
710
|
-
const index = Number(indexStr);
|
|
711
|
-
const specAnnotation = specAnnotations[index];
|
|
712
|
-
if (!specAnnotation || specAnnotation.type !== 'text') continue;
|
|
713
|
-
|
|
714
|
-
const textAnnotation = specAnnotation as TextAnnotation;
|
|
715
|
-
const annotationG = el as SVGGElement;
|
|
716
|
-
|
|
717
|
-
// Visual affordance: show grab cursor
|
|
718
|
-
annotationG.style.cursor = 'grab';
|
|
719
|
-
|
|
720
|
-
// Stash connector info for real-time updates during drag
|
|
721
|
-
const connectorLine = annotationG.querySelector('line.oc-annotation-connector');
|
|
722
|
-
const origX2 = connectorLine ? Number(connectorLine.getAttribute('x2')) : 0;
|
|
723
|
-
const origY2 = connectorLine ? Number(connectorLine.getAttribute('y2')) : 0;
|
|
724
|
-
|
|
725
|
-
// For curved connectors, stash path/polygon elements to hide during drag
|
|
726
|
-
const curvedPath = annotationG.querySelector('path.oc-annotation-connector');
|
|
727
|
-
const arrowhead = annotationG.querySelector('polygon.oc-annotation-connector');
|
|
728
|
-
const hasCurvedConnector = curvedPath !== null;
|
|
729
|
-
|
|
730
|
-
const origDx = textAnnotation.offset?.dx ?? 0;
|
|
731
|
-
const origDy = textAnnotation.offset?.dy ?? 0;
|
|
732
|
-
|
|
733
|
-
const cleanup = createDragHandler({
|
|
734
|
-
element: annotationG,
|
|
735
|
-
svg: svg as unknown as SVGSVGElement,
|
|
736
|
-
onMove: (dx, dy) => {
|
|
737
|
-
// Move the entire annotation group
|
|
738
|
-
annotationG.setAttribute('transform', `translate(${dx}, ${dy})`);
|
|
739
|
-
|
|
740
|
-
// For straight connectors, counter-adjust the data-point end
|
|
741
|
-
if (connectorLine && !hasCurvedConnector) {
|
|
742
|
-
connectorLine.setAttribute('x2', String(origX2 - dx));
|
|
743
|
-
connectorLine.setAttribute('y2', String(origY2 - dy));
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
// Hide curved connector elements during drag
|
|
747
|
-
if (hasCurvedConnector) {
|
|
748
|
-
if (curvedPath) curvedPath.setAttribute('display', 'none');
|
|
749
|
-
if (arrowhead) arrowhead.setAttribute('display', 'none');
|
|
750
|
-
}
|
|
751
|
-
},
|
|
752
|
-
onEnd: (dx, dy, moved) => {
|
|
753
|
-
// Clean up visual state
|
|
754
|
-
annotationG.removeAttribute('transform');
|
|
755
|
-
|
|
756
|
-
// Restore straight connector to original values
|
|
757
|
-
if (connectorLine && !hasCurvedConnector) {
|
|
758
|
-
connectorLine.setAttribute('x2', String(origX2));
|
|
759
|
-
connectorLine.setAttribute('y2', String(origY2));
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
// Restore curved connector elements
|
|
763
|
-
if (hasCurvedConnector) {
|
|
764
|
-
if (curvedPath) curvedPath.removeAttribute('display');
|
|
765
|
-
if (arrowhead) arrowhead.removeAttribute('display');
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
if (moved) {
|
|
769
|
-
const newOffset: AnnotationOffset = {
|
|
770
|
-
dx: origDx + dx,
|
|
771
|
-
dy: origDy + dy,
|
|
772
|
-
};
|
|
773
|
-
// Fire legacy callback
|
|
774
|
-
onAnnotationEdit?.(textAnnotation, newOffset);
|
|
775
|
-
// Fire unified edit callback
|
|
776
|
-
onEdit?.({ type: 'annotation', annotation: textAnnotation, offset: newOffset });
|
|
777
|
-
}
|
|
778
|
-
},
|
|
779
|
-
setDragging,
|
|
780
|
-
});
|
|
781
|
-
|
|
782
|
-
cleanups.push(cleanup);
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
return () => {
|
|
786
|
-
for (const cleanup of cleanups) {
|
|
787
|
-
cleanup();
|
|
788
|
-
}
|
|
789
|
-
};
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
// ---------------------------------------------------------------------------
|
|
793
|
-
// Connector endpoint drag
|
|
794
|
-
// ---------------------------------------------------------------------------
|
|
795
|
-
|
|
796
|
-
/**
|
|
797
|
-
* Wire drag on connector endpoint handles for text annotations.
|
|
798
|
-
* Dynamically creates invisible handle circles at connector endpoints
|
|
799
|
-
* so they only exist when editing is active (not in every chart).
|
|
800
|
-
* During drag, updates the handle position and the connector line endpoints.
|
|
801
|
-
* On end, fires onEdit with the accumulated endpoint offset.
|
|
802
|
-
*
|
|
803
|
-
* Shows handles on hover over the parent annotation group.
|
|
804
|
-
* Returns a cleanup function that removes handles and all listeners.
|
|
805
|
-
*/
|
|
806
|
-
function wireConnectorEndpointDrag(
|
|
807
|
-
svg: SVGElement,
|
|
808
|
-
specAnnotations: Annotation[],
|
|
809
|
-
onEdit: (edit: ElementEdit) => void,
|
|
810
|
-
setDragging: (dragging: boolean) => void,
|
|
811
|
-
): () => void {
|
|
812
|
-
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
813
|
-
const cleanups: Array<() => void> = [];
|
|
814
|
-
const annotationGroups = svg.querySelectorAll('.oc-annotation-text');
|
|
815
|
-
|
|
816
|
-
for (const el of annotationGroups) {
|
|
817
|
-
const annotationG = el as SVGGElement;
|
|
818
|
-
const indexStr = annotationG.getAttribute('data-annotation-index');
|
|
819
|
-
if (indexStr === null) continue;
|
|
820
|
-
|
|
821
|
-
const index = Number(indexStr);
|
|
822
|
-
const specAnnotation = specAnnotations[index];
|
|
823
|
-
if (!specAnnotation || specAnnotation.type !== 'text') continue;
|
|
824
|
-
|
|
825
|
-
const textAnnotation = specAnnotation as TextAnnotation;
|
|
826
|
-
|
|
827
|
-
// Find connector line or curved connector to determine endpoints
|
|
828
|
-
const connectorLine = annotationG.querySelector('line.oc-annotation-connector');
|
|
829
|
-
const curvedPath = annotationG.querySelector('path.oc-annotation-connector');
|
|
830
|
-
if (!connectorLine && !curvedPath) continue;
|
|
831
|
-
|
|
832
|
-
// Determine connector endpoint positions from the connector element
|
|
833
|
-
let fromX: number, fromY: number, toX: number, toY: number;
|
|
834
|
-
if (connectorLine) {
|
|
835
|
-
fromX = Number(connectorLine.getAttribute('x1')) || 0;
|
|
836
|
-
fromY = Number(connectorLine.getAttribute('y1')) || 0;
|
|
837
|
-
toX = Number(connectorLine.getAttribute('x2')) || 0;
|
|
838
|
-
toY = Number(connectorLine.getAttribute('y2')) || 0;
|
|
839
|
-
} else {
|
|
840
|
-
// For curved connectors, get positions from the path data
|
|
841
|
-
// The path starts at M x y, so parse the first coordinates
|
|
842
|
-
const pathD = curvedPath!.getAttribute('d') ?? '';
|
|
843
|
-
const mMatch = pathD.match(/M\s*([\d.e+-]+)\s+([\d.e+-]+)/);
|
|
844
|
-
fromX = mMatch ? Number(mMatch[1]) : 0;
|
|
845
|
-
fromY = mMatch ? Number(mMatch[2]) : 0;
|
|
846
|
-
// For curved connectors, the arrow polygon has the target
|
|
847
|
-
const arrowhead = annotationG.querySelector('polygon.oc-annotation-connector');
|
|
848
|
-
const points = arrowhead?.getAttribute('points') ?? '';
|
|
849
|
-
const firstPoint = points.split(' ')[0] ?? '0,0';
|
|
850
|
-
const [px, py] = firstPoint.split(',');
|
|
851
|
-
toX = Number(px) || 0;
|
|
852
|
-
toY = Number(py) || 0;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
// Create handles dynamically
|
|
856
|
-
const endpoints: Array<{ name: 'from' | 'to'; cx: number; cy: number }> = [
|
|
857
|
-
{ name: 'from', cx: fromX, cy: fromY },
|
|
858
|
-
{ name: 'to', cx: toX, cy: toY },
|
|
859
|
-
];
|
|
860
|
-
|
|
861
|
-
const createdHandles: SVGCircleElement[] = [];
|
|
862
|
-
|
|
863
|
-
for (const ep of endpoints) {
|
|
864
|
-
// Skip endpoints with invalid coordinates to prevent NaN in SVG attributes
|
|
865
|
-
if (!Number.isFinite(ep.cx) || !Number.isFinite(ep.cy)) continue;
|
|
866
|
-
|
|
867
|
-
const handleEl = document.createElementNS(SVG_NS, 'circle') as SVGCircleElement;
|
|
868
|
-
handleEl.setAttribute('class', 'oc-connector-handle');
|
|
869
|
-
handleEl.setAttribute('data-endpoint', ep.name);
|
|
870
|
-
handleEl.setAttribute('cx', String(ep.cx));
|
|
871
|
-
handleEl.setAttribute('cy', String(ep.cy));
|
|
872
|
-
handleEl.setAttribute('r', '4');
|
|
873
|
-
handleEl.setAttribute('opacity', '0');
|
|
874
|
-
handleEl.setAttribute('fill', 'currentColor');
|
|
875
|
-
handleEl.setAttribute('stroke', 'currentColor');
|
|
876
|
-
annotationG.appendChild(handleEl);
|
|
877
|
-
createdHandles.push(handleEl);
|
|
878
|
-
|
|
879
|
-
const origCx = ep.cx;
|
|
880
|
-
const origCy = ep.cy;
|
|
881
|
-
|
|
882
|
-
// Prevent parent annotation drag from firing
|
|
883
|
-
const stopProp = (e: Event) => {
|
|
884
|
-
e.stopPropagation();
|
|
885
|
-
};
|
|
886
|
-
handleEl.addEventListener('mousedown', stopProp);
|
|
887
|
-
handleEl.addEventListener('touchstart', stopProp);
|
|
888
|
-
cleanups.push(() => {
|
|
889
|
-
handleEl.removeEventListener('mousedown', stopProp);
|
|
890
|
-
handleEl.removeEventListener('touchstart', stopProp);
|
|
891
|
-
});
|
|
892
|
-
|
|
893
|
-
const cleanup = createDragHandler({
|
|
894
|
-
element: handleEl,
|
|
895
|
-
svg: svg as unknown as SVGSVGElement,
|
|
896
|
-
onMove: (dx, dy) => {
|
|
897
|
-
handleEl.setAttribute('cx', String(origCx + dx));
|
|
898
|
-
handleEl.setAttribute('cy', String(origCy + dy));
|
|
899
|
-
|
|
900
|
-
if (connectorLine) {
|
|
901
|
-
if (ep.name === 'from') {
|
|
902
|
-
connectorLine.setAttribute('x1', String(origCx + dx));
|
|
903
|
-
connectorLine.setAttribute('y1', String(origCy + dy));
|
|
904
|
-
} else {
|
|
905
|
-
connectorLine.setAttribute('x2', String(origCx + dx));
|
|
906
|
-
connectorLine.setAttribute('y2', String(origCy + dy));
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
},
|
|
910
|
-
onEnd: (dx, dy, moved) => {
|
|
911
|
-
handleEl.setAttribute('cx', String(origCx));
|
|
912
|
-
handleEl.setAttribute('cy', String(origCy));
|
|
913
|
-
|
|
914
|
-
if (connectorLine) {
|
|
915
|
-
if (ep.name === 'from') {
|
|
916
|
-
connectorLine.setAttribute('x1', String(origCx));
|
|
917
|
-
connectorLine.setAttribute('y1', String(origCy));
|
|
918
|
-
} else {
|
|
919
|
-
connectorLine.setAttribute('x2', String(origCx));
|
|
920
|
-
connectorLine.setAttribute('y2', String(origCy));
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
if (moved) {
|
|
925
|
-
const existingOffset = textAnnotation.connectorOffset?.[ep.name];
|
|
926
|
-
const origEndDx = existingOffset?.dx ?? 0;
|
|
927
|
-
const origEndDy = existingOffset?.dy ?? 0;
|
|
928
|
-
onEdit({
|
|
929
|
-
type: 'annotation-connector',
|
|
930
|
-
annotation: textAnnotation,
|
|
931
|
-
endpoint: ep.name,
|
|
932
|
-
offset: { dx: origEndDx + dx, dy: origEndDy + dy },
|
|
933
|
-
});
|
|
934
|
-
}
|
|
935
|
-
},
|
|
936
|
-
setDragging,
|
|
937
|
-
});
|
|
938
|
-
|
|
939
|
-
cleanups.push(cleanup);
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
// Wire hover to show/hide handles
|
|
943
|
-
const showHandles = () => {
|
|
944
|
-
for (const h of createdHandles) {
|
|
945
|
-
h.setAttribute('opacity', '0.6');
|
|
946
|
-
}
|
|
947
|
-
};
|
|
948
|
-
const hideHandles = () => {
|
|
949
|
-
for (const h of createdHandles) {
|
|
950
|
-
h.setAttribute('opacity', '0');
|
|
951
|
-
}
|
|
952
|
-
};
|
|
953
|
-
|
|
954
|
-
annotationG.addEventListener('mouseenter', showHandles);
|
|
955
|
-
annotationG.addEventListener('mouseleave', hideHandles);
|
|
956
|
-
cleanups.push(() => {
|
|
957
|
-
annotationG.removeEventListener('mouseenter', showHandles);
|
|
958
|
-
annotationG.removeEventListener('mouseleave', hideHandles);
|
|
959
|
-
// Remove dynamically created handles
|
|
960
|
-
for (const h of createdHandles) {
|
|
961
|
-
h.remove();
|
|
962
|
-
}
|
|
963
|
-
});
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
return () => {
|
|
967
|
-
for (const cleanup of cleanups) {
|
|
968
|
-
cleanup();
|
|
969
|
-
}
|
|
970
|
-
};
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
// ---------------------------------------------------------------------------
|
|
974
|
-
// Range/refline annotation label drag
|
|
975
|
-
// ---------------------------------------------------------------------------
|
|
976
|
-
|
|
977
|
-
/**
|
|
978
|
-
* Wire drag on range and refline annotation labels.
|
|
979
|
-
* On drag end, fires onEdit with the label offset.
|
|
980
|
-
* Returns a cleanup function.
|
|
981
|
-
*/
|
|
982
|
-
function wireAnnotationLabelDrag(
|
|
983
|
-
svg: SVGElement,
|
|
984
|
-
specAnnotations: Annotation[],
|
|
985
|
-
onEdit: (edit: ElementEdit) => void,
|
|
986
|
-
setDragging: (dragging: boolean) => void,
|
|
987
|
-
): () => void {
|
|
988
|
-
const cleanups: Array<() => void> = [];
|
|
989
|
-
|
|
990
|
-
// Target range and refline annotation labels
|
|
991
|
-
const selectors = [
|
|
992
|
-
'.oc-annotation-range .oc-annotation-label',
|
|
993
|
-
'.oc-annotation-refline .oc-annotation-label',
|
|
994
|
-
];
|
|
995
|
-
|
|
996
|
-
for (const selector of selectors) {
|
|
997
|
-
const labels = svg.querySelectorAll(selector);
|
|
998
|
-
|
|
999
|
-
for (const label of labels) {
|
|
1000
|
-
const annotationG = label.closest('.oc-annotation') as SVGGElement | null;
|
|
1001
|
-
if (!annotationG) continue;
|
|
1002
|
-
|
|
1003
|
-
const indexStr = annotationG.getAttribute('data-annotation-index');
|
|
1004
|
-
if (indexStr === null) continue;
|
|
1005
|
-
|
|
1006
|
-
const index = Number(indexStr);
|
|
1007
|
-
const specAnnotation = specAnnotations[index];
|
|
1008
|
-
if (!specAnnotation) continue;
|
|
1009
|
-
|
|
1010
|
-
const labelEl = label as SVGTextElement;
|
|
1011
|
-
labelEl.style.cursor = 'grab';
|
|
1012
|
-
|
|
1013
|
-
const isRange = specAnnotation.type === 'range';
|
|
1014
|
-
const existingLabelOffset = isRange
|
|
1015
|
-
? (specAnnotation as RangeAnnotation).labelOffset
|
|
1016
|
-
: (specAnnotation as RefLineAnnotation).labelOffset;
|
|
1017
|
-
const origLabelDx = existingLabelOffset?.dx ?? 0;
|
|
1018
|
-
const origLabelDy = existingLabelOffset?.dy ?? 0;
|
|
1019
|
-
|
|
1020
|
-
const cleanup = createDragHandler({
|
|
1021
|
-
element: labelEl,
|
|
1022
|
-
svg: svg as unknown as SVGSVGElement,
|
|
1023
|
-
onMove: (dx, dy) => {
|
|
1024
|
-
(labelEl as SVGElement & ElementCSSInlineStyle).style.transform =
|
|
1025
|
-
`translate(${dx}px, ${dy}px)`;
|
|
1026
|
-
},
|
|
1027
|
-
onEnd: (dx, dy, moved) => {
|
|
1028
|
-
(labelEl as SVGElement & ElementCSSInlineStyle).style.transform = '';
|
|
1029
|
-
|
|
1030
|
-
if (moved) {
|
|
1031
|
-
if (isRange) {
|
|
1032
|
-
onEdit({
|
|
1033
|
-
type: 'range-label',
|
|
1034
|
-
annotation: specAnnotation as RangeAnnotation,
|
|
1035
|
-
labelOffset: { dx: origLabelDx + dx, dy: origLabelDy + dy },
|
|
1036
|
-
});
|
|
1037
|
-
} else {
|
|
1038
|
-
onEdit({
|
|
1039
|
-
type: 'refline-label',
|
|
1040
|
-
annotation: specAnnotation as RefLineAnnotation,
|
|
1041
|
-
labelOffset: { dx: origLabelDx + dx, dy: origLabelDy + dy },
|
|
1042
|
-
});
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
},
|
|
1046
|
-
setDragging,
|
|
1047
|
-
});
|
|
1048
|
-
|
|
1049
|
-
cleanups.push(cleanup);
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
return () => {
|
|
1054
|
-
for (const cleanup of cleanups) {
|
|
1055
|
-
cleanup();
|
|
1056
|
-
}
|
|
1057
|
-
};
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// ---------------------------------------------------------------------------
|
|
1061
|
-
// Chrome text drag
|
|
1062
|
-
// ---------------------------------------------------------------------------
|
|
1063
|
-
|
|
1064
|
-
/**
|
|
1065
|
-
* Wire drag on chrome text elements (title, subtitle, source, byline, footer).
|
|
1066
|
-
* On drag end, fires onEdit with the chrome key, text, and offset.
|
|
1067
|
-
* Returns a cleanup function.
|
|
1068
|
-
*/
|
|
1069
|
-
function wireChromeDrag(
|
|
1070
|
-
svg: SVGElement,
|
|
1071
|
-
spec: ChartSpec | GraphSpec,
|
|
1072
|
-
onEdit: (edit: ElementEdit) => void,
|
|
1073
|
-
setDragging: (dragging: boolean) => void,
|
|
1074
|
-
): () => void {
|
|
1075
|
-
const chromeTexts = svg.querySelectorAll('.oc-chrome text[data-chrome-key]');
|
|
1076
|
-
const cleanups: Array<() => void> = [];
|
|
1077
|
-
|
|
1078
|
-
// Read existing chrome offsets from the spec
|
|
1079
|
-
const chromeConfig = 'chrome' in spec ? spec.chrome : undefined;
|
|
1080
|
-
|
|
1081
|
-
for (const el of chromeTexts) {
|
|
1082
|
-
const textEl = el as SVGTextElement;
|
|
1083
|
-
const key = textEl.getAttribute('data-chrome-key') as ChromeKey;
|
|
1084
|
-
if (!key) continue;
|
|
1085
|
-
|
|
1086
|
-
// Read existing offset for this chrome element
|
|
1087
|
-
const chromeEntry = chromeConfig?.[key];
|
|
1088
|
-
const existingOffset =
|
|
1089
|
-
typeof chromeEntry === 'object' && chromeEntry !== null ? chromeEntry.offset : undefined;
|
|
1090
|
-
const origChromeDx = existingOffset?.dx ?? 0;
|
|
1091
|
-
const origChromeDy = existingOffset?.dy ?? 0;
|
|
1092
|
-
|
|
1093
|
-
textEl.style.cursor = 'grab';
|
|
1094
|
-
|
|
1095
|
-
const cleanup = createDragHandler({
|
|
1096
|
-
element: textEl,
|
|
1097
|
-
svg: svg as unknown as SVGSVGElement,
|
|
1098
|
-
onMove: (dx, dy) => {
|
|
1099
|
-
(textEl as SVGElement & ElementCSSInlineStyle).style.transform =
|
|
1100
|
-
`translate(${dx}px, ${dy}px)`;
|
|
1101
|
-
},
|
|
1102
|
-
onEnd: (dx, dy, moved) => {
|
|
1103
|
-
(textEl as SVGElement & ElementCSSInlineStyle).style.transform = '';
|
|
1104
|
-
|
|
1105
|
-
if (moved) {
|
|
1106
|
-
onEdit({
|
|
1107
|
-
type: 'chrome',
|
|
1108
|
-
key,
|
|
1109
|
-
text: textEl.textContent ?? '',
|
|
1110
|
-
offset: { dx: origChromeDx + dx, dy: origChromeDy + dy },
|
|
1111
|
-
});
|
|
1112
|
-
}
|
|
1113
|
-
},
|
|
1114
|
-
setDragging,
|
|
1115
|
-
});
|
|
1116
|
-
|
|
1117
|
-
cleanups.push(cleanup);
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
return () => {
|
|
1121
|
-
for (const cleanup of cleanups) {
|
|
1122
|
-
cleanup();
|
|
1123
|
-
}
|
|
1124
|
-
};
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
// ---------------------------------------------------------------------------
|
|
1128
|
-
// Legend drag
|
|
1129
|
-
// ---------------------------------------------------------------------------
|
|
1130
|
-
|
|
1131
|
-
/**
|
|
1132
|
-
* Wire drag on the legend group.
|
|
1133
|
-
* Click suppression prevents legend toggle from firing after a drag.
|
|
1134
|
-
* On drag end, fires onEdit with the legend offset.
|
|
1135
|
-
* Returns a cleanup function.
|
|
1136
|
-
*/
|
|
1137
|
-
function wireLegendDrag(
|
|
1138
|
-
svg: SVGElement,
|
|
1139
|
-
spec: ChartSpec | GraphSpec,
|
|
1140
|
-
onEdit: (edit: ElementEdit) => void,
|
|
1141
|
-
setDragging: (dragging: boolean) => void,
|
|
1142
|
-
): () => void {
|
|
1143
|
-
const legendG = svg.querySelector('.oc-legend') as SVGGElement | null;
|
|
1144
|
-
if (!legendG) return () => {};
|
|
1145
|
-
|
|
1146
|
-
const cleanups: Array<() => void> = [];
|
|
1147
|
-
|
|
1148
|
-
// Read existing legend offset from the spec
|
|
1149
|
-
const legendConfig = 'legend' in spec ? spec.legend : undefined;
|
|
1150
|
-
const origLegendDx = legendConfig?.offset?.dx ?? 0;
|
|
1151
|
-
const origLegendDy = legendConfig?.offset?.dy ?? 0;
|
|
1152
|
-
|
|
1153
|
-
// Set grab cursor on the legend background, not on entry elements
|
|
1154
|
-
legendG.style.cursor = 'grab';
|
|
1155
|
-
|
|
1156
|
-
const cleanup = createDragHandler({
|
|
1157
|
-
element: legendG,
|
|
1158
|
-
svg: svg as unknown as SVGSVGElement,
|
|
1159
|
-
onMove: (dx, dy) => {
|
|
1160
|
-
(legendG as SVGElement & ElementCSSInlineStyle).style.transform =
|
|
1161
|
-
`translate(${dx}px, ${dy}px)`;
|
|
1162
|
-
},
|
|
1163
|
-
onEnd: (dx, dy, moved) => {
|
|
1164
|
-
(legendG as SVGElement & ElementCSSInlineStyle).style.transform = '';
|
|
1165
|
-
|
|
1166
|
-
if (moved) {
|
|
1167
|
-
onEdit({ type: 'legend', offset: { dx: origLegendDx + dx, dy: origLegendDy + dy } });
|
|
1168
|
-
}
|
|
1169
|
-
},
|
|
1170
|
-
setDragging,
|
|
1171
|
-
});
|
|
1172
|
-
|
|
1173
|
-
cleanups.push(cleanup);
|
|
1174
|
-
|
|
1175
|
-
return () => {
|
|
1176
|
-
for (const cleanup of cleanups) {
|
|
1177
|
-
cleanup();
|
|
1178
|
-
}
|
|
1179
|
-
};
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
// ---------------------------------------------------------------------------
|
|
1183
|
-
// Series label drag
|
|
1184
|
-
// ---------------------------------------------------------------------------
|
|
1185
|
-
|
|
1186
|
-
/**
|
|
1187
|
-
* Wire drag on series label elements (.oc-mark-label[data-series]).
|
|
1188
|
-
* On drag end, fires onEdit with the series name and offset.
|
|
1189
|
-
* Returns a cleanup function.
|
|
1190
|
-
*/
|
|
1191
|
-
function wireSeriesLabelDrag(
|
|
1192
|
-
svg: SVGElement,
|
|
1193
|
-
spec: ChartSpec | GraphSpec,
|
|
1194
|
-
onEdit: (edit: ElementEdit) => void,
|
|
1195
|
-
setDragging: (dragging: boolean) => void,
|
|
1196
|
-
): () => void {
|
|
1197
|
-
const labels = svg.querySelectorAll('.oc-mark-label');
|
|
1198
|
-
const cleanups: Array<() => void> = [];
|
|
1199
|
-
|
|
1200
|
-
// Read existing label offsets from the spec (skip boolean shorthand)
|
|
1201
|
-
const rawLabels = 'labels' in spec ? spec.labels : undefined;
|
|
1202
|
-
const labelsConfig = typeof rawLabels === 'object' ? rawLabels : undefined;
|
|
1203
|
-
|
|
1204
|
-
for (const label of labels) {
|
|
1205
|
-
const labelEl = label as SVGTextElement;
|
|
1206
|
-
// Check label itself first, then fall back to the parent mark group's data-series
|
|
1207
|
-
const series =
|
|
1208
|
-
labelEl.getAttribute('data-series') ??
|
|
1209
|
-
labelEl.closest('[data-series]')?.getAttribute('data-series');
|
|
1210
|
-
if (!series) continue;
|
|
1211
|
-
|
|
1212
|
-
// Read existing offset for this series label
|
|
1213
|
-
const existingSeriesOffset = labelsConfig?.offsets?.[series];
|
|
1214
|
-
const origSeriesDx = existingSeriesOffset?.dx ?? 0;
|
|
1215
|
-
const origSeriesDy = existingSeriesOffset?.dy ?? 0;
|
|
1216
|
-
|
|
1217
|
-
labelEl.style.cursor = 'grab';
|
|
1218
|
-
|
|
1219
|
-
const cleanup = createDragHandler({
|
|
1220
|
-
element: labelEl,
|
|
1221
|
-
svg: svg as unknown as SVGSVGElement,
|
|
1222
|
-
onMove: (dx, dy) => {
|
|
1223
|
-
(labelEl as SVGElement & ElementCSSInlineStyle).style.transform =
|
|
1224
|
-
`translate(${dx}px, ${dy}px)`;
|
|
1225
|
-
},
|
|
1226
|
-
onEnd: (dx, dy, moved) => {
|
|
1227
|
-
(labelEl as SVGElement & ElementCSSInlineStyle).style.transform = '';
|
|
1228
|
-
|
|
1229
|
-
if (moved) {
|
|
1230
|
-
onEdit({
|
|
1231
|
-
type: 'series-label',
|
|
1232
|
-
series,
|
|
1233
|
-
offset: { dx: origSeriesDx + dx, dy: origSeriesDy + dy },
|
|
1234
|
-
});
|
|
1235
|
-
}
|
|
1236
|
-
},
|
|
1237
|
-
setDragging,
|
|
1238
|
-
});
|
|
1239
|
-
|
|
1240
|
-
cleanups.push(cleanup);
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
return () => {
|
|
1244
|
-
for (const cleanup of cleanups) {
|
|
1245
|
-
cleanup();
|
|
1246
|
-
}
|
|
1247
|
-
};
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
// ---------------------------------------------------------------------------
|
|
1251
|
-
// Legend interactivity
|
|
1252
|
-
// ---------------------------------------------------------------------------
|
|
1253
|
-
|
|
1254
|
-
/**
|
|
1255
|
-
* Wire click handlers on legend entries to toggle series visibility.
|
|
1256
|
-
* Fires onEdit with { type: 'legend-toggle', series, hidden } for each toggle,
|
|
1257
|
-
* and optionally calls the legacy onLegendToggle callback.
|
|
1258
|
-
* Legend entries for hidden series stay visible but dimmed (opacity 0.3).
|
|
1259
|
-
* Returns a cleanup function.
|
|
1260
|
-
*/
|
|
1261
|
-
function wireLegendInteraction(
|
|
1262
|
-
svg: SVGElement,
|
|
1263
|
-
_layout: ChartLayout,
|
|
1264
|
-
onLegendToggle?: (series: string, visible: boolean) => void,
|
|
1265
|
-
onEdit?: (edit: ElementEdit) => void,
|
|
1266
|
-
): () => void {
|
|
1267
|
-
const legendEntries = svg.querySelectorAll('[data-legend-index]');
|
|
1268
|
-
const cleanups: Array<() => void> = [];
|
|
1269
|
-
|
|
1270
|
-
// Track which series are hidden
|
|
1271
|
-
const hiddenSeries = new Set<string>();
|
|
1272
|
-
|
|
1273
|
-
for (const entry of legendEntries) {
|
|
1274
|
-
// Skip overflow indicator entries ("+N more")
|
|
1275
|
-
if (entry.getAttribute('data-legend-overflow') === 'true') continue;
|
|
1276
|
-
|
|
1277
|
-
const handleClick = () => {
|
|
1278
|
-
const label = entry.getAttribute('data-legend-label');
|
|
1279
|
-
if (!label) return;
|
|
1280
|
-
|
|
1281
|
-
if (hiddenSeries.has(label)) {
|
|
1282
|
-
hiddenSeries.delete(label);
|
|
1283
|
-
entry.setAttribute('opacity', '1');
|
|
1284
|
-
entry.setAttribute('aria-label', `${label}: visible`);
|
|
1285
|
-
onLegendToggle?.(label, true);
|
|
1286
|
-
onEdit?.({ type: 'legend-toggle', series: label, hidden: false });
|
|
1287
|
-
} else {
|
|
1288
|
-
hiddenSeries.add(label);
|
|
1289
|
-
entry.setAttribute('opacity', '0.3');
|
|
1290
|
-
entry.setAttribute('aria-label', `${label}: hidden`);
|
|
1291
|
-
onLegendToggle?.(label, false);
|
|
1292
|
-
onEdit?.({ type: 'legend-toggle', series: label, hidden: true });
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
// Toggle visibility of marks with matching series.
|
|
1296
|
-
// Uses the data-series attribute set by the SVG renderer, which works
|
|
1297
|
-
// for all mark types (line, area, rect, arc, point).
|
|
1298
|
-
const marks = svg.querySelectorAll('.oc-mark');
|
|
1299
|
-
for (const mark of marks) {
|
|
1300
|
-
const seriesName = mark.getAttribute('data-series');
|
|
1301
|
-
if (!seriesName) continue;
|
|
1302
|
-
|
|
1303
|
-
if (hiddenSeries.has(seriesName)) {
|
|
1304
|
-
(mark as SVGElement).style.display = 'none';
|
|
1305
|
-
} else {
|
|
1306
|
-
(mark as SVGElement).style.display = '';
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
};
|
|
1310
|
-
|
|
1311
|
-
entry.addEventListener('click', handleClick);
|
|
1312
|
-
cleanups.push(() => entry.removeEventListener('click', handleClick));
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
return () => {
|
|
1316
|
-
for (const cleanup of cleanups) {
|
|
1317
|
-
cleanup();
|
|
1318
|
-
}
|
|
1319
|
-
};
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
// ---------------------------------------------------------------------------
|
|
1323
|
-
// Keyboard navigation
|
|
1324
|
-
// ---------------------------------------------------------------------------
|
|
1325
|
-
|
|
1326
|
-
/**
|
|
1327
|
-
* Wire keyboard navigation on the SVG element.
|
|
1328
|
-
* Arrow keys move focus between mark elements. Enter/Space shows tooltip.
|
|
1329
|
-
* Escape hides tooltip. Returns a cleanup function.
|
|
1330
|
-
*/
|
|
1331
|
-
function wireKeyboardNav(
|
|
1332
|
-
svg: SVGElement,
|
|
1333
|
-
container: HTMLElement,
|
|
1334
|
-
tooltipDescriptors: Map<string, TooltipContent>,
|
|
1335
|
-
tooltipManager: TooltipManager,
|
|
1336
|
-
layout: ChartLayout,
|
|
1337
|
-
): () => void {
|
|
1338
|
-
// Make container focusable
|
|
1339
|
-
container.setAttribute('tabindex', '0');
|
|
1340
|
-
container.setAttribute('aria-roledescription', 'chart');
|
|
1341
|
-
container.setAttribute('aria-label', layout.a11y.altText);
|
|
1342
|
-
|
|
1343
|
-
// Collect navigable mark elements (those with tooltip content)
|
|
1344
|
-
const markElements: SVGElement[] = [];
|
|
1345
|
-
const allMarkEls = svg.querySelectorAll('[data-mark-id]');
|
|
1346
|
-
for (const el of allMarkEls) {
|
|
1347
|
-
const markId = el.getAttribute('data-mark-id');
|
|
1348
|
-
if (markId && tooltipDescriptors.has(markId)) {
|
|
1349
|
-
markElements.push(el as SVGElement);
|
|
1350
|
-
}
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
let focusIndex = -1;
|
|
1354
|
-
|
|
1355
|
-
function highlightMark(index: number): void {
|
|
1356
|
-
// Remove previous highlight
|
|
1357
|
-
if (focusIndex >= 0 && focusIndex < markElements.length) {
|
|
1358
|
-
markElements[focusIndex].classList.remove('oc-mark-focused');
|
|
1359
|
-
markElements[focusIndex].removeAttribute('aria-selected');
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
focusIndex = index;
|
|
1363
|
-
|
|
1364
|
-
if (focusIndex >= 0 && focusIndex < markElements.length) {
|
|
1365
|
-
const el = markElements[focusIndex];
|
|
1366
|
-
el.classList.add('oc-mark-focused');
|
|
1367
|
-
el.setAttribute('aria-selected', 'true');
|
|
1368
|
-
}
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
function showTooltipForFocused(): void {
|
|
1372
|
-
if (focusIndex < 0 || focusIndex >= markElements.length) return;
|
|
1373
|
-
|
|
1374
|
-
const el = markElements[focusIndex];
|
|
1375
|
-
const markId = el.getAttribute('data-mark-id');
|
|
1376
|
-
if (!markId) return;
|
|
1377
|
-
|
|
1378
|
-
const content = tooltipDescriptors.get(markId);
|
|
1379
|
-
if (!content) return;
|
|
1380
|
-
|
|
1381
|
-
// Position tooltip near the mark element
|
|
1382
|
-
const bbox = el.getBoundingClientRect();
|
|
1383
|
-
const containerRect = container.getBoundingClientRect();
|
|
1384
|
-
const x = bbox.left + bbox.width / 2 - containerRect.left;
|
|
1385
|
-
const y = bbox.top - containerRect.top;
|
|
1386
|
-
tooltipManager.show(content, x, y);
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
1390
|
-
if (markElements.length === 0) return;
|
|
1391
|
-
|
|
1392
|
-
switch (e.key) {
|
|
1393
|
-
case 'ArrowRight':
|
|
1394
|
-
case 'ArrowDown': {
|
|
1395
|
-
e.preventDefault();
|
|
1396
|
-
const next = focusIndex < markElements.length - 1 ? focusIndex + 1 : 0;
|
|
1397
|
-
highlightMark(next);
|
|
1398
|
-
showTooltipForFocused();
|
|
1399
|
-
break;
|
|
1400
|
-
}
|
|
1401
|
-
case 'ArrowLeft':
|
|
1402
|
-
case 'ArrowUp': {
|
|
1403
|
-
e.preventDefault();
|
|
1404
|
-
const prev = focusIndex > 0 ? focusIndex - 1 : markElements.length - 1;
|
|
1405
|
-
highlightMark(prev);
|
|
1406
|
-
showTooltipForFocused();
|
|
1407
|
-
break;
|
|
1408
|
-
}
|
|
1409
|
-
case 'Enter':
|
|
1410
|
-
case ' ': {
|
|
1411
|
-
e.preventDefault();
|
|
1412
|
-
if (focusIndex >= 0) {
|
|
1413
|
-
showTooltipForFocused();
|
|
1414
|
-
} else if (markElements.length > 0) {
|
|
1415
|
-
highlightMark(0);
|
|
1416
|
-
showTooltipForFocused();
|
|
1417
|
-
}
|
|
1418
|
-
break;
|
|
1419
|
-
}
|
|
1420
|
-
case 'Escape': {
|
|
1421
|
-
e.preventDefault();
|
|
1422
|
-
tooltipManager.hide();
|
|
1423
|
-
highlightMark(-1);
|
|
1424
|
-
break;
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
};
|
|
1428
|
-
|
|
1429
|
-
container.addEventListener('keydown', handleKeyDown);
|
|
1430
|
-
|
|
1431
|
-
return () => {
|
|
1432
|
-
container.removeEventListener('keydown', handleKeyDown);
|
|
1433
|
-
container.removeAttribute('tabindex');
|
|
1434
|
-
container.removeAttribute('aria-roledescription');
|
|
1435
|
-
container.removeAttribute('aria-label');
|
|
1436
|
-
};
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
// ---------------------------------------------------------------------------
|
|
1440
|
-
// Hidden data table for screen readers
|
|
1441
|
-
// ---------------------------------------------------------------------------
|
|
1442
|
-
|
|
1443
|
-
/**
|
|
1444
|
-
* Create a visually-hidden data table from the chart's a11y fallback data.
|
|
1445
|
-
* Returns the table element (append to container) and a cleanup function.
|
|
1446
|
-
*/
|
|
1447
|
-
function createScreenReaderTable(
|
|
1448
|
-
layout: ChartLayout,
|
|
1449
|
-
container: HTMLElement,
|
|
1450
|
-
): HTMLTableElement | null {
|
|
1451
|
-
const data = layout.a11y.dataTableFallback;
|
|
1452
|
-
if (!data || data.length === 0) return null;
|
|
1453
|
-
|
|
1454
|
-
const table = document.createElement('table');
|
|
1455
|
-
table.className = 'oc-sr-only';
|
|
1456
|
-
// Inline critical SR-only styles so the table stays hidden even when the
|
|
1457
|
-
// external stylesheet isn't loaded (e.g. CDN / esm.sh usage).
|
|
1458
|
-
table.style.position = 'absolute';
|
|
1459
|
-
table.style.width = '1px';
|
|
1460
|
-
table.style.height = '1px';
|
|
1461
|
-
table.style.padding = '0';
|
|
1462
|
-
table.style.margin = '-1px';
|
|
1463
|
-
table.style.overflow = 'hidden';
|
|
1464
|
-
table.style.clipPath = 'inset(50%)';
|
|
1465
|
-
table.style.whiteSpace = 'nowrap';
|
|
1466
|
-
table.style.borderWidth = '0';
|
|
1467
|
-
table.setAttribute('role', 'table');
|
|
1468
|
-
table.setAttribute('aria-label', `Data table: ${layout.a11y.altText}`);
|
|
1469
|
-
|
|
1470
|
-
// First row is headers
|
|
1471
|
-
if (data.length > 0) {
|
|
1472
|
-
const thead = document.createElement('thead');
|
|
1473
|
-
const headerRow = document.createElement('tr');
|
|
1474
|
-
const headers = data[0] as unknown[];
|
|
1475
|
-
for (const header of headers) {
|
|
1476
|
-
const th = document.createElement('th');
|
|
1477
|
-
th.textContent = String(header ?? '');
|
|
1478
|
-
th.setAttribute('scope', 'col');
|
|
1479
|
-
headerRow.appendChild(th);
|
|
1480
|
-
}
|
|
1481
|
-
thead.appendChild(headerRow);
|
|
1482
|
-
table.appendChild(thead);
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
// Remaining rows are data
|
|
1486
|
-
if (data.length > 1) {
|
|
1487
|
-
const tbody = document.createElement('tbody');
|
|
1488
|
-
for (let i = 1; i < data.length; i++) {
|
|
1489
|
-
const tr = document.createElement('tr');
|
|
1490
|
-
const cells = data[i] as unknown[];
|
|
1491
|
-
for (const cell of cells) {
|
|
1492
|
-
const td = document.createElement('td');
|
|
1493
|
-
td.textContent = String(cell ?? '');
|
|
1494
|
-
tr.appendChild(td);
|
|
1495
|
-
}
|
|
1496
|
-
tbody.appendChild(tr);
|
|
1497
|
-
}
|
|
1498
|
-
table.appendChild(tbody);
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
container.appendChild(table);
|
|
1502
|
-
return table;
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
132
|
// ---------------------------------------------------------------------------
|
|
1506
133
|
// Editable element helpers
|
|
1507
134
|
// ---------------------------------------------------------------------------
|
|
1508
135
|
|
|
1509
|
-
/** CSS for editable hover feedback, injected into the SVG as a <style> element. */
|
|
1510
136
|
const EDITABLE_HOVER_CSS = `
|
|
1511
137
|
.oc-editable-hover {
|
|
1512
138
|
outline: 1.5px solid rgba(79, 70, 229, 0.35);
|
|
@@ -1515,257 +141,32 @@ const EDITABLE_HOVER_CSS = `
|
|
|
1515
141
|
}
|
|
1516
142
|
`;
|
|
1517
143
|
|
|
1518
|
-
/**
|
|
1519
|
-
* Inject editable styles into an SVG element and make it focusable.
|
|
1520
|
-
* Called when any editing callback is provided.
|
|
1521
|
-
*/
|
|
1522
144
|
function makeEditable(svg: SVGElement): void {
|
|
1523
145
|
svg.setAttribute('tabindex', '0');
|
|
1524
146
|
svg.style.outline = 'none';
|
|
1525
147
|
|
|
1526
|
-
// Inject hover style into SVG defs
|
|
1527
148
|
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
|
1528
149
|
style.textContent = EDITABLE_HOVER_CSS;
|
|
1529
150
|
svg.insertBefore(style, svg.firstChild);
|
|
1530
151
|
}
|
|
1531
152
|
|
|
1532
|
-
/**
|
|
1533
|
-
* Check whether any editing-related callback is provided in the options.
|
|
1534
|
-
*/
|
|
1535
153
|
function hasEditingCallbacks(opts?: MountOptions): boolean {
|
|
1536
154
|
return !!(opts?.onEdit || opts?.onSelect || opts?.onDeselect || opts?.onTextEdit);
|
|
1537
155
|
}
|
|
1538
156
|
|
|
1539
|
-
/**
|
|
1540
|
-
* Find a DOM element inside the SVG that matches the given ElementRef.
|
|
1541
|
-
*/
|
|
1542
|
-
function findElementByRef(svg: SVGElement, ref: ElementRef): SVGElement | null {
|
|
1543
|
-
switch (ref.type) {
|
|
1544
|
-
case 'annotation': {
|
|
1545
|
-
// Prefer id-based lookup when available
|
|
1546
|
-
if (ref.id) {
|
|
1547
|
-
const byId = svg.querySelector(`[data-annotation-id="${ref.id}"]`);
|
|
1548
|
-
if (byId) return byId as SVGElement;
|
|
1549
|
-
}
|
|
1550
|
-
return svg.querySelector(`[data-annotation-index="${ref.index}"]`) as SVGElement | null;
|
|
1551
|
-
}
|
|
1552
|
-
case 'chrome':
|
|
1553
|
-
return svg.querySelector(`[data-chrome-key="${ref.key}"]`) as SVGElement | null;
|
|
1554
|
-
case 'series-label':
|
|
1555
|
-
return svg.querySelector(`.oc-mark-label[data-series="${ref.series}"]`) as SVGElement | null;
|
|
1556
|
-
case 'legend':
|
|
1557
|
-
return svg.querySelector('.oc-legend') as SVGElement | null;
|
|
1558
|
-
case 'legend-entry':
|
|
1559
|
-
return svg.querySelector(`[data-legend-index="${ref.index}"]`) as SVGElement | null;
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
/**
|
|
1564
|
-
* Build an ElementRef from a DOM element's data attributes.
|
|
1565
|
-
* Walks up the tree to find the closest editable ancestor if needed.
|
|
1566
|
-
*/
|
|
1567
|
-
function buildElementRef(element: Element, _specAnnotations: Annotation[]): ElementRef | null {
|
|
1568
|
-
// Check for annotation
|
|
1569
|
-
const annotationEl = element.closest('[data-annotation-index]');
|
|
1570
|
-
if (annotationEl) {
|
|
1571
|
-
const index = Number(annotationEl.getAttribute('data-annotation-index'));
|
|
1572
|
-
const id = annotationEl.getAttribute('data-annotation-id') ?? undefined;
|
|
1573
|
-
return elementRef.annotation(index, id);
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
// Check for chrome
|
|
1577
|
-
const chromeEl = element.closest('[data-chrome-key]');
|
|
1578
|
-
if (chromeEl) {
|
|
1579
|
-
const key = chromeEl.getAttribute('data-chrome-key') as ChromeKey;
|
|
1580
|
-
if (key) return elementRef.chrome(key);
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
// Check for series label
|
|
1584
|
-
const seriesLabelEl = element.closest('.oc-mark-label[data-series]');
|
|
1585
|
-
if (seriesLabelEl) {
|
|
1586
|
-
const series = seriesLabelEl.getAttribute('data-series');
|
|
1587
|
-
if (series) return elementRef.seriesLabel(series);
|
|
1588
|
-
}
|
|
1589
|
-
|
|
1590
|
-
// Check for legend entry
|
|
1591
|
-
const legendEntryEl = element.closest('[data-legend-index]');
|
|
1592
|
-
if (legendEntryEl) {
|
|
1593
|
-
const index = Number(legendEntryEl.getAttribute('data-legend-index'));
|
|
1594
|
-
const series = legendEntryEl.getAttribute('data-legend-label') ?? '';
|
|
1595
|
-
return elementRef.legendEntry(series, index);
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
// Check for legend group
|
|
1599
|
-
const legendEl = element.closest('.oc-legend');
|
|
1600
|
-
if (legendEl) return elementRef.legend();
|
|
1601
|
-
|
|
1602
|
-
return null;
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
/**
|
|
1606
|
-
* Get an ordered list of all editable ElementRefs from the current spec and layout.
|
|
1607
|
-
* Order: chrome (title, subtitle, source, byline, footer), annotations by index,
|
|
1608
|
-
* series labels alphabetical, legend.
|
|
1609
|
-
*/
|
|
1610
|
-
function getEditableElements(
|
|
1611
|
-
spec: ChartSpec | LayerSpec | GraphSpec,
|
|
1612
|
-
layout: ChartLayout,
|
|
1613
|
-
): ElementRef[] {
|
|
1614
|
-
const refs: ElementRef[] = [];
|
|
1615
|
-
|
|
1616
|
-
// Chrome keys in display order
|
|
1617
|
-
const chromeKeys: ChromeKey[] = ['title', 'subtitle', 'source', 'byline', 'footer'];
|
|
1618
|
-
for (const key of chromeKeys) {
|
|
1619
|
-
if (layout.chrome[key]) {
|
|
1620
|
-
refs.push(elementRef.chrome(key));
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
|
|
1624
|
-
// Annotations by index
|
|
1625
|
-
const annotations: Annotation[] =
|
|
1626
|
-
'annotations' in spec && Array.isArray(spec.annotations) ? spec.annotations : [];
|
|
1627
|
-
for (let i = 0; i < annotations.length; i++) {
|
|
1628
|
-
refs.push(elementRef.annotation(i, annotations[i].id));
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
// Series labels (alphabetical)
|
|
1632
|
-
const seriesLabels: string[] = [];
|
|
1633
|
-
for (const mark of layout.marks) {
|
|
1634
|
-
if (mark.type === 'line' && mark.label?.visible && mark.seriesKey) {
|
|
1635
|
-
seriesLabels.push(mark.seriesKey);
|
|
1636
|
-
}
|
|
1637
|
-
}
|
|
1638
|
-
seriesLabels.sort();
|
|
1639
|
-
for (const series of seriesLabels) {
|
|
1640
|
-
refs.push(elementRef.seriesLabel(series));
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
// Legend
|
|
1644
|
-
if ('entries' in layout.legend && layout.legend.entries.length > 0) {
|
|
1645
|
-
refs.push(elementRef.legend());
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
return refs;
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
/**
|
|
1652
|
-
* Check if an ElementRef points to a text-editable element (chrome text or text annotation).
|
|
1653
|
-
*/
|
|
1654
|
-
function isTextEditable(ref: ElementRef, specAnnotations: Annotation[]): boolean {
|
|
1655
|
-
if (ref.type === 'chrome') return true;
|
|
1656
|
-
if (ref.type === 'annotation') {
|
|
1657
|
-
const annotation = specAnnotations[ref.index];
|
|
1658
|
-
return annotation?.type === 'text';
|
|
1659
|
-
}
|
|
1660
|
-
return false;
|
|
1661
|
-
}
|
|
1662
|
-
|
|
1663
|
-
/**
|
|
1664
|
-
* Get the current text content for an element ref.
|
|
1665
|
-
*/
|
|
1666
|
-
function getElementText(ref: ElementRef, spec: ChartSpec | LayerSpec | GraphSpec): string | null {
|
|
1667
|
-
if (ref.type === 'chrome') {
|
|
1668
|
-
const chromeConfig = 'chrome' in spec ? spec.chrome : undefined;
|
|
1669
|
-
if (!chromeConfig) return null;
|
|
1670
|
-
const entry = chromeConfig[ref.key];
|
|
1671
|
-
if (typeof entry === 'string') return entry;
|
|
1672
|
-
if (typeof entry === 'object' && entry !== null && 'text' in entry) {
|
|
1673
|
-
return (entry as { text: string }).text;
|
|
1674
|
-
}
|
|
1675
|
-
return null;
|
|
1676
|
-
}
|
|
1677
|
-
if (ref.type === 'annotation') {
|
|
1678
|
-
const annotations: Annotation[] =
|
|
1679
|
-
'annotations' in spec && Array.isArray(spec.annotations) ? spec.annotations : [];
|
|
1680
|
-
const annotation = annotations[ref.index];
|
|
1681
|
-
if (annotation?.type === 'text') return (annotation as TextAnnotation).text ?? null;
|
|
1682
|
-
if (annotation?.label) return annotation.label;
|
|
1683
|
-
return null;
|
|
1684
|
-
}
|
|
1685
|
-
return null;
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
/**
|
|
1689
|
-
* Compare two ElementRefs for equality.
|
|
1690
|
-
*/
|
|
1691
|
-
function refsEqual(a: ElementRef | null, b: ElementRef | null): boolean {
|
|
1692
|
-
if (a === null || b === null) return a === b;
|
|
1693
|
-
if (a.type !== b.type) return false;
|
|
1694
|
-
switch (a.type) {
|
|
1695
|
-
case 'annotation': {
|
|
1696
|
-
const bAnno = b as typeof a;
|
|
1697
|
-
if (a.id && bAnno.id) return a.id === bAnno.id;
|
|
1698
|
-
return a.index === bAnno.index;
|
|
1699
|
-
}
|
|
1700
|
-
case 'chrome':
|
|
1701
|
-
return a.key === (b as typeof a).key;
|
|
1702
|
-
case 'series-label':
|
|
1703
|
-
return a.series === (b as typeof a).series;
|
|
1704
|
-
case 'legend':
|
|
1705
|
-
return true;
|
|
1706
|
-
case 'legend-entry': {
|
|
1707
|
-
const bEntry = b as typeof a;
|
|
1708
|
-
return a.index === bEntry.index && a.series === bEntry.series;
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
/**
|
|
1714
|
-
* Render a selection overlay rectangle around a target element.
|
|
1715
|
-
* Returns the overlay group element.
|
|
1716
|
-
*/
|
|
1717
|
-
function renderSelectionOverlay(
|
|
1718
|
-
svg: SVGElement,
|
|
1719
|
-
ref: ElementRef,
|
|
1720
|
-
layout: ChartLayout,
|
|
1721
|
-
): SVGGElement | null {
|
|
1722
|
-
const target = findElementByRef(svg, ref);
|
|
1723
|
-
if (!target) return null;
|
|
1724
|
-
|
|
1725
|
-
const bbox = (target as SVGGraphicsElement).getBBox();
|
|
1726
|
-
const padding = 4;
|
|
1727
|
-
|
|
1728
|
-
// Resolve accent color from theme
|
|
1729
|
-
const accentColor = layout.theme.colors.categorical?.[0] ?? '#4f46e5';
|
|
1730
|
-
|
|
1731
|
-
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
1732
|
-
g.setAttribute('class', 'oc-selection-overlay');
|
|
1733
|
-
|
|
1734
|
-
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
1735
|
-
rect.setAttribute('x', String(bbox.x - padding));
|
|
1736
|
-
rect.setAttribute('y', String(bbox.y - padding));
|
|
1737
|
-
rect.setAttribute('width', String(bbox.width + padding * 2));
|
|
1738
|
-
rect.setAttribute('height', String(bbox.height + padding * 2));
|
|
1739
|
-
rect.setAttribute('rx', '3');
|
|
1740
|
-
rect.setAttribute('fill', 'transparent');
|
|
1741
|
-
rect.setAttribute('stroke', accentColor);
|
|
1742
|
-
rect.setAttribute('stroke-width', '1.5');
|
|
1743
|
-
rect.setAttribute('pointer-events', 'none');
|
|
1744
|
-
|
|
1745
|
-
g.appendChild(rect);
|
|
1746
|
-
svg.appendChild(g);
|
|
1747
|
-
|
|
1748
|
-
return g;
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
157
|
// ---------------------------------------------------------------------------
|
|
1752
158
|
// Main API
|
|
1753
159
|
// ---------------------------------------------------------------------------
|
|
1754
160
|
|
|
1755
161
|
/**
|
|
1756
162
|
* Create a chart instance from a spec and mount it into a container.
|
|
1757
|
-
*
|
|
1758
|
-
* @param container - The DOM element to render into.
|
|
1759
|
-
* @param spec - The visualization spec.
|
|
1760
|
-
* @param options - Mount options (theme, darkMode, responsive, etc.).
|
|
1761
|
-
* @returns A ChartInstance with update/resize/export/destroy methods.
|
|
1762
163
|
*/
|
|
1763
|
-
export function createChart(
|
|
164
|
+
export function createChart<TData extends DataRow = DataRow>(
|
|
1764
165
|
container: HTMLElement,
|
|
1765
|
-
spec: ChartSpec | LayerSpec | GraphSpec,
|
|
166
|
+
spec: ChartSpec<TData> | LayerSpec<TData> | GraphSpec,
|
|
1766
167
|
options?: MountOptions,
|
|
1767
168
|
): ChartInstance {
|
|
1768
|
-
let currentSpec: ChartSpec | LayerSpec | GraphSpec = spec;
|
|
169
|
+
let currentSpec: ChartSpec | LayerSpec | GraphSpec = spec as ChartSpec | LayerSpec | GraphSpec;
|
|
1769
170
|
let currentLayout: ChartLayout;
|
|
1770
171
|
let svgElement: SVGElement | null = null;
|
|
1771
172
|
let tooltipManager: TooltipManager | null = null;
|
|
@@ -1793,10 +194,18 @@ export function createChart(
|
|
|
1793
194
|
let selectedElement: ElementRef | null = options?.selectedElement ?? null;
|
|
1794
195
|
let overlayElement: SVGGElement | null = null;
|
|
1795
196
|
let isTextEditingActive = false;
|
|
197
|
+
|
|
198
|
+
// Runtime legend-toggle state
|
|
199
|
+
const runtimeHiddenSeries = new Set<string>();
|
|
200
|
+
const runtimeShownSeries = new Set<string>();
|
|
1796
201
|
let textEditCleanup: (() => void) | null = null;
|
|
1797
202
|
|
|
1798
203
|
const measureText = createMeasureText();
|
|
1799
204
|
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Compilation
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
1800
209
|
function compile(): ChartLayout {
|
|
1801
210
|
const { width, height } = getContainerDimensions();
|
|
1802
211
|
const darkMode = resolveDarkMode(options?.darkMode);
|
|
@@ -1811,19 +220,104 @@ export function createChart(
|
|
|
1811
220
|
};
|
|
1812
221
|
|
|
1813
222
|
if (isLayerSpec(currentSpec)) {
|
|
1814
|
-
return compileLayer(currentSpec as LayerSpec, compileOpts);
|
|
223
|
+
return compileLayer(withRuntimeHidden(currentSpec as LayerSpec) as LayerSpec, compileOpts);
|
|
224
|
+
}
|
|
225
|
+
return compileChart(withRuntimeHidden(currentSpec) as ChartSpec | GraphSpec, compileOpts);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function withRuntimeHidden<T extends { hiddenSeries?: string[]; annotations?: Annotation[] }>(
|
|
229
|
+
spec: T,
|
|
230
|
+
): T {
|
|
231
|
+
if (runtimeHiddenSeries.size === 0 && runtimeShownSeries.size === 0) return spec;
|
|
232
|
+
const userHidden = spec.hiddenSeries ?? [];
|
|
233
|
+
const finalHidden = new Set<string>(userHidden);
|
|
234
|
+
for (const s of runtimeHiddenSeries) finalHidden.add(s);
|
|
235
|
+
for (const s of runtimeShownSeries) finalHidden.delete(s);
|
|
236
|
+
const out: T = { ...spec, hiddenSeries: Array.from(finalHidden) };
|
|
237
|
+
if (finalHidden.size > 0 && spec.annotations) {
|
|
238
|
+
const filtered = spec.annotations.filter((a) => a.type !== 'text');
|
|
239
|
+
out.annotations = filtered.length > 0 ? filtered : undefined;
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Legend toggle
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
function countSeries(spec: ChartSpec | GraphSpec | LayerSpec): number {
|
|
249
|
+
if (isLayerSpec(spec)) return Infinity;
|
|
250
|
+
const enc = (spec as ChartSpec).encoding;
|
|
251
|
+
const colorEnc = enc?.color;
|
|
252
|
+
if (!colorEnc || 'condition' in colorEnc || colorEnc.type === 'quantitative') return Infinity;
|
|
253
|
+
if (!('field' in colorEnc) || !colorEnc.field) return Infinity;
|
|
254
|
+
const field = colorEnc.field;
|
|
255
|
+
const seen = new Set<string>();
|
|
256
|
+
for (const row of (spec as ChartSpec).data ?? []) {
|
|
257
|
+
seen.add(String((row as Record<string, unknown>)[field]));
|
|
258
|
+
}
|
|
259
|
+
return seen.size;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isSeriesHidden(series: string): boolean {
|
|
263
|
+
if (runtimeShownSeries.has(series)) return false;
|
|
264
|
+
if (runtimeHiddenSeries.has(series)) return true;
|
|
265
|
+
const userHidden = (currentSpec as { hiddenSeries?: string[] }).hiddenSeries ?? [];
|
|
266
|
+
return userHidden.includes(series);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function toggleSeriesVisibility(series: string): boolean {
|
|
270
|
+
const wasHidden = isSeriesHidden(series);
|
|
271
|
+
if (!wasHidden) {
|
|
272
|
+
const total = countSeries(currentSpec);
|
|
273
|
+
if (Number.isFinite(total)) {
|
|
274
|
+
const userHidden = new Set((currentSpec as { hiddenSeries?: string[] }).hiddenSeries ?? []);
|
|
275
|
+
let visibleAfter = 0;
|
|
276
|
+
const seriesField = getColorField(currentSpec);
|
|
277
|
+
if (seriesField) {
|
|
278
|
+
const allSeries = new Set<string>();
|
|
279
|
+
for (const row of (currentSpec as ChartSpec).data ?? []) {
|
|
280
|
+
allSeries.add(String((row as Record<string, unknown>)[seriesField]));
|
|
281
|
+
}
|
|
282
|
+
for (const s of allSeries) {
|
|
283
|
+
if (s === series) continue;
|
|
284
|
+
if (runtimeShownSeries.has(s)) {
|
|
285
|
+
visibleAfter++;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (runtimeHiddenSeries.has(s)) continue;
|
|
289
|
+
if (!userHidden.has(s)) visibleAfter++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (visibleAfter === 0) return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (wasHidden) {
|
|
296
|
+
runtimeHiddenSeries.delete(series);
|
|
297
|
+
const userHidden = (currentSpec as { hiddenSeries?: string[] }).hiddenSeries ?? [];
|
|
298
|
+
if (userHidden.includes(series)) runtimeShownSeries.add(series);
|
|
299
|
+
} else {
|
|
300
|
+
runtimeShownSeries.delete(series);
|
|
301
|
+
runtimeHiddenSeries.add(series);
|
|
1815
302
|
}
|
|
1816
|
-
|
|
303
|
+
render();
|
|
304
|
+
return !wasHidden;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function getColorField(spec: ChartSpec | GraphSpec | LayerSpec): string | undefined {
|
|
308
|
+
if (isLayerSpec(spec)) return undefined;
|
|
309
|
+
const colorEnc = (spec as ChartSpec).encoding?.color;
|
|
310
|
+
if (!colorEnc || 'condition' in colorEnc) return undefined;
|
|
311
|
+
if (!('field' in colorEnc) || !colorEnc.field) return undefined;
|
|
312
|
+
return colorEnc.field;
|
|
1817
313
|
}
|
|
1818
314
|
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Container dimensions
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
1819
319
|
function getContainerDimensions(): { width: number; height: number } {
|
|
1820
320
|
const rect = container.getBoundingClientRect();
|
|
1821
|
-
// Sparkline mode allows tiny containers (KPI cards, inline strips). Drop
|
|
1822
|
-
// the standard floor and, when the wrapper has auto-height (collapsed to 0
|
|
1823
|
-
// because we haven't rendered yet), measure the parent that the user sized
|
|
1824
|
-
// explicitly. Without this, a <div style={{height: 40}}><Chart/></div>
|
|
1825
|
-
// would still render at the fallback height because oc-chart-root has
|
|
1826
|
-
// width:100% but no intrinsic height.
|
|
1827
321
|
const isSparkline =
|
|
1828
322
|
'display' in currentSpec && (currentSpec as ChartSpec).display === 'sparkline';
|
|
1829
323
|
if (isSparkline) {
|
|
@@ -1845,22 +339,22 @@ export function createChart(
|
|
|
1845
339
|
};
|
|
1846
340
|
}
|
|
1847
341
|
|
|
1848
|
-
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// Selection & text editing
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
1849
346
|
function getSpecAnnotations(): Annotation[] {
|
|
1850
347
|
return 'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
|
|
1851
348
|
? currentSpec.annotations
|
|
1852
349
|
: [];
|
|
1853
350
|
}
|
|
1854
351
|
|
|
1855
|
-
/** Select an element: render overlay, fire onSelect, update state. */
|
|
1856
352
|
function selectElement(ref: ElementRef): void {
|
|
1857
353
|
if (!svgElement) return;
|
|
1858
354
|
|
|
1859
|
-
// Confirm the target element exists before deselecting the previous one
|
|
1860
355
|
const target = findElementByRef(svgElement, ref);
|
|
1861
356
|
if (!target) return;
|
|
1862
357
|
|
|
1863
|
-
// Deselect previous if different
|
|
1864
358
|
if (selectedElement && !refsEqual(selectedElement, ref)) {
|
|
1865
359
|
deselectElement();
|
|
1866
360
|
}
|
|
@@ -1869,15 +363,12 @@ export function createChart(
|
|
|
1869
363
|
overlayElement = renderSelectionOverlay(svgElement, ref, currentLayout);
|
|
1870
364
|
options?.onSelect?.(ref);
|
|
1871
365
|
|
|
1872
|
-
// Focus SVG for keyboard events
|
|
1873
366
|
(svgElement as SVGSVGElement).focus();
|
|
1874
367
|
}
|
|
1875
368
|
|
|
1876
|
-
/** Deselect the current element: remove overlay, fire onDeselect, clear state. */
|
|
1877
369
|
function deselectElement(): void {
|
|
1878
370
|
if (!selectedElement) return;
|
|
1879
371
|
|
|
1880
|
-
// Cancel text editing if active
|
|
1881
372
|
if (isTextEditingActive && textEditCleanup) {
|
|
1882
373
|
textEditCleanup();
|
|
1883
374
|
textEditCleanup = null;
|
|
@@ -1895,7 +386,6 @@ export function createChart(
|
|
|
1895
386
|
options?.onDeselect?.(prev);
|
|
1896
387
|
}
|
|
1897
388
|
|
|
1898
|
-
/** Enter text editing mode for the currently selected element. */
|
|
1899
389
|
function enterTextEditing(): void {
|
|
1900
390
|
if (!svgElement || !selectedElement || isTextEditingActive) return;
|
|
1901
391
|
|
|
@@ -1905,11 +395,9 @@ export function createChart(
|
|
|
1905
395
|
const currentText = getElementText(selectedElement, currentSpec);
|
|
1906
396
|
if (currentText === null) return;
|
|
1907
397
|
|
|
1908
|
-
// Find the text element within the selected element
|
|
1909
398
|
const target = findElementByRef(svgElement, selectedElement);
|
|
1910
399
|
if (!target) return;
|
|
1911
400
|
|
|
1912
|
-
// The target might be a group; find the actual text element
|
|
1913
401
|
const textEl = target.tagName === 'text' ? target : target.querySelector('text');
|
|
1914
402
|
if (!textEl) return;
|
|
1915
403
|
|
|
@@ -1926,7 +414,6 @@ export function createChart(
|
|
|
1926
414
|
textEditCleanup = null;
|
|
1927
415
|
|
|
1928
416
|
if (newText !== currentText) {
|
|
1929
|
-
// Fire text edit callbacks
|
|
1930
417
|
options?.onTextEdit?.(editRef, currentText, newText);
|
|
1931
418
|
options?.onEdit?.({
|
|
1932
419
|
type: 'text-edit',
|
|
@@ -1945,32 +432,24 @@ export function createChart(
|
|
|
1945
432
|
textEditCleanup = overlay.destroy;
|
|
1946
433
|
}
|
|
1947
434
|
|
|
1948
|
-
/**
|
|
1949
|
-
* Wire click-based selection events on the SVG.
|
|
1950
|
-
* Uses event delegation for efficiency.
|
|
1951
|
-
*/
|
|
1952
435
|
function wireSelectionEvents(): () => void {
|
|
1953
436
|
if (!svgElement) return () => {};
|
|
1954
437
|
|
|
1955
438
|
const svg = svgElement;
|
|
1956
439
|
const cleanups: Array<() => void> = [];
|
|
1957
440
|
|
|
1958
|
-
// Click handler for selection
|
|
1959
441
|
const handleClick = (e: Event) => {
|
|
1960
442
|
const mouseEvent = e as MouseEvent;
|
|
1961
443
|
const target = mouseEvent.target as Element;
|
|
1962
444
|
|
|
1963
|
-
// Don't interfere with text editing
|
|
1964
445
|
if (isTextEditingActive) return;
|
|
1965
446
|
|
|
1966
447
|
const specAnnotations = getSpecAnnotations();
|
|
1967
448
|
const ref = buildElementRef(target, specAnnotations);
|
|
1968
449
|
|
|
1969
450
|
if (ref) {
|
|
1970
|
-
// Clicked on an editable element
|
|
1971
451
|
selectElement(ref);
|
|
1972
452
|
} else {
|
|
1973
|
-
// Clicked on empty area / non-editable element, deselect
|
|
1974
453
|
deselectElement();
|
|
1975
454
|
}
|
|
1976
455
|
};
|
|
@@ -1978,7 +457,6 @@ export function createChart(
|
|
|
1978
457
|
svg.addEventListener('click', handleClick);
|
|
1979
458
|
cleanups.push(() => svg.removeEventListener('click', handleClick));
|
|
1980
459
|
|
|
1981
|
-
// Hover feedback on editable elements
|
|
1982
460
|
const handleMouseEnter = (e: Event) => {
|
|
1983
461
|
const target = (e.target as Element).closest(
|
|
1984
462
|
'[data-annotation-index], [data-chrome-key], .oc-mark-label[data-series], .oc-legend, [data-legend-index]',
|
|
@@ -2002,7 +480,6 @@ export function createChart(
|
|
|
2002
480
|
svg.removeEventListener('mouseleave', handleMouseLeave, true);
|
|
2003
481
|
});
|
|
2004
482
|
|
|
2005
|
-
// Double-click to enter text editing
|
|
2006
483
|
const handleDblClick = (e: Event) => {
|
|
2007
484
|
const mouseEvent = e as MouseEvent;
|
|
2008
485
|
const target = mouseEvent.target as Element;
|
|
@@ -2010,7 +487,6 @@ export function createChart(
|
|
|
2010
487
|
const ref = buildElementRef(target, specAnnotations);
|
|
2011
488
|
|
|
2012
489
|
if (ref && isTextEditable(ref, specAnnotations)) {
|
|
2013
|
-
// Select first if not already selected
|
|
2014
490
|
if (!refsEqual(selectedElement, ref)) {
|
|
2015
491
|
selectElement(ref);
|
|
2016
492
|
}
|
|
@@ -2028,10 +504,6 @@ export function createChart(
|
|
|
2028
504
|
};
|
|
2029
505
|
}
|
|
2030
506
|
|
|
2031
|
-
/**
|
|
2032
|
-
* Wire keyboard events for edit actions on the SVG.
|
|
2033
|
-
* Delete/Backspace -> delete, Escape -> cancel/deselect, Tab -> cycle, Enter -> text edit.
|
|
2034
|
-
*/
|
|
2035
507
|
function wireKeyboardEditEvents(): () => void {
|
|
2036
508
|
if (!svgElement) return () => {};
|
|
2037
509
|
|
|
@@ -2046,7 +518,6 @@ export function createChart(
|
|
|
2046
518
|
if (selectedElement && !isTextEditingActive) {
|
|
2047
519
|
e.preventDefault();
|
|
2048
520
|
options?.onEdit?.({ type: 'delete', element: selectedElement });
|
|
2049
|
-
// Stay selected (consumer decides whether to remove the element)
|
|
2050
521
|
}
|
|
2051
522
|
break;
|
|
2052
523
|
}
|
|
@@ -2054,7 +525,6 @@ export function createChart(
|
|
|
2054
525
|
case 'Escape': {
|
|
2055
526
|
e.preventDefault();
|
|
2056
527
|
if (isTextEditingActive && textEditCleanup) {
|
|
2057
|
-
// Cancel text editing, remain selected
|
|
2058
528
|
textEditCleanup();
|
|
2059
529
|
textEditCleanup = null;
|
|
2060
530
|
isTextEditingActive = false;
|
|
@@ -2113,8 +583,11 @@ export function createChart(
|
|
|
2113
583
|
};
|
|
2114
584
|
}
|
|
2115
585
|
|
|
586
|
+
// ---------------------------------------------------------------------------
|
|
587
|
+
// Render cycle
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
|
|
2116
590
|
function render(): void {
|
|
2117
|
-
// Defer re-render if a drag is in progress to avoid destroying the dragged element
|
|
2118
591
|
if (isDragging) {
|
|
2119
592
|
pendingRender = true;
|
|
2120
593
|
return;
|
|
@@ -2128,42 +601,24 @@ export function createChart(
|
|
|
2128
601
|
cancelAnimations(svgElement);
|
|
2129
602
|
|
|
2130
603
|
// Clean up previous render
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
cleanupChartEvents = null;
|
|
2150
|
-
}
|
|
2151
|
-
if (cleanupAnnotationDrag) {
|
|
2152
|
-
cleanupAnnotationDrag();
|
|
2153
|
-
cleanupAnnotationDrag = null;
|
|
2154
|
-
}
|
|
2155
|
-
if (cleanupEditDrags) {
|
|
2156
|
-
cleanupEditDrags();
|
|
2157
|
-
cleanupEditDrags = null;
|
|
2158
|
-
}
|
|
2159
|
-
if (cleanupSelection) {
|
|
2160
|
-
cleanupSelection();
|
|
2161
|
-
cleanupSelection = null;
|
|
2162
|
-
}
|
|
2163
|
-
if (cleanupKeyboardEdit) {
|
|
2164
|
-
cleanupKeyboardEdit();
|
|
2165
|
-
cleanupKeyboardEdit = null;
|
|
2166
|
-
}
|
|
604
|
+
cleanupTooltipEvents?.();
|
|
605
|
+
cleanupTooltipEvents = null;
|
|
606
|
+
cleanupVoronoiEvents?.();
|
|
607
|
+
cleanupVoronoiEvents = null;
|
|
608
|
+
cleanupKeyboardNav?.();
|
|
609
|
+
cleanupKeyboardNav = null;
|
|
610
|
+
cleanupLegend?.();
|
|
611
|
+
cleanupLegend = null;
|
|
612
|
+
cleanupChartEvents?.();
|
|
613
|
+
cleanupChartEvents = null;
|
|
614
|
+
cleanupAnnotationDrag?.();
|
|
615
|
+
cleanupAnnotationDrag = null;
|
|
616
|
+
cleanupEditDrags?.();
|
|
617
|
+
cleanupEditDrags = null;
|
|
618
|
+
cleanupSelection?.();
|
|
619
|
+
cleanupSelection = null;
|
|
620
|
+
cleanupKeyboardEdit?.();
|
|
621
|
+
cleanupKeyboardEdit = null;
|
|
2167
622
|
if (textEditCleanup) {
|
|
2168
623
|
textEditCleanup();
|
|
2169
624
|
textEditCleanup = null;
|
|
@@ -2183,8 +638,6 @@ export function createChart(
|
|
|
2183
638
|
|
|
2184
639
|
currentLayout = compile();
|
|
2185
640
|
const shouldAnimate = isFirstRender && !!currentLayout.animation?.enabled;
|
|
2186
|
-
// Crosshair is resolved by the engine (handles sparkline default-off,
|
|
2187
|
-
// user-explicit precedence, breakpoint overrides). Mount just reads it.
|
|
2188
641
|
const crosshair = !!currentLayout.crosshair;
|
|
2189
642
|
svgElement = renderChartSVG(currentLayout, container, {
|
|
2190
643
|
animate: shouldAnimate,
|
|
@@ -2215,6 +668,7 @@ export function createChart(
|
|
|
2215
668
|
cleanupLegend = wireLegendInteraction(
|
|
2216
669
|
svgElement,
|
|
2217
670
|
currentLayout,
|
|
671
|
+
toggleSeriesVisibility,
|
|
2218
672
|
options?.onLegendToggle,
|
|
2219
673
|
options?.onEdit,
|
|
2220
674
|
);
|
|
@@ -2226,7 +680,7 @@ export function createChart(
|
|
|
2226
680
|
options?.onMarkLeave ||
|
|
2227
681
|
options?.onAnnotationClick
|
|
2228
682
|
) {
|
|
2229
|
-
const specAnnotations:
|
|
683
|
+
const specAnnotations: Annotation[] =
|
|
2230
684
|
'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
|
|
2231
685
|
? currentSpec.annotations
|
|
2232
686
|
: [];
|
|
@@ -2242,13 +696,12 @@ export function createChart(
|
|
|
2242
696
|
}
|
|
2243
697
|
};
|
|
2244
698
|
|
|
2245
|
-
// Shared annotation list for drag handlers (computed once)
|
|
2246
699
|
const dragAnnotations: Annotation[] =
|
|
2247
700
|
'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
|
|
2248
701
|
? currentSpec.annotations
|
|
2249
702
|
: [];
|
|
2250
703
|
|
|
2251
|
-
// Wire annotation drag editing
|
|
704
|
+
// Wire annotation drag editing
|
|
2252
705
|
if (options?.onAnnotationEdit || options?.onEdit) {
|
|
2253
706
|
cleanupAnnotationDrag = wireAnnotationDrag(
|
|
2254
707
|
svgElement,
|
|
@@ -2263,24 +716,16 @@ export function createChart(
|
|
|
2263
716
|
if (options?.onEdit) {
|
|
2264
717
|
const editCleanups: Array<() => void> = [];
|
|
2265
718
|
|
|
2266
|
-
// Connector endpoint drag
|
|
2267
719
|
editCleanups.push(
|
|
2268
720
|
wireConnectorEndpointDrag(svgElement, dragAnnotations, options.onEdit, setDragging),
|
|
2269
721
|
);
|
|
2270
|
-
|
|
2271
|
-
// Range/refline annotation label drag
|
|
2272
722
|
editCleanups.push(
|
|
2273
723
|
wireAnnotationLabelDrag(svgElement, dragAnnotations, options.onEdit, setDragging),
|
|
2274
724
|
);
|
|
2275
725
|
|
|
2276
|
-
// Chrome text drag
|
|
2277
726
|
const editSpec = currentSpec as ChartSpec | GraphSpec;
|
|
2278
727
|
editCleanups.push(wireChromeDrag(svgElement, editSpec, options.onEdit, setDragging));
|
|
2279
|
-
|
|
2280
|
-
// Legend drag
|
|
2281
728
|
editCleanups.push(wireLegendDrag(svgElement, editSpec, options.onEdit, setDragging));
|
|
2282
|
-
|
|
2283
|
-
// Series label drag
|
|
2284
729
|
editCleanups.push(wireSeriesLabelDrag(svgElement, editSpec, options.onEdit, setDragging));
|
|
2285
730
|
|
|
2286
731
|
cleanupEditDrags = () => {
|
|
@@ -2296,13 +741,11 @@ export function createChart(
|
|
|
2296
741
|
cleanupSelection = wireSelectionEvents();
|
|
2297
742
|
cleanupKeyboardEdit = wireKeyboardEditEvents();
|
|
2298
743
|
|
|
2299
|
-
// Restore selection overlay after re-render
|
|
2300
744
|
if (selectedElement) {
|
|
2301
745
|
const target = findElementByRef(svgElement, selectedElement);
|
|
2302
746
|
if (target) {
|
|
2303
747
|
overlayElement = renderSelectionOverlay(svgElement, selectedElement, currentLayout);
|
|
2304
748
|
} else {
|
|
2305
|
-
// Element no longer exists in DOM, clear selection silently
|
|
2306
749
|
selectedElement = null;
|
|
2307
750
|
overlayElement = null;
|
|
2308
751
|
}
|
|
@@ -2321,10 +764,7 @@ export function createChart(
|
|
|
2321
764
|
container.classList.remove('oc-dark');
|
|
2322
765
|
}
|
|
2323
766
|
|
|
2324
|
-
// Set up animation cleanup on first render only
|
|
2325
|
-
// onComplete fires when animations finish naturally (not on cancellation/destroy).
|
|
2326
|
-
// It nulls out cleanupAnimations so resizes work after the animation window,
|
|
2327
|
-
// and replays any resize that was skipped mid-animation.
|
|
767
|
+
// Set up animation cleanup on first render only
|
|
2328
768
|
if (shouldAnimate && svgElement) {
|
|
2329
769
|
cleanupAnimations = setupAnimationCleanup(svgElement, () => {
|
|
2330
770
|
cleanupAnimations = null;
|
|
@@ -2339,9 +779,15 @@ export function createChart(
|
|
|
2339
779
|
}
|
|
2340
780
|
}
|
|
2341
781
|
|
|
782
|
+
// ---------------------------------------------------------------------------
|
|
783
|
+
// Public API methods
|
|
784
|
+
// ---------------------------------------------------------------------------
|
|
785
|
+
|
|
2342
786
|
function update(newSpec: ChartSpec | GraphSpec, updateOpts?: UpdateOptions): void {
|
|
2343
787
|
if (destroyed) return;
|
|
2344
788
|
currentSpec = newSpec;
|
|
789
|
+
runtimeHiddenSeries.clear();
|
|
790
|
+
runtimeShownSeries.clear();
|
|
2345
791
|
if (updateOpts && 'selectedElement' in updateOpts) {
|
|
2346
792
|
selectedElement = updateOpts.selectedElement ?? null;
|
|
2347
793
|
}
|
|
@@ -2350,10 +796,6 @@ export function createChart(
|
|
|
2350
796
|
|
|
2351
797
|
function resize(): void {
|
|
2352
798
|
if (destroyed) return;
|
|
2353
|
-
// Skip resize during entrance animation. The resize observer fires
|
|
2354
|
-
// immediately when the container first enters DOM layout, and re-rendering
|
|
2355
|
-
// would destroy the animated SVG. Resizes during this window are queued
|
|
2356
|
-
// and replayed once the animation completes via the onComplete callback.
|
|
2357
799
|
if (cleanupAnimations) {
|
|
2358
800
|
pendingResize = true;
|
|
2359
801
|
return;
|
|
@@ -2396,7 +838,6 @@ export function createChart(
|
|
|
2396
838
|
if (destroyed) return;
|
|
2397
839
|
destroyed = true;
|
|
2398
840
|
|
|
2399
|
-
// Cancel entrance animations (cancellation does not fire onComplete)
|
|
2400
841
|
if (cleanupAnimations) {
|
|
2401
842
|
cleanupAnimations();
|
|
2402
843
|
cleanupAnimations = null;
|
|
@@ -2404,42 +845,24 @@ export function createChart(
|
|
|
2404
845
|
}
|
|
2405
846
|
cancelAnimations(svgElement);
|
|
2406
847
|
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
cleanupChartEvents = null;
|
|
2426
|
-
}
|
|
2427
|
-
if (cleanupAnnotationDrag) {
|
|
2428
|
-
cleanupAnnotationDrag();
|
|
2429
|
-
cleanupAnnotationDrag = null;
|
|
2430
|
-
}
|
|
2431
|
-
if (cleanupEditDrags) {
|
|
2432
|
-
cleanupEditDrags();
|
|
2433
|
-
cleanupEditDrags = null;
|
|
2434
|
-
}
|
|
2435
|
-
if (cleanupSelection) {
|
|
2436
|
-
cleanupSelection();
|
|
2437
|
-
cleanupSelection = null;
|
|
2438
|
-
}
|
|
2439
|
-
if (cleanupKeyboardEdit) {
|
|
2440
|
-
cleanupKeyboardEdit();
|
|
2441
|
-
cleanupKeyboardEdit = null;
|
|
2442
|
-
}
|
|
848
|
+
cleanupTooltipEvents?.();
|
|
849
|
+
cleanupTooltipEvents = null;
|
|
850
|
+
cleanupVoronoiEvents?.();
|
|
851
|
+
cleanupVoronoiEvents = null;
|
|
852
|
+
cleanupKeyboardNav?.();
|
|
853
|
+
cleanupKeyboardNav = null;
|
|
854
|
+
cleanupLegend?.();
|
|
855
|
+
cleanupLegend = null;
|
|
856
|
+
cleanupChartEvents?.();
|
|
857
|
+
cleanupChartEvents = null;
|
|
858
|
+
cleanupAnnotationDrag?.();
|
|
859
|
+
cleanupAnnotationDrag = null;
|
|
860
|
+
cleanupEditDrags?.();
|
|
861
|
+
cleanupEditDrags = null;
|
|
862
|
+
cleanupSelection?.();
|
|
863
|
+
cleanupSelection = null;
|
|
864
|
+
cleanupKeyboardEdit?.();
|
|
865
|
+
cleanupKeyboardEdit = null;
|
|
2443
866
|
if (textEditCleanup) {
|
|
2444
867
|
textEditCleanup();
|
|
2445
868
|
textEditCleanup = null;
|
|
@@ -2470,9 +893,7 @@ export function createChart(
|
|
|
2470
893
|
// Initial render
|
|
2471
894
|
render();
|
|
2472
895
|
|
|
2473
|
-
// Set up responsive resize
|
|
2474
|
-
// ~60fps (16ms) internally, which is sufficient to coalesce a drag-burst
|
|
2475
|
-
// into a single render without additive delay.
|
|
896
|
+
// Set up responsive resize
|
|
2476
897
|
if (options?.responsive !== false) {
|
|
2477
898
|
disconnectResize = observeResize(container, () => {
|
|
2478
899
|
resize();
|