@opendata-ai/openchart-vanilla 6.5.2 → 6.7.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 +45 -2
- package/dist/index.js +757 -30
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +3 -3
- package/src/__tests__/sankey.test.ts +133 -0
- package/src/__tests__/svg-renderer.test.ts +6 -5
- package/src/graph/canvas-renderer.ts +1 -1
- package/src/index.ts +3 -0
- package/src/sankey-mount.ts +532 -0
- package/src/sankey-renderer.ts +602 -0
- package/src/svg-renderer.ts +15 -10
- package/src/table-renderer.ts +1 -1
|
@@ -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
|
+
}
|