@opendata-ai/openchart-vanilla 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +327 -0
- package/dist/index.js +4745 -0
- package/dist/index.js.map +1 -0
- package/dist/simulation-worker.js +1196 -0
- package/package.json +58 -0
- package/src/__test-fixtures__/dom.ts +42 -0
- package/src/__test-fixtures__/specs.ts +187 -0
- package/src/__tests__/edit-events.test.ts +747 -0
- package/src/__tests__/events.test.ts +336 -0
- package/src/__tests__/export.test.ts +150 -0
- package/src/__tests__/mount.test.ts +219 -0
- package/src/__tests__/svg-renderer.test.ts +609 -0
- package/src/__tests__/table-mount.test.ts +484 -0
- package/src/__tests__/tooltip.test.ts +201 -0
- package/src/export.ts +105 -0
- package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
- package/src/graph/__tests__/graph-mount.test.ts +213 -0
- package/src/graph/__tests__/interaction.test.ts +205 -0
- package/src/graph/__tests__/keyboard.test.ts +653 -0
- package/src/graph/__tests__/search.test.ts +88 -0
- package/src/graph/__tests__/simulation.test.ts +233 -0
- package/src/graph/__tests__/spatial-index.test.ts +142 -0
- package/src/graph/__tests__/zoom.test.ts +195 -0
- package/src/graph/canvas-renderer.ts +660 -0
- package/src/graph/interaction.ts +359 -0
- package/src/graph/keyboard.ts +208 -0
- package/src/graph/search.ts +50 -0
- package/src/graph/simulation-worker-url.ts +30 -0
- package/src/graph/simulation-worker.ts +265 -0
- package/src/graph/simulation.ts +350 -0
- package/src/graph/spatial-index.ts +121 -0
- package/src/graph/types.ts +44 -0
- package/src/graph/worker-protocol.ts +67 -0
- package/src/graph/zoom.ts +104 -0
- package/src/graph-mount.ts +675 -0
- package/src/index.ts +56 -0
- package/src/mount.ts +1639 -0
- package/src/renderers/table-cells.ts +444 -0
- package/src/resize-observer.ts +46 -0
- package/src/svg-renderer.ts +914 -0
- package/src/table-keyboard.ts +266 -0
- package/src/table-mount.ts +532 -0
- package/src/table-renderer.ts +350 -0
- package/src/tooltip.ts +120 -0
package/src/mount.ts
ADDED
|
@@ -0,0 +1,1639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mount API: the main entry point for vanilla JS usage.
|
|
3
|
+
*
|
|
4
|
+
* createChart() takes a container, spec, and options, compiles the chart,
|
|
5
|
+
* renders it as SVG, sets up responsive resizing, tooltip interaction
|
|
6
|
+
* (mouse/touch/keyboard), keyboard navigation between data points,
|
|
7
|
+
* and returns a ChartInstance with update/resize/export/destroy methods.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
Annotation,
|
|
12
|
+
AnnotationOffset,
|
|
13
|
+
ChartEventHandlers,
|
|
14
|
+
ChartLayout,
|
|
15
|
+
ChartSpec,
|
|
16
|
+
ChromeKey,
|
|
17
|
+
CompileOptions,
|
|
18
|
+
DarkMode,
|
|
19
|
+
ElementEdit,
|
|
20
|
+
GraphSpec,
|
|
21
|
+
MeasureTextFn,
|
|
22
|
+
RangeAnnotation,
|
|
23
|
+
RefLineAnnotation,
|
|
24
|
+
TextAnnotation,
|
|
25
|
+
ThemeConfig,
|
|
26
|
+
TooltipContent,
|
|
27
|
+
} from '@opendata-ai/openchart-core';
|
|
28
|
+
import { compileChart } from '@opendata-ai/openchart-engine';
|
|
29
|
+
import { exportCSV, exportPNG, exportSVG, type PNGExportOptions } from './export';
|
|
30
|
+
import { observeResize } from './resize-observer';
|
|
31
|
+
import { renderChartSVG } from './svg-renderer';
|
|
32
|
+
import { createTooltipManager, type TooltipManager } from './tooltip';
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Types
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export interface MountOptions extends ChartEventHandlers {
|
|
39
|
+
/** Theme overrides. */
|
|
40
|
+
theme?: ThemeConfig;
|
|
41
|
+
/** Dark mode setting: "auto" (system pref), "force", or "off". */
|
|
42
|
+
darkMode?: DarkMode;
|
|
43
|
+
/** Callback when a data point is clicked. @deprecated Use onMarkClick instead. */
|
|
44
|
+
onDataPointClick?: (data: Record<string, unknown>) => void;
|
|
45
|
+
/** Enable responsive resizing. Defaults to true. */
|
|
46
|
+
responsive?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ExportOptions extends PNGExportOptions {
|
|
50
|
+
// Extensible for future formats
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ChartInstance {
|
|
54
|
+
/** Re-compile and re-render with a new spec. */
|
|
55
|
+
update(spec: ChartSpec | GraphSpec): void;
|
|
56
|
+
/** Re-compile at current container dimensions. */
|
|
57
|
+
resize(): void;
|
|
58
|
+
/** Export the chart. */
|
|
59
|
+
export(format: 'svg'): string;
|
|
60
|
+
export(format: 'png', options?: ExportOptions): Promise<Blob>;
|
|
61
|
+
export(format: 'csv'): string;
|
|
62
|
+
export(format: 'svg' | 'png' | 'csv', options?: ExportOptions): string | Promise<Blob>;
|
|
63
|
+
/** Remove all DOM elements and disconnect observers. */
|
|
64
|
+
destroy(): void;
|
|
65
|
+
/** The current compiled layout (for hooks / debugging). */
|
|
66
|
+
readonly layout: ChartLayout;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Dark mode resolution
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
function resolveDarkMode(mode?: DarkMode): boolean {
|
|
74
|
+
if (mode === 'force') return true;
|
|
75
|
+
if (mode === 'off' || mode === undefined) return false;
|
|
76
|
+
// "auto": check system preference
|
|
77
|
+
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
78
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// measureText via hidden canvas
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
function createMeasureText(): MeasureTextFn {
|
|
88
|
+
let canvas: HTMLCanvasElement | null = null;
|
|
89
|
+
let ctx: CanvasRenderingContext2D | null = null;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
text: string,
|
|
93
|
+
fontSize: number,
|
|
94
|
+
fontWeight?: number,
|
|
95
|
+
): { width: number; height: number } => {
|
|
96
|
+
if (!canvas) {
|
|
97
|
+
canvas = document.createElement('canvas');
|
|
98
|
+
ctx = canvas.getContext('2d');
|
|
99
|
+
}
|
|
100
|
+
if (!ctx) {
|
|
101
|
+
// Fallback: heuristic estimation
|
|
102
|
+
return { width: text.length * fontSize * 0.6, height: fontSize * 1.2 };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const weight = fontWeight ?? 400;
|
|
106
|
+
ctx.font = `${weight} ${fontSize}px Inter, sans-serif`;
|
|
107
|
+
const metrics = ctx.measureText(text);
|
|
108
|
+
return {
|
|
109
|
+
width: metrics.width,
|
|
110
|
+
height: fontSize * 1.2,
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Tooltip event wiring
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Wire tooltip events on mark elements inside an SVG.
|
|
121
|
+
* Returns a cleanup function to remove all listeners.
|
|
122
|
+
*/
|
|
123
|
+
function wireTooltipEvents(
|
|
124
|
+
svg: SVGElement,
|
|
125
|
+
tooltipDescriptors: Map<string, TooltipContent>,
|
|
126
|
+
tooltipManager: TooltipManager,
|
|
127
|
+
): () => void {
|
|
128
|
+
const markElements = svg.querySelectorAll('[data-mark-id]');
|
|
129
|
+
const cleanups: Array<() => void> = [];
|
|
130
|
+
|
|
131
|
+
for (const el of markElements) {
|
|
132
|
+
const markId = el.getAttribute('data-mark-id');
|
|
133
|
+
if (!markId) continue;
|
|
134
|
+
|
|
135
|
+
const content = tooltipDescriptors.get(markId);
|
|
136
|
+
if (!content) continue;
|
|
137
|
+
|
|
138
|
+
// Mouse enter -> show tooltip
|
|
139
|
+
const handleMouseEnter = (e: Event) => {
|
|
140
|
+
const mouseEvent = e as MouseEvent;
|
|
141
|
+
const svgRect = svg.getBoundingClientRect();
|
|
142
|
+
const x = mouseEvent.clientX - svgRect.left;
|
|
143
|
+
const y = mouseEvent.clientY - svgRect.top;
|
|
144
|
+
tooltipManager.show(content, x, y);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Mouse move -> reposition tooltip
|
|
148
|
+
const handleMouseMove = (e: Event) => {
|
|
149
|
+
const mouseEvent = e as MouseEvent;
|
|
150
|
+
const svgRect = svg.getBoundingClientRect();
|
|
151
|
+
const x = mouseEvent.clientX - svgRect.left;
|
|
152
|
+
const y = mouseEvent.clientY - svgRect.top;
|
|
153
|
+
tooltipManager.show(content, x, y);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Mouse leave -> hide tooltip
|
|
157
|
+
const handleMouseLeave = () => {
|
|
158
|
+
tooltipManager.hide();
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Touch: tap to show
|
|
162
|
+
const handleTouchStart = (e: Event) => {
|
|
163
|
+
const touchEvent = e as TouchEvent;
|
|
164
|
+
if (touchEvent.touches.length > 0) {
|
|
165
|
+
const touch = touchEvent.touches[0];
|
|
166
|
+
const svgRect = svg.getBoundingClientRect();
|
|
167
|
+
const x = touch.clientX - svgRect.left;
|
|
168
|
+
const y = touch.clientY - svgRect.top;
|
|
169
|
+
tooltipManager.show(content, x, y);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
el.addEventListener('mouseenter', handleMouseEnter);
|
|
174
|
+
el.addEventListener('mousemove', handleMouseMove);
|
|
175
|
+
el.addEventListener('mouseleave', handleMouseLeave);
|
|
176
|
+
el.addEventListener('touchstart', handleTouchStart);
|
|
177
|
+
|
|
178
|
+
cleanups.push(() => {
|
|
179
|
+
el.removeEventListener('mouseenter', handleMouseEnter);
|
|
180
|
+
el.removeEventListener('mousemove', handleMouseMove);
|
|
181
|
+
el.removeEventListener('mouseleave', handleMouseLeave);
|
|
182
|
+
el.removeEventListener('touchstart', handleTouchStart);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return () => {
|
|
187
|
+
for (const cleanup of cleanups) {
|
|
188
|
+
cleanup();
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Chart event wiring (click, hover, leave on marks; legend toggle; annotation click)
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build a map from data-mark-id to { datum, series } so event handlers
|
|
199
|
+
* can look up the data row associated with a clicked/hovered mark element.
|
|
200
|
+
*/
|
|
201
|
+
function buildMarkDataMap(
|
|
202
|
+
layout: ChartLayout,
|
|
203
|
+
): Map<string, { datum: Record<string, unknown>; series?: string }> {
|
|
204
|
+
const map = new Map<string, { datum: Record<string, unknown>; series?: string }>();
|
|
205
|
+
|
|
206
|
+
for (let i = 0; i < layout.marks.length; i++) {
|
|
207
|
+
const mark = layout.marks[i];
|
|
208
|
+
switch (mark.type) {
|
|
209
|
+
case 'line':
|
|
210
|
+
map.set(`line-${mark.seriesKey ?? i}`, {
|
|
211
|
+
// For line marks, data is an array. Use the first row as representative.
|
|
212
|
+
datum: mark.data[0] ?? {},
|
|
213
|
+
series: mark.seriesKey,
|
|
214
|
+
});
|
|
215
|
+
break;
|
|
216
|
+
case 'area':
|
|
217
|
+
map.set(`area-${mark.seriesKey ?? i}`, {
|
|
218
|
+
datum: mark.data[0] ?? {},
|
|
219
|
+
series: mark.seriesKey,
|
|
220
|
+
});
|
|
221
|
+
break;
|
|
222
|
+
case 'rect':
|
|
223
|
+
map.set(`rect-${i}`, { datum: mark.data });
|
|
224
|
+
break;
|
|
225
|
+
case 'arc':
|
|
226
|
+
map.set(`arc-${i}`, { datum: mark.data });
|
|
227
|
+
break;
|
|
228
|
+
case 'point':
|
|
229
|
+
map.set(`point-${i}`, { datum: mark.data });
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return map;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Wire chart event handlers (onMarkClick, onMarkHover, onMarkLeave) to mark
|
|
239
|
+
* elements, onLegendToggle to legend entries, and onAnnotationClick to annotation
|
|
240
|
+
* elements inside an SVG.
|
|
241
|
+
*
|
|
242
|
+
* Returns a cleanup function to remove all listeners.
|
|
243
|
+
*/
|
|
244
|
+
function wireChartEvents(
|
|
245
|
+
svg: SVGElement,
|
|
246
|
+
layout: ChartLayout,
|
|
247
|
+
specAnnotations: import('@opendata-ai/openchart-core').Annotation[],
|
|
248
|
+
handlers: ChartEventHandlers,
|
|
249
|
+
): () => void {
|
|
250
|
+
const cleanups: Array<() => void> = [];
|
|
251
|
+
const markDataMap = buildMarkDataMap(layout);
|
|
252
|
+
|
|
253
|
+
// Wire mark click/hover/leave events
|
|
254
|
+
if (handlers.onMarkClick || handlers.onMarkHover || handlers.onMarkLeave) {
|
|
255
|
+
const markElements = svg.querySelectorAll('[data-mark-id]');
|
|
256
|
+
|
|
257
|
+
for (const el of markElements) {
|
|
258
|
+
const markId = el.getAttribute('data-mark-id');
|
|
259
|
+
if (!markId) continue;
|
|
260
|
+
|
|
261
|
+
const markInfo = markDataMap.get(markId);
|
|
262
|
+
if (!markInfo) continue;
|
|
263
|
+
|
|
264
|
+
const series = markInfo.series ?? el.getAttribute('data-series') ?? undefined;
|
|
265
|
+
|
|
266
|
+
if (handlers.onMarkClick) {
|
|
267
|
+
const handleClick = (e: Event) => {
|
|
268
|
+
const mouseEvent = e as MouseEvent;
|
|
269
|
+
const svgRect = svg.getBoundingClientRect();
|
|
270
|
+
handlers.onMarkClick!({
|
|
271
|
+
datum: markInfo.datum,
|
|
272
|
+
series,
|
|
273
|
+
position: {
|
|
274
|
+
x: mouseEvent.clientX - svgRect.left,
|
|
275
|
+
y: mouseEvent.clientY - svgRect.top,
|
|
276
|
+
},
|
|
277
|
+
event: mouseEvent,
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
el.addEventListener('click', handleClick);
|
|
281
|
+
cleanups.push(() => el.removeEventListener('click', handleClick));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (handlers.onMarkHover) {
|
|
285
|
+
const handleEnter = (e: Event) => {
|
|
286
|
+
const mouseEvent = e as MouseEvent;
|
|
287
|
+
const svgRect = svg.getBoundingClientRect();
|
|
288
|
+
handlers.onMarkHover!({
|
|
289
|
+
datum: markInfo.datum,
|
|
290
|
+
series,
|
|
291
|
+
position: {
|
|
292
|
+
x: mouseEvent.clientX - svgRect.left,
|
|
293
|
+
y: mouseEvent.clientY - svgRect.top,
|
|
294
|
+
},
|
|
295
|
+
event: mouseEvent,
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
el.addEventListener('mouseenter', handleEnter);
|
|
299
|
+
cleanups.push(() => el.removeEventListener('mouseenter', handleEnter));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (handlers.onMarkLeave) {
|
|
303
|
+
const handleLeave = () => {
|
|
304
|
+
handlers.onMarkLeave!();
|
|
305
|
+
};
|
|
306
|
+
el.addEventListener('mouseleave', handleLeave);
|
|
307
|
+
cleanups.push(() => el.removeEventListener('mouseleave', handleLeave));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Wire annotation click events
|
|
313
|
+
if (handlers.onAnnotationClick) {
|
|
314
|
+
const annotationElements = svg.querySelectorAll('.viz-annotation');
|
|
315
|
+
|
|
316
|
+
for (let i = 0; i < annotationElements.length; i++) {
|
|
317
|
+
const el = annotationElements[i];
|
|
318
|
+
const specAnnotation = specAnnotations[i];
|
|
319
|
+
if (!specAnnotation) continue;
|
|
320
|
+
|
|
321
|
+
const handleClick = (e: Event) => {
|
|
322
|
+
const mouseEvent = e as MouseEvent;
|
|
323
|
+
handlers.onAnnotationClick!(specAnnotation, mouseEvent);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
el.addEventListener('click', handleClick);
|
|
327
|
+
cleanups.push(() => el.removeEventListener('click', handleClick));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return () => {
|
|
332
|
+
for (const cleanup of cleanups) {
|
|
333
|
+
cleanup();
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// Shared drag handler utility
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
interface DragConfig {
|
|
343
|
+
element: SVGElement;
|
|
344
|
+
svg: SVGSVGElement;
|
|
345
|
+
onMove: (dx: number, dy: number) => void;
|
|
346
|
+
onEnd: (dx: number, dy: number, moved: boolean) => void;
|
|
347
|
+
setDragging: (dragging: boolean) => void;
|
|
348
|
+
threshold?: number; // default: 3
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Reusable drag handler for SVG elements.
|
|
353
|
+
* Handles mouse and touch events, viewBox scaling, threshold detection,
|
|
354
|
+
* click suppression after drag, and cursor state.
|
|
355
|
+
*
|
|
356
|
+
* Returns a cleanup function that removes all listeners.
|
|
357
|
+
*/
|
|
358
|
+
function createDragHandler(config: DragConfig): () => void {
|
|
359
|
+
const { element, svg, onMove, onEnd, setDragging, threshold = 3 } = config;
|
|
360
|
+
const cleanups: Array<() => void> = [];
|
|
361
|
+
|
|
362
|
+
// Track active document listeners so cleanup can remove them mid-drag
|
|
363
|
+
let activeDocMouseMove: ((e: MouseEvent) => void) | null = null;
|
|
364
|
+
let activeDocMouseUp: ((e: MouseEvent) => void) | null = null;
|
|
365
|
+
let activeDocTouchMove: ((e: TouchEvent) => void) | null = null;
|
|
366
|
+
let activeDocTouchEnd: ((e: TouchEvent) => void) | null = null;
|
|
367
|
+
let activeDocTouchCancel: ((e: TouchEvent) => void) | null = null;
|
|
368
|
+
|
|
369
|
+
function getScale(): { scaleX: number; scaleY: number } {
|
|
370
|
+
const viewBox = svg.viewBox?.baseVal;
|
|
371
|
+
const svgRect = svg.getBoundingClientRect();
|
|
372
|
+
return {
|
|
373
|
+
scaleX: viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1,
|
|
374
|
+
scaleY: viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function startDrag(startX: number, startY: number): void {
|
|
379
|
+
setDragging(true);
|
|
380
|
+
const { scaleX, scaleY } = getScale();
|
|
381
|
+
|
|
382
|
+
element.style.cursor = 'grabbing';
|
|
383
|
+
// Prevent text selection during drag
|
|
384
|
+
svg.style.userSelect = 'none';
|
|
385
|
+
|
|
386
|
+
const handleMove = (clientX: number, clientY: number) => {
|
|
387
|
+
const dx = (clientX - startX) * scaleX;
|
|
388
|
+
const dy = (clientY - startY) * scaleY;
|
|
389
|
+
onMove(dx, dy);
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const cleanupDocListeners = () => {
|
|
393
|
+
if (activeDocMouseMove) {
|
|
394
|
+
document.removeEventListener('mousemove', activeDocMouseMove);
|
|
395
|
+
activeDocMouseMove = null;
|
|
396
|
+
}
|
|
397
|
+
if (activeDocMouseUp) {
|
|
398
|
+
document.removeEventListener('mouseup', activeDocMouseUp);
|
|
399
|
+
activeDocMouseUp = null;
|
|
400
|
+
}
|
|
401
|
+
if (activeDocTouchMove) {
|
|
402
|
+
document.removeEventListener('touchmove', activeDocTouchMove);
|
|
403
|
+
activeDocTouchMove = null;
|
|
404
|
+
}
|
|
405
|
+
if (activeDocTouchEnd) {
|
|
406
|
+
document.removeEventListener('touchend', activeDocTouchEnd);
|
|
407
|
+
activeDocTouchEnd = null;
|
|
408
|
+
}
|
|
409
|
+
if (activeDocTouchCancel) {
|
|
410
|
+
document.removeEventListener('touchcancel', activeDocTouchCancel);
|
|
411
|
+
activeDocTouchCancel = null;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const handleEnd = (clientX: number, clientY: number) => {
|
|
416
|
+
const dx = (clientX - startX) * scaleX;
|
|
417
|
+
const dy = (clientY - startY) * scaleY;
|
|
418
|
+
const moved = Math.abs(dx) > threshold || Math.abs(dy) > threshold;
|
|
419
|
+
|
|
420
|
+
onEnd(dx, dy, moved);
|
|
421
|
+
|
|
422
|
+
// Suppress click if drag actually moved
|
|
423
|
+
if (moved) {
|
|
424
|
+
element.addEventListener(
|
|
425
|
+
'click',
|
|
426
|
+
(clickE) => {
|
|
427
|
+
clickE.stopPropagation();
|
|
428
|
+
},
|
|
429
|
+
{ capture: true, once: true },
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
element.style.cursor = 'grab';
|
|
434
|
+
svg.style.userSelect = '';
|
|
435
|
+
|
|
436
|
+
cleanupDocListeners();
|
|
437
|
+
setDragging(false);
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// Mouse listeners
|
|
441
|
+
const onMouseMove = (moveEvent: MouseEvent) => {
|
|
442
|
+
handleMove(moveEvent.clientX, moveEvent.clientY);
|
|
443
|
+
};
|
|
444
|
+
const onMouseUp = (upEvent: MouseEvent) => {
|
|
445
|
+
handleEnd(upEvent.clientX, upEvent.clientY);
|
|
446
|
+
};
|
|
447
|
+
document.addEventListener('mousemove', onMouseMove);
|
|
448
|
+
document.addEventListener('mouseup', onMouseUp);
|
|
449
|
+
activeDocMouseMove = onMouseMove;
|
|
450
|
+
activeDocMouseUp = onMouseUp;
|
|
451
|
+
|
|
452
|
+
// Touch listeners
|
|
453
|
+
const onTouchMove = (moveEvent: TouchEvent) => {
|
|
454
|
+
if (moveEvent.touches.length > 0) {
|
|
455
|
+
moveEvent.preventDefault();
|
|
456
|
+
handleMove(moveEvent.touches[0].clientX, moveEvent.touches[0].clientY);
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
const onTouchEnd = (endEvent: TouchEvent) => {
|
|
460
|
+
const touch = endEvent.changedTouches[0];
|
|
461
|
+
if (touch) {
|
|
462
|
+
handleEnd(touch.clientX, touch.clientY);
|
|
463
|
+
} else {
|
|
464
|
+
handleEnd(startX, startY);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
468
|
+
document.addEventListener('touchend', onTouchEnd);
|
|
469
|
+
document.addEventListener('touchcancel', onTouchEnd);
|
|
470
|
+
activeDocTouchMove = onTouchMove;
|
|
471
|
+
activeDocTouchEnd = onTouchEnd;
|
|
472
|
+
activeDocTouchCancel = onTouchEnd;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Mouse down handler
|
|
476
|
+
const handleMouseDown = (e: Event) => {
|
|
477
|
+
const mouseEvent = e as MouseEvent;
|
|
478
|
+
mouseEvent.preventDefault();
|
|
479
|
+
startDrag(mouseEvent.clientX, mouseEvent.clientY);
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// Touch start handler
|
|
483
|
+
const handleTouchStart = (e: Event) => {
|
|
484
|
+
const touchEvent = e as TouchEvent;
|
|
485
|
+
if (touchEvent.touches.length === 1) {
|
|
486
|
+
touchEvent.preventDefault();
|
|
487
|
+
startDrag(touchEvent.touches[0].clientX, touchEvent.touches[0].clientY);
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
element.addEventListener('mousedown', handleMouseDown);
|
|
492
|
+
element.addEventListener('touchstart', handleTouchStart, { passive: false });
|
|
493
|
+
cleanups.push(() => {
|
|
494
|
+
element.removeEventListener('mousedown', handleMouseDown);
|
|
495
|
+
element.removeEventListener('touchstart', handleTouchStart);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
return () => {
|
|
499
|
+
for (const cleanup of cleanups) {
|
|
500
|
+
cleanup();
|
|
501
|
+
}
|
|
502
|
+
// Clean up any active document listeners (mid-drag unmount)
|
|
503
|
+
if (activeDocMouseMove) {
|
|
504
|
+
document.removeEventListener('mousemove', activeDocMouseMove);
|
|
505
|
+
activeDocMouseMove = null;
|
|
506
|
+
}
|
|
507
|
+
if (activeDocMouseUp) {
|
|
508
|
+
document.removeEventListener('mouseup', activeDocMouseUp);
|
|
509
|
+
activeDocMouseUp = null;
|
|
510
|
+
}
|
|
511
|
+
if (activeDocTouchMove) {
|
|
512
|
+
document.removeEventListener('touchmove', activeDocTouchMove);
|
|
513
|
+
activeDocTouchMove = null;
|
|
514
|
+
}
|
|
515
|
+
if (activeDocTouchEnd) {
|
|
516
|
+
document.removeEventListener('touchend', activeDocTouchEnd);
|
|
517
|
+
activeDocTouchEnd = null;
|
|
518
|
+
}
|
|
519
|
+
if (activeDocTouchCancel) {
|
|
520
|
+
document.removeEventListener('touchcancel', activeDocTouchCancel);
|
|
521
|
+
activeDocTouchCancel = null;
|
|
522
|
+
}
|
|
523
|
+
// Restore user-select in case of mid-drag cleanup
|
|
524
|
+
svg.style.userSelect = '';
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// Annotation drag editing
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Wire drag-to-reposition on text annotation labels.
|
|
534
|
+
* Only activates for text annotations (not range or refline).
|
|
535
|
+
* During drag, applies a CSS transform for real-time visual feedback and
|
|
536
|
+
* counter-adjusts straight connector endpoints so the data-point end stays fixed.
|
|
537
|
+
* On mouseup, fires the callback with the updated offset values.
|
|
538
|
+
*
|
|
539
|
+
* Returns a cleanup function to remove all listeners.
|
|
540
|
+
*/
|
|
541
|
+
function wireAnnotationDrag(
|
|
542
|
+
svg: SVGElement,
|
|
543
|
+
specAnnotations: Annotation[],
|
|
544
|
+
onAnnotationEdit:
|
|
545
|
+
| ((annotation: TextAnnotation, updatedOffset: AnnotationOffset) => void)
|
|
546
|
+
| undefined,
|
|
547
|
+
onEdit: ((edit: ElementEdit) => void) | undefined,
|
|
548
|
+
setDragging: (dragging: boolean) => void,
|
|
549
|
+
): () => void {
|
|
550
|
+
const annotationElements = svg.querySelectorAll('.viz-annotation-text');
|
|
551
|
+
const cleanups: Array<() => void> = [];
|
|
552
|
+
|
|
553
|
+
for (const el of annotationElements) {
|
|
554
|
+
const indexStr = el.getAttribute('data-annotation-index');
|
|
555
|
+
if (indexStr === null) continue;
|
|
556
|
+
|
|
557
|
+
const index = Number(indexStr);
|
|
558
|
+
const specAnnotation = specAnnotations[index];
|
|
559
|
+
if (!specAnnotation || specAnnotation.type !== 'text') continue;
|
|
560
|
+
|
|
561
|
+
const textAnnotation = specAnnotation as TextAnnotation;
|
|
562
|
+
const annotationG = el as SVGGElement;
|
|
563
|
+
|
|
564
|
+
// Visual affordance: show grab cursor
|
|
565
|
+
annotationG.style.cursor = 'grab';
|
|
566
|
+
|
|
567
|
+
// Stash connector info for real-time updates during drag
|
|
568
|
+
const connectorLine = annotationG.querySelector('line.viz-annotation-connector');
|
|
569
|
+
const origX2 = connectorLine ? Number(connectorLine.getAttribute('x2')) : 0;
|
|
570
|
+
const origY2 = connectorLine ? Number(connectorLine.getAttribute('y2')) : 0;
|
|
571
|
+
|
|
572
|
+
// For curved connectors, stash path/polygon elements to hide during drag
|
|
573
|
+
const curvedPath = annotationG.querySelector('path.viz-annotation-connector');
|
|
574
|
+
const arrowhead = annotationG.querySelector('polygon.viz-annotation-connector');
|
|
575
|
+
const hasCurvedConnector = curvedPath !== null;
|
|
576
|
+
|
|
577
|
+
const origDx = textAnnotation.offset?.dx ?? 0;
|
|
578
|
+
const origDy = textAnnotation.offset?.dy ?? 0;
|
|
579
|
+
|
|
580
|
+
const cleanup = createDragHandler({
|
|
581
|
+
element: annotationG,
|
|
582
|
+
svg: svg as unknown as SVGSVGElement,
|
|
583
|
+
onMove: (dx, dy) => {
|
|
584
|
+
// Move the entire annotation group
|
|
585
|
+
annotationG.setAttribute('transform', `translate(${dx}, ${dy})`);
|
|
586
|
+
|
|
587
|
+
// For straight connectors, counter-adjust the data-point end
|
|
588
|
+
if (connectorLine && !hasCurvedConnector) {
|
|
589
|
+
connectorLine.setAttribute('x2', String(origX2 - dx));
|
|
590
|
+
connectorLine.setAttribute('y2', String(origY2 - dy));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Hide curved connector elements during drag
|
|
594
|
+
if (hasCurvedConnector) {
|
|
595
|
+
if (curvedPath) curvedPath.setAttribute('display', 'none');
|
|
596
|
+
if (arrowhead) arrowhead.setAttribute('display', 'none');
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
onEnd: (dx, dy, moved) => {
|
|
600
|
+
// Clean up visual state
|
|
601
|
+
annotationG.removeAttribute('transform');
|
|
602
|
+
|
|
603
|
+
// Restore straight connector to original values
|
|
604
|
+
if (connectorLine && !hasCurvedConnector) {
|
|
605
|
+
connectorLine.setAttribute('x2', String(origX2));
|
|
606
|
+
connectorLine.setAttribute('y2', String(origY2));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Restore curved connector elements
|
|
610
|
+
if (hasCurvedConnector) {
|
|
611
|
+
if (curvedPath) curvedPath.removeAttribute('display');
|
|
612
|
+
if (arrowhead) arrowhead.removeAttribute('display');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (moved) {
|
|
616
|
+
const newOffset: AnnotationOffset = {
|
|
617
|
+
dx: origDx + dx,
|
|
618
|
+
dy: origDy + dy,
|
|
619
|
+
};
|
|
620
|
+
// Fire legacy callback
|
|
621
|
+
onAnnotationEdit?.(textAnnotation, newOffset);
|
|
622
|
+
// Fire unified edit callback
|
|
623
|
+
onEdit?.({ type: 'annotation', annotation: textAnnotation, offset: newOffset });
|
|
624
|
+
}
|
|
625
|
+
},
|
|
626
|
+
setDragging,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
cleanups.push(cleanup);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return () => {
|
|
633
|
+
for (const cleanup of cleanups) {
|
|
634
|
+
cleanup();
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
// Connector endpoint drag
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Wire drag on connector endpoint handles for text annotations.
|
|
645
|
+
* Dynamically creates invisible handle circles at connector endpoints
|
|
646
|
+
* so they only exist when editing is active (not in every chart).
|
|
647
|
+
* During drag, updates the handle position and the connector line endpoints.
|
|
648
|
+
* On end, fires onEdit with the accumulated endpoint offset.
|
|
649
|
+
*
|
|
650
|
+
* Shows handles on hover over the parent annotation group.
|
|
651
|
+
* Returns a cleanup function that removes handles and all listeners.
|
|
652
|
+
*/
|
|
653
|
+
function wireConnectorEndpointDrag(
|
|
654
|
+
svg: SVGElement,
|
|
655
|
+
specAnnotations: Annotation[],
|
|
656
|
+
onEdit: (edit: ElementEdit) => void,
|
|
657
|
+
setDragging: (dragging: boolean) => void,
|
|
658
|
+
): () => void {
|
|
659
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
660
|
+
const cleanups: Array<() => void> = [];
|
|
661
|
+
const annotationGroups = svg.querySelectorAll('.viz-annotation-text');
|
|
662
|
+
|
|
663
|
+
for (const el of annotationGroups) {
|
|
664
|
+
const annotationG = el as SVGGElement;
|
|
665
|
+
const indexStr = annotationG.getAttribute('data-annotation-index');
|
|
666
|
+
if (indexStr === null) continue;
|
|
667
|
+
|
|
668
|
+
const index = Number(indexStr);
|
|
669
|
+
const specAnnotation = specAnnotations[index];
|
|
670
|
+
if (!specAnnotation || specAnnotation.type !== 'text') continue;
|
|
671
|
+
|
|
672
|
+
const textAnnotation = specAnnotation as TextAnnotation;
|
|
673
|
+
|
|
674
|
+
// Find connector line or curved connector to determine endpoints
|
|
675
|
+
const connectorLine = annotationG.querySelector('line.viz-annotation-connector');
|
|
676
|
+
const curvedPath = annotationG.querySelector('path.viz-annotation-connector');
|
|
677
|
+
if (!connectorLine && !curvedPath) continue;
|
|
678
|
+
|
|
679
|
+
// Determine connector endpoint positions from the connector element
|
|
680
|
+
let fromX: number, fromY: number, toX: number, toY: number;
|
|
681
|
+
if (connectorLine) {
|
|
682
|
+
fromX = Number(connectorLine.getAttribute('x1'));
|
|
683
|
+
fromY = Number(connectorLine.getAttribute('y1'));
|
|
684
|
+
toX = Number(connectorLine.getAttribute('x2'));
|
|
685
|
+
toY = Number(connectorLine.getAttribute('y2'));
|
|
686
|
+
} else {
|
|
687
|
+
// For curved connectors, get positions from the path data
|
|
688
|
+
// The path starts at M x y, so parse the first coordinates
|
|
689
|
+
const pathD = curvedPath!.getAttribute('d') ?? '';
|
|
690
|
+
const mMatch = pathD.match(/M\s*([\d.e+-]+)\s+([\d.e+-]+)/);
|
|
691
|
+
fromX = mMatch ? Number(mMatch[1]) : 0;
|
|
692
|
+
fromY = mMatch ? Number(mMatch[2]) : 0;
|
|
693
|
+
// For curved connectors, the arrow polygon has the target
|
|
694
|
+
const arrowhead = annotationG.querySelector('polygon.viz-annotation-connector');
|
|
695
|
+
const points = arrowhead?.getAttribute('points') ?? '';
|
|
696
|
+
const firstPoint = points.split(' ')[0] ?? '0,0';
|
|
697
|
+
const [px, py] = firstPoint.split(',');
|
|
698
|
+
toX = Number(px);
|
|
699
|
+
toY = Number(py);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Create handles dynamically
|
|
703
|
+
const endpoints: Array<{ name: 'from' | 'to'; cx: number; cy: number }> = [
|
|
704
|
+
{ name: 'from', cx: fromX, cy: fromY },
|
|
705
|
+
{ name: 'to', cx: toX, cy: toY },
|
|
706
|
+
];
|
|
707
|
+
|
|
708
|
+
const createdHandles: SVGCircleElement[] = [];
|
|
709
|
+
|
|
710
|
+
for (const ep of endpoints) {
|
|
711
|
+
const handleEl = document.createElementNS(SVG_NS, 'circle') as SVGCircleElement;
|
|
712
|
+
handleEl.setAttribute('class', 'viz-connector-handle');
|
|
713
|
+
handleEl.setAttribute('data-endpoint', ep.name);
|
|
714
|
+
handleEl.setAttribute('cx', String(ep.cx));
|
|
715
|
+
handleEl.setAttribute('cy', String(ep.cy));
|
|
716
|
+
handleEl.setAttribute('r', '4');
|
|
717
|
+
handleEl.setAttribute('opacity', '0');
|
|
718
|
+
handleEl.setAttribute('fill', 'currentColor');
|
|
719
|
+
handleEl.setAttribute('stroke', 'currentColor');
|
|
720
|
+
annotationG.appendChild(handleEl);
|
|
721
|
+
createdHandles.push(handleEl);
|
|
722
|
+
|
|
723
|
+
const origCx = ep.cx;
|
|
724
|
+
const origCy = ep.cy;
|
|
725
|
+
|
|
726
|
+
// Prevent parent annotation drag from firing
|
|
727
|
+
const stopProp = (e: Event) => {
|
|
728
|
+
e.stopPropagation();
|
|
729
|
+
};
|
|
730
|
+
handleEl.addEventListener('mousedown', stopProp);
|
|
731
|
+
handleEl.addEventListener('touchstart', stopProp);
|
|
732
|
+
cleanups.push(() => {
|
|
733
|
+
handleEl.removeEventListener('mousedown', stopProp);
|
|
734
|
+
handleEl.removeEventListener('touchstart', stopProp);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
const cleanup = createDragHandler({
|
|
738
|
+
element: handleEl,
|
|
739
|
+
svg: svg as unknown as SVGSVGElement,
|
|
740
|
+
onMove: (dx, dy) => {
|
|
741
|
+
handleEl.setAttribute('cx', String(origCx + dx));
|
|
742
|
+
handleEl.setAttribute('cy', String(origCy + dy));
|
|
743
|
+
|
|
744
|
+
if (connectorLine) {
|
|
745
|
+
if (ep.name === 'from') {
|
|
746
|
+
connectorLine.setAttribute('x1', String(origCx + dx));
|
|
747
|
+
connectorLine.setAttribute('y1', String(origCy + dy));
|
|
748
|
+
} else {
|
|
749
|
+
connectorLine.setAttribute('x2', String(origCx + dx));
|
|
750
|
+
connectorLine.setAttribute('y2', String(origCy + dy));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
},
|
|
754
|
+
onEnd: (dx, dy, moved) => {
|
|
755
|
+
handleEl.setAttribute('cx', String(origCx));
|
|
756
|
+
handleEl.setAttribute('cy', String(origCy));
|
|
757
|
+
|
|
758
|
+
if (connectorLine) {
|
|
759
|
+
if (ep.name === 'from') {
|
|
760
|
+
connectorLine.setAttribute('x1', String(origCx));
|
|
761
|
+
connectorLine.setAttribute('y1', String(origCy));
|
|
762
|
+
} else {
|
|
763
|
+
connectorLine.setAttribute('x2', String(origCx));
|
|
764
|
+
connectorLine.setAttribute('y2', String(origCy));
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (moved) {
|
|
769
|
+
const existingOffset = textAnnotation.connectorOffset?.[ep.name];
|
|
770
|
+
const origEndDx = existingOffset?.dx ?? 0;
|
|
771
|
+
const origEndDy = existingOffset?.dy ?? 0;
|
|
772
|
+
onEdit({
|
|
773
|
+
type: 'annotation-connector',
|
|
774
|
+
annotation: textAnnotation,
|
|
775
|
+
endpoint: ep.name,
|
|
776
|
+
offset: { dx: origEndDx + dx, dy: origEndDy + dy },
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
setDragging,
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
cleanups.push(cleanup);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Wire hover to show/hide handles
|
|
787
|
+
const showHandles = () => {
|
|
788
|
+
for (const h of createdHandles) {
|
|
789
|
+
h.setAttribute('opacity', '0.6');
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
const hideHandles = () => {
|
|
793
|
+
for (const h of createdHandles) {
|
|
794
|
+
h.setAttribute('opacity', '0');
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
annotationG.addEventListener('mouseenter', showHandles);
|
|
799
|
+
annotationG.addEventListener('mouseleave', hideHandles);
|
|
800
|
+
cleanups.push(() => {
|
|
801
|
+
annotationG.removeEventListener('mouseenter', showHandles);
|
|
802
|
+
annotationG.removeEventListener('mouseleave', hideHandles);
|
|
803
|
+
// Remove dynamically created handles
|
|
804
|
+
for (const h of createdHandles) {
|
|
805
|
+
h.remove();
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return () => {
|
|
811
|
+
for (const cleanup of cleanups) {
|
|
812
|
+
cleanup();
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ---------------------------------------------------------------------------
|
|
818
|
+
// Range/refline annotation label drag
|
|
819
|
+
// ---------------------------------------------------------------------------
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Wire drag on range and refline annotation labels.
|
|
823
|
+
* On drag end, fires onEdit with the label offset.
|
|
824
|
+
* Returns a cleanup function.
|
|
825
|
+
*/
|
|
826
|
+
function wireAnnotationLabelDrag(
|
|
827
|
+
svg: SVGElement,
|
|
828
|
+
specAnnotations: Annotation[],
|
|
829
|
+
onEdit: (edit: ElementEdit) => void,
|
|
830
|
+
setDragging: (dragging: boolean) => void,
|
|
831
|
+
): () => void {
|
|
832
|
+
const cleanups: Array<() => void> = [];
|
|
833
|
+
|
|
834
|
+
// Target range and refline annotation labels
|
|
835
|
+
const selectors = [
|
|
836
|
+
'.viz-annotation-range .viz-annotation-label',
|
|
837
|
+
'.viz-annotation-refline .viz-annotation-label',
|
|
838
|
+
];
|
|
839
|
+
|
|
840
|
+
for (const selector of selectors) {
|
|
841
|
+
const labels = svg.querySelectorAll(selector);
|
|
842
|
+
|
|
843
|
+
for (const label of labels) {
|
|
844
|
+
const annotationG = label.closest('.viz-annotation') as SVGGElement | null;
|
|
845
|
+
if (!annotationG) continue;
|
|
846
|
+
|
|
847
|
+
const indexStr = annotationG.getAttribute('data-annotation-index');
|
|
848
|
+
if (indexStr === null) continue;
|
|
849
|
+
|
|
850
|
+
const index = Number(indexStr);
|
|
851
|
+
const specAnnotation = specAnnotations[index];
|
|
852
|
+
if (!specAnnotation) continue;
|
|
853
|
+
|
|
854
|
+
const labelEl = label as SVGTextElement;
|
|
855
|
+
labelEl.style.cursor = 'grab';
|
|
856
|
+
|
|
857
|
+
const isRange = specAnnotation.type === 'range';
|
|
858
|
+
const existingLabelOffset = isRange
|
|
859
|
+
? (specAnnotation as RangeAnnotation).labelOffset
|
|
860
|
+
: (specAnnotation as RefLineAnnotation).labelOffset;
|
|
861
|
+
const origLabelDx = existingLabelOffset?.dx ?? 0;
|
|
862
|
+
const origLabelDy = existingLabelOffset?.dy ?? 0;
|
|
863
|
+
|
|
864
|
+
const cleanup = createDragHandler({
|
|
865
|
+
element: labelEl,
|
|
866
|
+
svg: svg as unknown as SVGSVGElement,
|
|
867
|
+
onMove: (dx, dy) => {
|
|
868
|
+
(labelEl as SVGElement & ElementCSSInlineStyle).style.transform =
|
|
869
|
+
`translate(${dx}px, ${dy}px)`;
|
|
870
|
+
},
|
|
871
|
+
onEnd: (dx, dy, moved) => {
|
|
872
|
+
(labelEl as SVGElement & ElementCSSInlineStyle).style.transform = '';
|
|
873
|
+
|
|
874
|
+
if (moved) {
|
|
875
|
+
if (isRange) {
|
|
876
|
+
onEdit({
|
|
877
|
+
type: 'range-label',
|
|
878
|
+
annotation: specAnnotation as RangeAnnotation,
|
|
879
|
+
labelOffset: { dx: origLabelDx + dx, dy: origLabelDy + dy },
|
|
880
|
+
});
|
|
881
|
+
} else {
|
|
882
|
+
onEdit({
|
|
883
|
+
type: 'refline-label',
|
|
884
|
+
annotation: specAnnotation as RefLineAnnotation,
|
|
885
|
+
labelOffset: { dx: origLabelDx + dx, dy: origLabelDy + dy },
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
setDragging,
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
cleanups.push(cleanup);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return () => {
|
|
898
|
+
for (const cleanup of cleanups) {
|
|
899
|
+
cleanup();
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
905
|
+
// Chrome text drag
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Wire drag on chrome text elements (title, subtitle, source, byline, footer).
|
|
910
|
+
* On drag end, fires onEdit with the chrome key, text, and offset.
|
|
911
|
+
* Returns a cleanup function.
|
|
912
|
+
*/
|
|
913
|
+
function wireChromeDrag(
|
|
914
|
+
svg: SVGElement,
|
|
915
|
+
spec: ChartSpec | GraphSpec,
|
|
916
|
+
onEdit: (edit: ElementEdit) => void,
|
|
917
|
+
setDragging: (dragging: boolean) => void,
|
|
918
|
+
): () => void {
|
|
919
|
+
const chromeTexts = svg.querySelectorAll('.viz-chrome text[data-chrome-key]');
|
|
920
|
+
const cleanups: Array<() => void> = [];
|
|
921
|
+
|
|
922
|
+
// Read existing chrome offsets from the spec
|
|
923
|
+
const chromeConfig = 'chrome' in spec ? spec.chrome : undefined;
|
|
924
|
+
|
|
925
|
+
for (const el of chromeTexts) {
|
|
926
|
+
const textEl = el as SVGTextElement;
|
|
927
|
+
const key = textEl.getAttribute('data-chrome-key') as ChromeKey;
|
|
928
|
+
if (!key) continue;
|
|
929
|
+
|
|
930
|
+
// Read existing offset for this chrome element
|
|
931
|
+
const chromeEntry = chromeConfig?.[key];
|
|
932
|
+
const existingOffset =
|
|
933
|
+
typeof chromeEntry === 'object' && chromeEntry !== null ? chromeEntry.offset : undefined;
|
|
934
|
+
const origChromeDx = existingOffset?.dx ?? 0;
|
|
935
|
+
const origChromeDy = existingOffset?.dy ?? 0;
|
|
936
|
+
|
|
937
|
+
textEl.style.cursor = 'grab';
|
|
938
|
+
|
|
939
|
+
const cleanup = createDragHandler({
|
|
940
|
+
element: textEl,
|
|
941
|
+
svg: svg as unknown as SVGSVGElement,
|
|
942
|
+
onMove: (dx, dy) => {
|
|
943
|
+
(textEl as SVGElement & ElementCSSInlineStyle).style.transform =
|
|
944
|
+
`translate(${dx}px, ${dy}px)`;
|
|
945
|
+
},
|
|
946
|
+
onEnd: (dx, dy, moved) => {
|
|
947
|
+
(textEl as SVGElement & ElementCSSInlineStyle).style.transform = '';
|
|
948
|
+
|
|
949
|
+
if (moved) {
|
|
950
|
+
onEdit({
|
|
951
|
+
type: 'chrome',
|
|
952
|
+
key,
|
|
953
|
+
text: textEl.textContent ?? '',
|
|
954
|
+
offset: { dx: origChromeDx + dx, dy: origChromeDy + dy },
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
},
|
|
958
|
+
setDragging,
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
cleanups.push(cleanup);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return () => {
|
|
965
|
+
for (const cleanup of cleanups) {
|
|
966
|
+
cleanup();
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// ---------------------------------------------------------------------------
|
|
972
|
+
// Legend drag
|
|
973
|
+
// ---------------------------------------------------------------------------
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Wire drag on the legend group.
|
|
977
|
+
* Click suppression prevents legend toggle from firing after a drag.
|
|
978
|
+
* On drag end, fires onEdit with the legend offset.
|
|
979
|
+
* Returns a cleanup function.
|
|
980
|
+
*/
|
|
981
|
+
function wireLegendDrag(
|
|
982
|
+
svg: SVGElement,
|
|
983
|
+
spec: ChartSpec | GraphSpec,
|
|
984
|
+
onEdit: (edit: ElementEdit) => void,
|
|
985
|
+
setDragging: (dragging: boolean) => void,
|
|
986
|
+
): () => void {
|
|
987
|
+
const legendG = svg.querySelector('.viz-legend') as SVGGElement | null;
|
|
988
|
+
if (!legendG) return () => {};
|
|
989
|
+
|
|
990
|
+
const cleanups: Array<() => void> = [];
|
|
991
|
+
|
|
992
|
+
// Read existing legend offset from the spec
|
|
993
|
+
const legendConfig = 'legend' in spec ? spec.legend : undefined;
|
|
994
|
+
const origLegendDx = legendConfig?.offset?.dx ?? 0;
|
|
995
|
+
const origLegendDy = legendConfig?.offset?.dy ?? 0;
|
|
996
|
+
|
|
997
|
+
// Set grab cursor on the legend background, not on entry elements
|
|
998
|
+
legendG.style.cursor = 'grab';
|
|
999
|
+
|
|
1000
|
+
const cleanup = createDragHandler({
|
|
1001
|
+
element: legendG,
|
|
1002
|
+
svg: svg as unknown as SVGSVGElement,
|
|
1003
|
+
onMove: (dx, dy) => {
|
|
1004
|
+
(legendG as SVGElement & ElementCSSInlineStyle).style.transform =
|
|
1005
|
+
`translate(${dx}px, ${dy}px)`;
|
|
1006
|
+
},
|
|
1007
|
+
onEnd: (dx, dy, moved) => {
|
|
1008
|
+
(legendG as SVGElement & ElementCSSInlineStyle).style.transform = '';
|
|
1009
|
+
|
|
1010
|
+
if (moved) {
|
|
1011
|
+
onEdit({ type: 'legend', offset: { dx: origLegendDx + dx, dy: origLegendDy + dy } });
|
|
1012
|
+
}
|
|
1013
|
+
},
|
|
1014
|
+
setDragging,
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
cleanups.push(cleanup);
|
|
1018
|
+
|
|
1019
|
+
return () => {
|
|
1020
|
+
for (const cleanup of cleanups) {
|
|
1021
|
+
cleanup();
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// ---------------------------------------------------------------------------
|
|
1027
|
+
// Series label drag
|
|
1028
|
+
// ---------------------------------------------------------------------------
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Wire drag on series label elements (.viz-mark-label[data-series]).
|
|
1032
|
+
* On drag end, fires onEdit with the series name and offset.
|
|
1033
|
+
* Returns a cleanup function.
|
|
1034
|
+
*/
|
|
1035
|
+
function wireSeriesLabelDrag(
|
|
1036
|
+
svg: SVGElement,
|
|
1037
|
+
spec: ChartSpec | GraphSpec,
|
|
1038
|
+
onEdit: (edit: ElementEdit) => void,
|
|
1039
|
+
setDragging: (dragging: boolean) => void,
|
|
1040
|
+
): () => void {
|
|
1041
|
+
const labels = svg.querySelectorAll('.viz-mark-label');
|
|
1042
|
+
const cleanups: Array<() => void> = [];
|
|
1043
|
+
|
|
1044
|
+
// Read existing label offsets from the spec
|
|
1045
|
+
const labelsConfig = 'labels' in spec ? spec.labels : undefined;
|
|
1046
|
+
|
|
1047
|
+
for (const label of labels) {
|
|
1048
|
+
const labelEl = label as SVGTextElement;
|
|
1049
|
+
// Check label itself first, then fall back to the parent mark group's data-series
|
|
1050
|
+
const series =
|
|
1051
|
+
labelEl.getAttribute('data-series') ??
|
|
1052
|
+
labelEl.closest('[data-series]')?.getAttribute('data-series');
|
|
1053
|
+
if (!series) continue;
|
|
1054
|
+
|
|
1055
|
+
// Read existing offset for this series label
|
|
1056
|
+
const existingSeriesOffset = labelsConfig?.offsets?.[series];
|
|
1057
|
+
const origSeriesDx = existingSeriesOffset?.dx ?? 0;
|
|
1058
|
+
const origSeriesDy = existingSeriesOffset?.dy ?? 0;
|
|
1059
|
+
|
|
1060
|
+
labelEl.style.cursor = 'grab';
|
|
1061
|
+
|
|
1062
|
+
const cleanup = createDragHandler({
|
|
1063
|
+
element: labelEl,
|
|
1064
|
+
svg: svg as unknown as SVGSVGElement,
|
|
1065
|
+
onMove: (dx, dy) => {
|
|
1066
|
+
(labelEl as SVGElement & ElementCSSInlineStyle).style.transform =
|
|
1067
|
+
`translate(${dx}px, ${dy}px)`;
|
|
1068
|
+
},
|
|
1069
|
+
onEnd: (dx, dy, moved) => {
|
|
1070
|
+
(labelEl as SVGElement & ElementCSSInlineStyle).style.transform = '';
|
|
1071
|
+
|
|
1072
|
+
if (moved) {
|
|
1073
|
+
onEdit({
|
|
1074
|
+
type: 'series-label',
|
|
1075
|
+
series,
|
|
1076
|
+
offset: { dx: origSeriesDx + dx, dy: origSeriesDy + dy },
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
},
|
|
1080
|
+
setDragging,
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
cleanups.push(cleanup);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
return () => {
|
|
1087
|
+
for (const cleanup of cleanups) {
|
|
1088
|
+
cleanup();
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ---------------------------------------------------------------------------
|
|
1094
|
+
// Legend interactivity
|
|
1095
|
+
// ---------------------------------------------------------------------------
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Wire click handlers on legend entries to toggle series visibility.
|
|
1099
|
+
* Optionally calls onLegendToggle when a series is toggled.
|
|
1100
|
+
* Returns a cleanup function.
|
|
1101
|
+
*/
|
|
1102
|
+
function wireLegendInteraction(
|
|
1103
|
+
svg: SVGElement,
|
|
1104
|
+
_layout: ChartLayout,
|
|
1105
|
+
onLegendToggle?: (series: string, visible: boolean) => void,
|
|
1106
|
+
): () => void {
|
|
1107
|
+
const legendEntries = svg.querySelectorAll('[data-legend-index]');
|
|
1108
|
+
const cleanups: Array<() => void> = [];
|
|
1109
|
+
|
|
1110
|
+
// Track which series are hidden
|
|
1111
|
+
const hiddenSeries = new Set<string>();
|
|
1112
|
+
|
|
1113
|
+
for (const entry of legendEntries) {
|
|
1114
|
+
const handleClick = () => {
|
|
1115
|
+
const label = entry.getAttribute('data-legend-label');
|
|
1116
|
+
if (!label) return;
|
|
1117
|
+
|
|
1118
|
+
if (hiddenSeries.has(label)) {
|
|
1119
|
+
hiddenSeries.delete(label);
|
|
1120
|
+
entry.setAttribute('opacity', '1');
|
|
1121
|
+
entry.setAttribute('aria-label', `${label}: visible`);
|
|
1122
|
+
onLegendToggle?.(label, true);
|
|
1123
|
+
} else {
|
|
1124
|
+
hiddenSeries.add(label);
|
|
1125
|
+
entry.setAttribute('opacity', '0.3');
|
|
1126
|
+
entry.setAttribute('aria-label', `${label}: hidden`);
|
|
1127
|
+
onLegendToggle?.(label, false);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Toggle visibility of marks with matching series.
|
|
1131
|
+
// Uses the data-series attribute set by the SVG renderer, which works
|
|
1132
|
+
// for all mark types (line, area, rect, arc, point).
|
|
1133
|
+
const marks = svg.querySelectorAll('.viz-mark');
|
|
1134
|
+
for (const mark of marks) {
|
|
1135
|
+
const seriesName = mark.getAttribute('data-series');
|
|
1136
|
+
if (!seriesName) continue;
|
|
1137
|
+
|
|
1138
|
+
if (hiddenSeries.has(seriesName)) {
|
|
1139
|
+
(mark as SVGElement).style.display = 'none';
|
|
1140
|
+
} else {
|
|
1141
|
+
(mark as SVGElement).style.display = '';
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
entry.addEventListener('click', handleClick);
|
|
1147
|
+
cleanups.push(() => entry.removeEventListener('click', handleClick));
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
return () => {
|
|
1151
|
+
for (const cleanup of cleanups) {
|
|
1152
|
+
cleanup();
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// ---------------------------------------------------------------------------
|
|
1158
|
+
// Keyboard navigation
|
|
1159
|
+
// ---------------------------------------------------------------------------
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* Wire keyboard navigation on the SVG element.
|
|
1163
|
+
* Arrow keys move focus between mark elements. Enter/Space shows tooltip.
|
|
1164
|
+
* Escape hides tooltip. Returns a cleanup function.
|
|
1165
|
+
*/
|
|
1166
|
+
function wireKeyboardNav(
|
|
1167
|
+
svg: SVGElement,
|
|
1168
|
+
container: HTMLElement,
|
|
1169
|
+
tooltipDescriptors: Map<string, TooltipContent>,
|
|
1170
|
+
tooltipManager: TooltipManager,
|
|
1171
|
+
layout: ChartLayout,
|
|
1172
|
+
): () => void {
|
|
1173
|
+
// Make container focusable
|
|
1174
|
+
container.setAttribute('tabindex', '0');
|
|
1175
|
+
container.setAttribute('aria-roledescription', 'chart');
|
|
1176
|
+
container.setAttribute('aria-label', layout.a11y.altText);
|
|
1177
|
+
|
|
1178
|
+
// Collect navigable mark elements (those with tooltip content)
|
|
1179
|
+
const markElements: SVGElement[] = [];
|
|
1180
|
+
const allMarkEls = svg.querySelectorAll('[data-mark-id]');
|
|
1181
|
+
for (const el of allMarkEls) {
|
|
1182
|
+
const markId = el.getAttribute('data-mark-id');
|
|
1183
|
+
if (markId && tooltipDescriptors.has(markId)) {
|
|
1184
|
+
markElements.push(el as SVGElement);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
let focusIndex = -1;
|
|
1189
|
+
|
|
1190
|
+
function highlightMark(index: number): void {
|
|
1191
|
+
// Remove previous highlight
|
|
1192
|
+
if (focusIndex >= 0 && focusIndex < markElements.length) {
|
|
1193
|
+
markElements[focusIndex].classList.remove('viz-mark-focused');
|
|
1194
|
+
markElements[focusIndex].removeAttribute('aria-selected');
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
focusIndex = index;
|
|
1198
|
+
|
|
1199
|
+
if (focusIndex >= 0 && focusIndex < markElements.length) {
|
|
1200
|
+
const el = markElements[focusIndex];
|
|
1201
|
+
el.classList.add('viz-mark-focused');
|
|
1202
|
+
el.setAttribute('aria-selected', 'true');
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function showTooltipForFocused(): void {
|
|
1207
|
+
if (focusIndex < 0 || focusIndex >= markElements.length) return;
|
|
1208
|
+
|
|
1209
|
+
const el = markElements[focusIndex];
|
|
1210
|
+
const markId = el.getAttribute('data-mark-id');
|
|
1211
|
+
if (!markId) return;
|
|
1212
|
+
|
|
1213
|
+
const content = tooltipDescriptors.get(markId);
|
|
1214
|
+
if (!content) return;
|
|
1215
|
+
|
|
1216
|
+
// Position tooltip near the mark element
|
|
1217
|
+
const bbox = el.getBoundingClientRect();
|
|
1218
|
+
const containerRect = container.getBoundingClientRect();
|
|
1219
|
+
const x = bbox.left + bbox.width / 2 - containerRect.left;
|
|
1220
|
+
const y = bbox.top - containerRect.top;
|
|
1221
|
+
tooltipManager.show(content, x, y);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
1225
|
+
if (markElements.length === 0) return;
|
|
1226
|
+
|
|
1227
|
+
switch (e.key) {
|
|
1228
|
+
case 'ArrowRight':
|
|
1229
|
+
case 'ArrowDown': {
|
|
1230
|
+
e.preventDefault();
|
|
1231
|
+
const next = focusIndex < markElements.length - 1 ? focusIndex + 1 : 0;
|
|
1232
|
+
highlightMark(next);
|
|
1233
|
+
showTooltipForFocused();
|
|
1234
|
+
break;
|
|
1235
|
+
}
|
|
1236
|
+
case 'ArrowLeft':
|
|
1237
|
+
case 'ArrowUp': {
|
|
1238
|
+
e.preventDefault();
|
|
1239
|
+
const prev = focusIndex > 0 ? focusIndex - 1 : markElements.length - 1;
|
|
1240
|
+
highlightMark(prev);
|
|
1241
|
+
showTooltipForFocused();
|
|
1242
|
+
break;
|
|
1243
|
+
}
|
|
1244
|
+
case 'Enter':
|
|
1245
|
+
case ' ': {
|
|
1246
|
+
e.preventDefault();
|
|
1247
|
+
if (focusIndex >= 0) {
|
|
1248
|
+
showTooltipForFocused();
|
|
1249
|
+
} else if (markElements.length > 0) {
|
|
1250
|
+
highlightMark(0);
|
|
1251
|
+
showTooltipForFocused();
|
|
1252
|
+
}
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
case 'Escape': {
|
|
1256
|
+
e.preventDefault();
|
|
1257
|
+
tooltipManager.hide();
|
|
1258
|
+
highlightMark(-1);
|
|
1259
|
+
break;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1264
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
1265
|
+
|
|
1266
|
+
return () => {
|
|
1267
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
1268
|
+
container.removeAttribute('tabindex');
|
|
1269
|
+
container.removeAttribute('aria-roledescription');
|
|
1270
|
+
container.removeAttribute('aria-label');
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// ---------------------------------------------------------------------------
|
|
1275
|
+
// Hidden data table for screen readers
|
|
1276
|
+
// ---------------------------------------------------------------------------
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Create a visually-hidden data table from the chart's a11y fallback data.
|
|
1280
|
+
* Returns the table element (append to container) and a cleanup function.
|
|
1281
|
+
*/
|
|
1282
|
+
function createScreenReaderTable(
|
|
1283
|
+
layout: ChartLayout,
|
|
1284
|
+
container: HTMLElement,
|
|
1285
|
+
): HTMLTableElement | null {
|
|
1286
|
+
const data = layout.a11y.dataTableFallback;
|
|
1287
|
+
if (!data || data.length === 0) return null;
|
|
1288
|
+
|
|
1289
|
+
const table = document.createElement('table');
|
|
1290
|
+
table.className = 'viz-sr-only';
|
|
1291
|
+
table.setAttribute('role', 'table');
|
|
1292
|
+
table.setAttribute('aria-label', `Data table: ${layout.a11y.altText}`);
|
|
1293
|
+
|
|
1294
|
+
// First row is headers
|
|
1295
|
+
if (data.length > 0) {
|
|
1296
|
+
const thead = document.createElement('thead');
|
|
1297
|
+
const headerRow = document.createElement('tr');
|
|
1298
|
+
const headers = data[0] as unknown[];
|
|
1299
|
+
for (const header of headers) {
|
|
1300
|
+
const th = document.createElement('th');
|
|
1301
|
+
th.textContent = String(header ?? '');
|
|
1302
|
+
th.setAttribute('scope', 'col');
|
|
1303
|
+
headerRow.appendChild(th);
|
|
1304
|
+
}
|
|
1305
|
+
thead.appendChild(headerRow);
|
|
1306
|
+
table.appendChild(thead);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Remaining rows are data
|
|
1310
|
+
if (data.length > 1) {
|
|
1311
|
+
const tbody = document.createElement('tbody');
|
|
1312
|
+
for (let i = 1; i < data.length; i++) {
|
|
1313
|
+
const tr = document.createElement('tr');
|
|
1314
|
+
const cells = data[i] as unknown[];
|
|
1315
|
+
for (const cell of cells) {
|
|
1316
|
+
const td = document.createElement('td');
|
|
1317
|
+
td.textContent = String(cell ?? '');
|
|
1318
|
+
tr.appendChild(td);
|
|
1319
|
+
}
|
|
1320
|
+
tbody.appendChild(tr);
|
|
1321
|
+
}
|
|
1322
|
+
table.appendChild(tbody);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
container.appendChild(table);
|
|
1326
|
+
return table;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// ---------------------------------------------------------------------------
|
|
1330
|
+
// Main API
|
|
1331
|
+
// ---------------------------------------------------------------------------
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Create a chart instance from a spec and mount it into a container.
|
|
1335
|
+
*
|
|
1336
|
+
* @param container - The DOM element to render into.
|
|
1337
|
+
* @param spec - The visualization spec.
|
|
1338
|
+
* @param options - Mount options (theme, darkMode, responsive, etc.).
|
|
1339
|
+
* @returns A ChartInstance with update/resize/export/destroy methods.
|
|
1340
|
+
*/
|
|
1341
|
+
export function createChart(
|
|
1342
|
+
container: HTMLElement,
|
|
1343
|
+
spec: ChartSpec | GraphSpec,
|
|
1344
|
+
options?: MountOptions,
|
|
1345
|
+
): ChartInstance {
|
|
1346
|
+
let currentSpec: ChartSpec | GraphSpec = spec;
|
|
1347
|
+
let currentLayout: ChartLayout;
|
|
1348
|
+
let svgElement: SVGElement | null = null;
|
|
1349
|
+
let tooltipManager: TooltipManager | null = null;
|
|
1350
|
+
let disconnectResize: (() => void) | null = null;
|
|
1351
|
+
let cleanupTooltipEvents: (() => void) | null = null;
|
|
1352
|
+
let cleanupKeyboardNav: (() => void) | null = null;
|
|
1353
|
+
let cleanupLegend: (() => void) | null = null;
|
|
1354
|
+
let cleanupChartEvents: (() => void) | null = null;
|
|
1355
|
+
let cleanupAnnotationDrag: (() => void) | null = null;
|
|
1356
|
+
let cleanupEditDrags: (() => void) | null = null;
|
|
1357
|
+
let srTable: HTMLTableElement | null = null;
|
|
1358
|
+
let destroyed = false;
|
|
1359
|
+
let isDragging = false;
|
|
1360
|
+
let pendingRender = false;
|
|
1361
|
+
|
|
1362
|
+
const measureText = createMeasureText();
|
|
1363
|
+
|
|
1364
|
+
function compile(): ChartLayout {
|
|
1365
|
+
const { width, height } = getContainerDimensions();
|
|
1366
|
+
const darkMode = resolveDarkMode(options?.darkMode);
|
|
1367
|
+
|
|
1368
|
+
const compileOpts: CompileOptions = {
|
|
1369
|
+
width,
|
|
1370
|
+
height,
|
|
1371
|
+
theme: options?.theme,
|
|
1372
|
+
darkMode,
|
|
1373
|
+
measureText,
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
return compileChart(currentSpec, compileOpts);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function getContainerDimensions(): { width: number; height: number } {
|
|
1380
|
+
const rect = container.getBoundingClientRect();
|
|
1381
|
+
return {
|
|
1382
|
+
width: Math.max(rect.width || 600, 100),
|
|
1383
|
+
height: Math.max(rect.height || 400, 100),
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function render(): void {
|
|
1388
|
+
// Defer re-render if a drag is in progress to avoid destroying the dragged element
|
|
1389
|
+
if (isDragging) {
|
|
1390
|
+
pendingRender = true;
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Clean up previous render
|
|
1395
|
+
if (cleanupTooltipEvents) {
|
|
1396
|
+
cleanupTooltipEvents();
|
|
1397
|
+
cleanupTooltipEvents = null;
|
|
1398
|
+
}
|
|
1399
|
+
if (cleanupKeyboardNav) {
|
|
1400
|
+
cleanupKeyboardNav();
|
|
1401
|
+
cleanupKeyboardNav = null;
|
|
1402
|
+
}
|
|
1403
|
+
if (cleanupLegend) {
|
|
1404
|
+
cleanupLegend();
|
|
1405
|
+
cleanupLegend = null;
|
|
1406
|
+
}
|
|
1407
|
+
if (cleanupChartEvents) {
|
|
1408
|
+
cleanupChartEvents();
|
|
1409
|
+
cleanupChartEvents = null;
|
|
1410
|
+
}
|
|
1411
|
+
if (cleanupAnnotationDrag) {
|
|
1412
|
+
cleanupAnnotationDrag();
|
|
1413
|
+
cleanupAnnotationDrag = null;
|
|
1414
|
+
}
|
|
1415
|
+
if (cleanupEditDrags) {
|
|
1416
|
+
cleanupEditDrags();
|
|
1417
|
+
cleanupEditDrags = null;
|
|
1418
|
+
}
|
|
1419
|
+
if (svgElement?.parentNode) {
|
|
1420
|
+
svgElement.parentNode.removeChild(svgElement);
|
|
1421
|
+
}
|
|
1422
|
+
if (tooltipManager) {
|
|
1423
|
+
tooltipManager.destroy();
|
|
1424
|
+
}
|
|
1425
|
+
if (srTable?.parentNode) {
|
|
1426
|
+
srTable.parentNode.removeChild(srTable);
|
|
1427
|
+
srTable = null;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
currentLayout = compile();
|
|
1431
|
+
svgElement = renderChartSVG(currentLayout, container);
|
|
1432
|
+
tooltipManager = createTooltipManager(container);
|
|
1433
|
+
|
|
1434
|
+
// Wire tooltip events on mark elements
|
|
1435
|
+
cleanupTooltipEvents = wireTooltipEvents(
|
|
1436
|
+
svgElement,
|
|
1437
|
+
currentLayout.tooltipDescriptors,
|
|
1438
|
+
tooltipManager,
|
|
1439
|
+
);
|
|
1440
|
+
|
|
1441
|
+
// Wire keyboard navigation
|
|
1442
|
+
cleanupKeyboardNav = wireKeyboardNav(
|
|
1443
|
+
svgElement,
|
|
1444
|
+
container,
|
|
1445
|
+
currentLayout.tooltipDescriptors,
|
|
1446
|
+
tooltipManager,
|
|
1447
|
+
currentLayout,
|
|
1448
|
+
);
|
|
1449
|
+
|
|
1450
|
+
// Wire legend interactivity
|
|
1451
|
+
cleanupLegend = wireLegendInteraction(svgElement, currentLayout, options?.onLegendToggle);
|
|
1452
|
+
|
|
1453
|
+
// Wire chart event handlers (mark click/hover/leave, annotation click)
|
|
1454
|
+
if (
|
|
1455
|
+
options?.onMarkClick ||
|
|
1456
|
+
options?.onMarkHover ||
|
|
1457
|
+
options?.onMarkLeave ||
|
|
1458
|
+
options?.onAnnotationClick
|
|
1459
|
+
) {
|
|
1460
|
+
const specAnnotations: import('@opendata-ai/openchart-core').Annotation[] =
|
|
1461
|
+
'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
|
|
1462
|
+
? currentSpec.annotations
|
|
1463
|
+
: [];
|
|
1464
|
+
cleanupChartEvents = wireChartEvents(svgElement, currentLayout, specAnnotations, options);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// Shared setDragging callback for all drag handlers
|
|
1468
|
+
const setDragging = (dragging: boolean) => {
|
|
1469
|
+
isDragging = dragging;
|
|
1470
|
+
if (!dragging && pendingRender) {
|
|
1471
|
+
pendingRender = false;
|
|
1472
|
+
render();
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1475
|
+
|
|
1476
|
+
// Shared annotation list for drag handlers (computed once)
|
|
1477
|
+
const dragAnnotations: Annotation[] =
|
|
1478
|
+
'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
|
|
1479
|
+
? currentSpec.annotations
|
|
1480
|
+
: [];
|
|
1481
|
+
|
|
1482
|
+
// Wire annotation drag editing (activates when onAnnotationEdit or onEdit is provided)
|
|
1483
|
+
if (options?.onAnnotationEdit || options?.onEdit) {
|
|
1484
|
+
cleanupAnnotationDrag = wireAnnotationDrag(
|
|
1485
|
+
svgElement,
|
|
1486
|
+
dragAnnotations,
|
|
1487
|
+
options?.onAnnotationEdit,
|
|
1488
|
+
options?.onEdit,
|
|
1489
|
+
setDragging,
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// Wire all edit drag handlers when onEdit is provided
|
|
1494
|
+
if (options?.onEdit) {
|
|
1495
|
+
const editCleanups: Array<() => void> = [];
|
|
1496
|
+
|
|
1497
|
+
// Connector endpoint drag
|
|
1498
|
+
editCleanups.push(
|
|
1499
|
+
wireConnectorEndpointDrag(svgElement, dragAnnotations, options.onEdit, setDragging),
|
|
1500
|
+
);
|
|
1501
|
+
|
|
1502
|
+
// Range/refline annotation label drag
|
|
1503
|
+
editCleanups.push(
|
|
1504
|
+
wireAnnotationLabelDrag(svgElement, dragAnnotations, options.onEdit, setDragging),
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
// Chrome text drag
|
|
1508
|
+
editCleanups.push(wireChromeDrag(svgElement, currentSpec, options.onEdit, setDragging));
|
|
1509
|
+
|
|
1510
|
+
// Legend drag
|
|
1511
|
+
editCleanups.push(wireLegendDrag(svgElement, currentSpec, options.onEdit, setDragging));
|
|
1512
|
+
|
|
1513
|
+
// Series label drag
|
|
1514
|
+
editCleanups.push(wireSeriesLabelDrag(svgElement, currentSpec, options.onEdit, setDragging));
|
|
1515
|
+
|
|
1516
|
+
cleanupEditDrags = () => {
|
|
1517
|
+
for (const cleanup of editCleanups) {
|
|
1518
|
+
cleanup();
|
|
1519
|
+
}
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// Create hidden data table for screen readers
|
|
1524
|
+
srTable = createScreenReaderTable(currentLayout, container);
|
|
1525
|
+
|
|
1526
|
+
// Apply container classes for CSS variable scoping and dark mode
|
|
1527
|
+
container.classList.add('viz-root');
|
|
1528
|
+
const isDark = resolveDarkMode(options?.darkMode);
|
|
1529
|
+
if (isDark) {
|
|
1530
|
+
container.classList.add('viz-dark');
|
|
1531
|
+
} else {
|
|
1532
|
+
container.classList.remove('viz-dark');
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function update(newSpec: ChartSpec | GraphSpec): void {
|
|
1537
|
+
if (destroyed) return;
|
|
1538
|
+
currentSpec = newSpec;
|
|
1539
|
+
render();
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function resize(): void {
|
|
1543
|
+
if (destroyed) return;
|
|
1544
|
+
render();
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function doExport(format: 'svg'): string;
|
|
1548
|
+
function doExport(format: 'png', exportOptions?: ExportOptions): Promise<Blob>;
|
|
1549
|
+
function doExport(format: 'csv'): string;
|
|
1550
|
+
function doExport(
|
|
1551
|
+
format: 'svg' | 'png' | 'csv',
|
|
1552
|
+
exportOptions?: ExportOptions,
|
|
1553
|
+
): string | Promise<Blob> {
|
|
1554
|
+
if (!svgElement) {
|
|
1555
|
+
throw new Error('Chart is not rendered yet');
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
switch (format) {
|
|
1559
|
+
case 'svg':
|
|
1560
|
+
return exportSVG(svgElement);
|
|
1561
|
+
case 'png':
|
|
1562
|
+
return exportPNG(svgElement, exportOptions);
|
|
1563
|
+
case 'csv':
|
|
1564
|
+
return exportCSV(
|
|
1565
|
+
'data' in currentSpec && Array.isArray(currentSpec.data) ? currentSpec.data : [],
|
|
1566
|
+
);
|
|
1567
|
+
default:
|
|
1568
|
+
throw new Error(`Unsupported export format: ${format}`);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function destroy(): void {
|
|
1573
|
+
if (destroyed) return;
|
|
1574
|
+
destroyed = true;
|
|
1575
|
+
|
|
1576
|
+
if (cleanupTooltipEvents) {
|
|
1577
|
+
cleanupTooltipEvents();
|
|
1578
|
+
cleanupTooltipEvents = null;
|
|
1579
|
+
}
|
|
1580
|
+
if (cleanupKeyboardNav) {
|
|
1581
|
+
cleanupKeyboardNav();
|
|
1582
|
+
cleanupKeyboardNav = null;
|
|
1583
|
+
}
|
|
1584
|
+
if (cleanupLegend) {
|
|
1585
|
+
cleanupLegend();
|
|
1586
|
+
cleanupLegend = null;
|
|
1587
|
+
}
|
|
1588
|
+
if (cleanupChartEvents) {
|
|
1589
|
+
cleanupChartEvents();
|
|
1590
|
+
cleanupChartEvents = null;
|
|
1591
|
+
}
|
|
1592
|
+
if (cleanupAnnotationDrag) {
|
|
1593
|
+
cleanupAnnotationDrag();
|
|
1594
|
+
cleanupAnnotationDrag = null;
|
|
1595
|
+
}
|
|
1596
|
+
if (cleanupEditDrags) {
|
|
1597
|
+
cleanupEditDrags();
|
|
1598
|
+
cleanupEditDrags = null;
|
|
1599
|
+
}
|
|
1600
|
+
if (disconnectResize) {
|
|
1601
|
+
disconnectResize();
|
|
1602
|
+
disconnectResize = null;
|
|
1603
|
+
}
|
|
1604
|
+
if (tooltipManager) {
|
|
1605
|
+
tooltipManager.destroy();
|
|
1606
|
+
tooltipManager = null;
|
|
1607
|
+
}
|
|
1608
|
+
if (svgElement?.parentNode) {
|
|
1609
|
+
svgElement.parentNode.removeChild(svgElement);
|
|
1610
|
+
svgElement = null;
|
|
1611
|
+
}
|
|
1612
|
+
if (srTable?.parentNode) {
|
|
1613
|
+
srTable.parentNode.removeChild(srTable);
|
|
1614
|
+
srTable = null;
|
|
1615
|
+
}
|
|
1616
|
+
container.classList.remove('viz-dark');
|
|
1617
|
+
container.classList.remove('viz-root');
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// Initial render
|
|
1621
|
+
render();
|
|
1622
|
+
|
|
1623
|
+
// Set up responsive resize
|
|
1624
|
+
if (options?.responsive !== false) {
|
|
1625
|
+
disconnectResize = observeResize(container, () => {
|
|
1626
|
+
resize();
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
return {
|
|
1631
|
+
update,
|
|
1632
|
+
resize,
|
|
1633
|
+
export: doExport,
|
|
1634
|
+
destroy,
|
|
1635
|
+
get layout() {
|
|
1636
|
+
return currentLayout;
|
|
1637
|
+
},
|
|
1638
|
+
};
|
|
1639
|
+
}
|