@opendata-ai/openchart-vanilla 6.5.2 → 6.6.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.
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Sankey mount API: the main entry point for vanilla JS sankey usage.
3
+ *
4
+ * createSankey() takes a container, SankeySpec, and options, compiles the
5
+ * sankey, renders it as SVG, sets up responsive resizing, tooltip interaction,
6
+ * hover highlighting, and returns a SankeyInstance with update/resize/export/destroy.
7
+ */
8
+
9
+ import type {
10
+ CompileOptions,
11
+ DarkMode,
12
+ SankeyLayout,
13
+ SankeySpec,
14
+ ThemeConfig,
15
+ } from '@opendata-ai/openchart-core';
16
+ import { compileSankey } from '@opendata-ai/openchart-engine';
17
+ import { cancelAnimations, setupAnimationCleanup } from './animation';
18
+ import {
19
+ exportJPG,
20
+ exportPNG,
21
+ exportSVG,
22
+ exportSVGWithFonts,
23
+ type JPGExportOptions,
24
+ type SVGExportOptions,
25
+ } from './export';
26
+ import { observeResize } from './resize-observer';
27
+ import { renderSankeySVG } from './sankey-renderer';
28
+ import { createTooltipManager, type TooltipManager } from './tooltip';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Types
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export interface SankeyMountOptions {
35
+ /** Theme overrides. */
36
+ theme?: ThemeConfig;
37
+ /** Dark mode setting: "auto" (system pref), "force", or "off". */
38
+ darkMode?: DarkMode;
39
+ /** Enable responsive resizing. Defaults to true. */
40
+ responsive?: boolean;
41
+ /** Show tooltips on hover. Defaults to true. */
42
+ tooltip?: boolean;
43
+ /** Callback when a node is clicked. */
44
+ onNodeClick?: (node: Record<string, unknown>) => void;
45
+ /** Callback when a link is clicked. */
46
+ onLinkClick?: (link: Record<string, unknown>) => void;
47
+ /** Callback when a node is hovered (null on mouse leave). */
48
+ onNodeHover?: (node: Record<string, unknown> | null) => void;
49
+ /** Callback when a link is hovered (null on mouse leave). */
50
+ onLinkHover?: (link: Record<string, unknown> | null) => void;
51
+ }
52
+
53
+ export interface SankeyInstance {
54
+ /** Re-compile and re-render with a new spec. */
55
+ update(spec: SankeySpec): void;
56
+ /** Re-compile at current container dimensions. */
57
+ resize(): void;
58
+ /** Export the sankey diagram. */
59
+ export(
60
+ format: 'svg' | 'svg-with-fonts' | 'png' | 'jpg',
61
+ options?: JPGExportOptions,
62
+ ): string | Promise<Blob> | Promise<string>;
63
+ /** Remove all DOM elements and disconnect observers. */
64
+ destroy(): void;
65
+ /** The current compiled layout. */
66
+ readonly layout: SankeyLayout;
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
+ if (typeof window !== 'undefined' && window.matchMedia) {
77
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
78
+ }
79
+ return false;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Constants
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /** Opacity for links connected to hovered node. */
87
+ const HIGHLIGHT_OPACITY = 0.7;
88
+ /** Opacity for links NOT connected to hovered node. */
89
+ const DIM_OPACITY = 0.15;
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Main API
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Create a sankey instance from a spec and mount it into a container.
97
+ */
98
+ export function createSankey(
99
+ container: HTMLElement,
100
+ spec: SankeySpec,
101
+ options?: SankeyMountOptions,
102
+ ): SankeyInstance {
103
+ let currentSpec = spec;
104
+ let currentLayout: SankeyLayout;
105
+ let destroyed = false;
106
+
107
+ // DOM
108
+ let svgElement: SVGSVGElement | null = null;
109
+
110
+ // Subsystems
111
+ let tooltipManager: TooltipManager | null = null;
112
+ let cleanupTooltipEvents: (() => void) | null = null;
113
+ let disconnectResize: (() => void) | null = null;
114
+
115
+ // Animation state
116
+ let isFirstRender = true;
117
+ let animationCleanup: (() => void) | null = null;
118
+ let pendingResize = false;
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Helpers
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function getContainerDimensions(): { width: number; height: number } {
125
+ const rect = container.getBoundingClientRect();
126
+ return {
127
+ width: Math.max(rect.width || 600, 100),
128
+ height: Math.max(rect.height || 400, 100),
129
+ };
130
+ }
131
+
132
+ function compile(): SankeyLayout {
133
+ const { width, height } = getContainerDimensions();
134
+ const darkMode = resolveDarkMode(options?.darkMode);
135
+
136
+ const compileOpts: CompileOptions = {
137
+ width,
138
+ height,
139
+ theme: options?.theme,
140
+ darkMode,
141
+ };
142
+
143
+ return compileSankey(currentSpec, compileOpts);
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Tooltip and interaction wiring
148
+ // ---------------------------------------------------------------------------
149
+
150
+ function wireTooltipAndInteraction(svg: SVGSVGElement, layout: SankeyLayout): () => void {
151
+ const cleanups: Array<() => void> = [];
152
+
153
+ // Wire tooltip on node elements
154
+ const nodeElements = svg.querySelectorAll('.oc-sankey-node');
155
+ for (const el of nodeElements) {
156
+ const markId = el.getAttribute('data-mark-id');
157
+ if (!markId) continue;
158
+
159
+ const content = layout.tooltipDescriptors.get(markId);
160
+ const nodeId = el.getAttribute('data-node-id');
161
+ const nodeData = nodeId ? (layout.nodes.find((n) => n.nodeId === nodeId)?.data ?? {}) : {};
162
+
163
+ const handleMouseEnter = (e: Event) => {
164
+ const mouseEvent = e as MouseEvent;
165
+ if (content && tooltipManager) {
166
+ const svgRect = svg.getBoundingClientRect();
167
+ const x = mouseEvent.clientX - svgRect.left;
168
+ const y = mouseEvent.clientY - svgRect.top;
169
+ tooltipManager.show(content, x, y);
170
+ }
171
+ options?.onNodeHover?.(nodeData);
172
+ if (nodeId) highlightConnectedLinks(svg, nodeId, layout);
173
+ };
174
+
175
+ const handleMouseMove = (e: Event) => {
176
+ if (content && tooltipManager) {
177
+ const mouseEvent = e as MouseEvent;
178
+ const svgRect = svg.getBoundingClientRect();
179
+ const x = mouseEvent.clientX - svgRect.left;
180
+ const y = mouseEvent.clientY - svgRect.top;
181
+ tooltipManager.show(content, x, y);
182
+ }
183
+ };
184
+
185
+ const handleMouseLeave = () => {
186
+ tooltipManager?.hide();
187
+ options?.onNodeHover?.(null);
188
+ resetLinkOpacity(svg, layout);
189
+ };
190
+
191
+ const handleClick = () => {
192
+ options?.onNodeClick?.(nodeData);
193
+ };
194
+
195
+ el.addEventListener('mouseenter', handleMouseEnter);
196
+ el.addEventListener('mousemove', handleMouseMove);
197
+ el.addEventListener('mouseleave', handleMouseLeave);
198
+ el.addEventListener('click', handleClick);
199
+
200
+ cleanups.push(() => {
201
+ el.removeEventListener('mouseenter', handleMouseEnter);
202
+ el.removeEventListener('mousemove', handleMouseMove);
203
+ el.removeEventListener('mouseleave', handleMouseLeave);
204
+ el.removeEventListener('click', handleClick);
205
+ });
206
+ }
207
+
208
+ // Wire tooltip on link elements
209
+ const linkElements = svg.querySelectorAll('.oc-sankey-link');
210
+ for (const el of linkElements) {
211
+ const markId = el.getAttribute('data-mark-id');
212
+ if (!markId) continue;
213
+
214
+ const content = layout.tooltipDescriptors.get(markId);
215
+ const sourceId = el.getAttribute('data-source');
216
+ const targetId = el.getAttribute('data-target');
217
+ const linkData = findLinkData(layout, sourceId, targetId);
218
+
219
+ const handleMouseEnter = (e: Event) => {
220
+ const mouseEvent = e as MouseEvent;
221
+ if (content && tooltipManager) {
222
+ const svgRect = svg.getBoundingClientRect();
223
+ const x = mouseEvent.clientX - svgRect.left;
224
+ const y = mouseEvent.clientY - svgRect.top;
225
+ tooltipManager.show(content, x, y);
226
+ }
227
+ options?.onLinkHover?.(linkData);
228
+ };
229
+
230
+ const handleMouseMove = (e: Event) => {
231
+ if (content && tooltipManager) {
232
+ const mouseEvent = e as MouseEvent;
233
+ const svgRect = svg.getBoundingClientRect();
234
+ const x = mouseEvent.clientX - svgRect.left;
235
+ const y = mouseEvent.clientY - svgRect.top;
236
+ tooltipManager.show(content, x, y);
237
+ }
238
+ };
239
+
240
+ const handleMouseLeave = () => {
241
+ tooltipManager?.hide();
242
+ options?.onLinkHover?.(null);
243
+ };
244
+
245
+ const handleClick = () => {
246
+ options?.onLinkClick?.(linkData);
247
+ };
248
+
249
+ el.addEventListener('mouseenter', handleMouseEnter);
250
+ el.addEventListener('mousemove', handleMouseMove);
251
+ el.addEventListener('mouseleave', handleMouseLeave);
252
+ el.addEventListener('click', handleClick);
253
+
254
+ cleanups.push(() => {
255
+ el.removeEventListener('mouseenter', handleMouseEnter);
256
+ el.removeEventListener('mousemove', handleMouseMove);
257
+ el.removeEventListener('mouseleave', handleMouseLeave);
258
+ el.removeEventListener('click', handleClick);
259
+ });
260
+ }
261
+
262
+ return () => {
263
+ for (const cleanup of cleanups) {
264
+ cleanup();
265
+ }
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Find the data record for a link by source/target ids.
271
+ */
272
+ function findLinkData(
273
+ layout: SankeyLayout,
274
+ sourceId: string | null,
275
+ targetId: string | null,
276
+ ): Record<string, unknown> {
277
+ if (!sourceId || !targetId) return {};
278
+ const link = layout.links.find((l) => l.sourceId === sourceId && l.targetId === targetId);
279
+ return link?.data ?? {};
280
+ }
281
+
282
+ /**
283
+ * Highlight links connected to a node and dim unconnected links.
284
+ */
285
+ function highlightConnectedLinks(
286
+ svg: SVGSVGElement,
287
+ nodeId: string,
288
+ _layout: SankeyLayout,
289
+ ): void {
290
+ const linkElements = svg.querySelectorAll('.oc-sankey-link');
291
+ for (const el of linkElements) {
292
+ const source = el.getAttribute('data-source');
293
+ const target = el.getAttribute('data-target');
294
+ const path = el.querySelector('path');
295
+ if (!path) continue;
296
+
297
+ const isConnected = source === nodeId || target === nodeId;
298
+ path.setAttribute('fill-opacity', String(isConnected ? HIGHLIGHT_OPACITY : DIM_OPACITY));
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Reset all link opacities to their original values.
304
+ */
305
+ function resetLinkOpacity(svg: SVGSVGElement, layout: SankeyLayout): void {
306
+ const linkElements = svg.querySelectorAll('.oc-sankey-link');
307
+ for (const el of linkElements) {
308
+ const path = el.querySelector('path');
309
+ if (!path) continue;
310
+ // Look up the original opacity via data attributes rather than positional index
311
+ const source = el.getAttribute('data-source');
312
+ const target = el.getAttribute('data-target');
313
+ const link = layout.links.find((l) => l.sourceId === source && l.targetId === target);
314
+ path.setAttribute('fill-opacity', String(link?.fillOpacity ?? 0.35));
315
+ }
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Render
320
+ // ---------------------------------------------------------------------------
321
+
322
+ function render(): void {
323
+ if (destroyed) return;
324
+
325
+ // Cancel in-progress animations before tearing down
326
+ if (animationCleanup) {
327
+ animationCleanup();
328
+ animationCleanup = null;
329
+ }
330
+ if (svgElement) {
331
+ cancelAnimations(svgElement);
332
+ }
333
+
334
+ // Clean up previous tooltip listeners
335
+ if (cleanupTooltipEvents) {
336
+ cleanupTooltipEvents();
337
+ cleanupTooltipEvents = null;
338
+ }
339
+
340
+ // Remove old SVG
341
+ if (svgElement?.parentNode) {
342
+ svgElement.parentNode.removeChild(svgElement);
343
+ }
344
+
345
+ // Compile
346
+ currentLayout = compile();
347
+
348
+ // Determine if we should animate
349
+ const shouldAnimate = isFirstRender && currentLayout.animation?.enabled;
350
+ isFirstRender = false;
351
+
352
+ // Render
353
+ const animation = shouldAnimate ? currentLayout.animation : undefined;
354
+ svgElement = renderSankeySVG(currentLayout, animation);
355
+ container.appendChild(svgElement);
356
+
357
+ // Dark mode class on container
358
+ const isDark = resolveDarkMode(options?.darkMode);
359
+ if (isDark) {
360
+ container.classList.add('oc-dark');
361
+ } else {
362
+ container.classList.remove('oc-dark');
363
+ }
364
+
365
+ // Wire tooltip + interaction events
366
+ if (options?.tooltip !== false && svgElement) {
367
+ if (!tooltipManager) {
368
+ tooltipManager = createTooltipManager(container);
369
+ }
370
+ cleanupTooltipEvents = wireTooltipAndInteraction(svgElement, currentLayout);
371
+ }
372
+
373
+ // Animation cleanup on first render
374
+ if (shouldAnimate && svgElement) {
375
+ animationCleanup = setupAnimationCleanup(svgElement, () => {
376
+ animationCleanup = null;
377
+ if (pendingResize) {
378
+ pendingResize = false;
379
+ resize();
380
+ }
381
+ });
382
+ }
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Public API
387
+ // ---------------------------------------------------------------------------
388
+
389
+ function update(newSpec: SankeySpec): void {
390
+ if (destroyed) return;
391
+ currentSpec = newSpec;
392
+ isFirstRender = true; // Allow animation on update
393
+ render();
394
+ }
395
+
396
+ function resize(): void {
397
+ if (destroyed) return;
398
+ // Skip resize during entrance animation to avoid tearing down the animated SVG.
399
+ // Queued resizes replay once animation completes.
400
+ if (animationCleanup) {
401
+ pendingResize = true;
402
+ return;
403
+ }
404
+ render();
405
+ }
406
+
407
+ function doExport(
408
+ format: 'svg' | 'svg-with-fonts' | 'png' | 'jpg',
409
+ exportOptions?: JPGExportOptions,
410
+ ): string | Promise<Blob> | Promise<string> {
411
+ if (!svgElement) {
412
+ throw new Error('Sankey is not rendered yet');
413
+ }
414
+
415
+ switch (format) {
416
+ case 'svg':
417
+ return exportSVG(svgElement);
418
+ case 'svg-with-fonts':
419
+ return exportSVGWithFonts(svgElement, exportOptions as SVGExportOptions);
420
+ case 'png':
421
+ return exportPNG(svgElement, exportOptions);
422
+ case 'jpg':
423
+ return exportJPG(svgElement, exportOptions);
424
+ default:
425
+ throw new Error(`Unsupported export format: ${format}`);
426
+ }
427
+ }
428
+
429
+ function destroy(): void {
430
+ if (destroyed) return;
431
+ destroyed = true;
432
+
433
+ // Cancel entrance animations
434
+ if (animationCleanup) {
435
+ animationCleanup();
436
+ animationCleanup = null;
437
+ pendingResize = false;
438
+ }
439
+ if (svgElement) {
440
+ cancelAnimations(svgElement);
441
+ }
442
+
443
+ // Disconnect resize observer
444
+ if (disconnectResize) {
445
+ disconnectResize();
446
+ disconnectResize = null;
447
+ }
448
+
449
+ // Clean up tooltip events
450
+ if (cleanupTooltipEvents) {
451
+ cleanupTooltipEvents();
452
+ cleanupTooltipEvents = null;
453
+ }
454
+
455
+ // Destroy tooltip manager
456
+ if (tooltipManager) {
457
+ tooltipManager.destroy();
458
+ tooltipManager = null;
459
+ }
460
+
461
+ // Remove SVG
462
+ if (svgElement?.parentNode) {
463
+ svgElement.parentNode.removeChild(svgElement);
464
+ }
465
+ svgElement = null;
466
+
467
+ container.classList.remove('oc-dark');
468
+ }
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // Initialize
472
+ // ---------------------------------------------------------------------------
473
+
474
+ try {
475
+ currentLayout = compile();
476
+
477
+ // Determine if we should animate
478
+ const shouldAnimate = currentLayout.animation?.enabled;
479
+ isFirstRender = false;
480
+
481
+ // Render
482
+ const animation = shouldAnimate ? currentLayout.animation : undefined;
483
+ svgElement = renderSankeySVG(currentLayout, animation);
484
+ container.appendChild(svgElement);
485
+
486
+ // Dark mode class on container
487
+ const isDark = resolveDarkMode(options?.darkMode);
488
+ if (isDark) {
489
+ container.classList.add('oc-dark');
490
+ } else {
491
+ container.classList.remove('oc-dark');
492
+ }
493
+
494
+ // Wire tooltip + interaction events
495
+ if (options?.tooltip !== false && svgElement) {
496
+ tooltipManager = createTooltipManager(container);
497
+ cleanupTooltipEvents = wireTooltipAndInteraction(svgElement, currentLayout);
498
+ }
499
+
500
+ // Animation cleanup
501
+ if (shouldAnimate && svgElement) {
502
+ animationCleanup = setupAnimationCleanup(svgElement, () => {
503
+ animationCleanup = null;
504
+ if (pendingResize) {
505
+ pendingResize = false;
506
+ resize();
507
+ }
508
+ });
509
+ }
510
+ } catch (err) {
511
+ console.error('[viz] Sankey mount failed:', err);
512
+ // Re-throw so callers can handle the error rather than silently returning a broken instance
513
+ throw err;
514
+ }
515
+
516
+ // Responsive resize
517
+ if (options?.responsive !== false) {
518
+ disconnectResize = observeResize(container, () => {
519
+ resize();
520
+ });
521
+ }
522
+
523
+ return {
524
+ update,
525
+ resize,
526
+ export: doExport,
527
+ destroy,
528
+ get layout() {
529
+ return currentLayout;
530
+ },
531
+ };
532
+ }