@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.
Files changed (44) hide show
  1. package/dist/index.d.ts +327 -0
  2. package/dist/index.js +4745 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/simulation-worker.js +1196 -0
  5. package/package.json +58 -0
  6. package/src/__test-fixtures__/dom.ts +42 -0
  7. package/src/__test-fixtures__/specs.ts +187 -0
  8. package/src/__tests__/edit-events.test.ts +747 -0
  9. package/src/__tests__/events.test.ts +336 -0
  10. package/src/__tests__/export.test.ts +150 -0
  11. package/src/__tests__/mount.test.ts +219 -0
  12. package/src/__tests__/svg-renderer.test.ts +609 -0
  13. package/src/__tests__/table-mount.test.ts +484 -0
  14. package/src/__tests__/tooltip.test.ts +201 -0
  15. package/src/export.ts +105 -0
  16. package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
  17. package/src/graph/__tests__/graph-mount.test.ts +213 -0
  18. package/src/graph/__tests__/interaction.test.ts +205 -0
  19. package/src/graph/__tests__/keyboard.test.ts +653 -0
  20. package/src/graph/__tests__/search.test.ts +88 -0
  21. package/src/graph/__tests__/simulation.test.ts +233 -0
  22. package/src/graph/__tests__/spatial-index.test.ts +142 -0
  23. package/src/graph/__tests__/zoom.test.ts +195 -0
  24. package/src/graph/canvas-renderer.ts +660 -0
  25. package/src/graph/interaction.ts +359 -0
  26. package/src/graph/keyboard.ts +208 -0
  27. package/src/graph/search.ts +50 -0
  28. package/src/graph/simulation-worker-url.ts +30 -0
  29. package/src/graph/simulation-worker.ts +265 -0
  30. package/src/graph/simulation.ts +350 -0
  31. package/src/graph/spatial-index.ts +121 -0
  32. package/src/graph/types.ts +44 -0
  33. package/src/graph/worker-protocol.ts +67 -0
  34. package/src/graph/zoom.ts +104 -0
  35. package/src/graph-mount.ts +675 -0
  36. package/src/index.ts +56 -0
  37. package/src/mount.ts +1639 -0
  38. package/src/renderers/table-cells.ts +444 -0
  39. package/src/resize-observer.ts +46 -0
  40. package/src/svg-renderer.ts +914 -0
  41. package/src/table-keyboard.ts +266 -0
  42. package/src/table-mount.ts +532 -0
  43. package/src/table-renderer.ts +350 -0
  44. package/src/tooltip.ts +120 -0
@@ -0,0 +1,675 @@
1
+ /**
2
+ * Graph mount API: the main entry point for vanilla JS graph usage.
3
+ *
4
+ * createGraph() takes a container, GraphSpec, and options, compiles the graph,
5
+ * creates a force simulation, canvas renderer, spatial index, interaction
6
+ * manager, and search manager, then runs an animation loop driven by
7
+ * simulation ticks. Returns a GraphInstance with update/search/zoom/destroy.
8
+ */
9
+
10
+ import type { CompileOptions, DarkMode, GraphSpec, ThemeConfig } from '@opendata-ai/openchart-core';
11
+ import type {
12
+ CompiledGraphEdge,
13
+ CompiledGraphNode,
14
+ GraphCompilation,
15
+ } from '@opendata-ai/openchart-engine';
16
+ import { compileGraph } from '@opendata-ai/openchart-engine';
17
+
18
+ import { GraphCanvasRenderer } from './graph/canvas-renderer';
19
+ import { GraphInteractionManager } from './graph/interaction';
20
+ import { attachGraphKeyboardNav } from './graph/keyboard';
21
+ import { GraphSearchManager } from './graph/search';
22
+ import { SimulationManager } from './graph/simulation';
23
+ import { SpatialIndex } from './graph/spatial-index';
24
+ import type { GraphRenderState, PositionedEdge, PositionedNode } from './graph/types';
25
+ import type { SimEdge, SimNode } from './graph/worker-protocol';
26
+ import { ZoomTransform } from './graph/zoom';
27
+ import { observeResize } from './resize-observer';
28
+ import { createTooltipManager, type TooltipManager } from './tooltip';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Types
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export interface GraphMountOptions {
35
+ theme?: ThemeConfig;
36
+ darkMode?: DarkMode;
37
+ responsive?: boolean;
38
+ onNodeClick?: (node: Record<string, unknown>) => void;
39
+ onNodeDoubleClick?: (node: Record<string, unknown>) => void;
40
+ onSelectionChange?: (nodeIds: string[]) => void;
41
+ }
42
+
43
+ export interface GraphInstance {
44
+ update(spec: GraphSpec): void;
45
+ search(query: string): void;
46
+ clearSearch(): void;
47
+ zoomToFit(): void;
48
+ zoomToNode(nodeId: string): void;
49
+ selectNode(nodeId: string): void;
50
+ getSelectedNodes(): string[];
51
+ resize(): void;
52
+ destroy(): void;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Dark mode resolution
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function resolveDarkMode(mode?: DarkMode): boolean {
60
+ if (mode === 'force') return true;
61
+ if (mode === 'off' || mode === undefined) return false;
62
+ if (typeof window !== 'undefined' && window.matchMedia) {
63
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Main API
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Create a graph instance from a spec and mount it into a container.
74
+ *
75
+ * @param container - The DOM element to render into.
76
+ * @param spec - The graph spec.
77
+ * @param options - Mount options.
78
+ * @returns A GraphInstance with update/search/zoom/destroy methods.
79
+ */
80
+ export function createGraph(
81
+ container: HTMLElement,
82
+ spec: GraphSpec,
83
+ options?: GraphMountOptions,
84
+ ): GraphInstance {
85
+ let currentSpec = spec;
86
+ let compilation: GraphCompilation;
87
+ let destroyed = false;
88
+
89
+ // DOM elements
90
+ let wrapper: HTMLElement | null = null;
91
+ let canvas: HTMLCanvasElement | null = null;
92
+ let chromeEl: HTMLElement | null = null;
93
+ let legendEl: HTMLElement | null = null;
94
+
95
+ // Subsystems
96
+ let renderer: GraphCanvasRenderer | null = null;
97
+ let simulation: SimulationManager | null = null;
98
+ const spatialIndex = new SpatialIndex();
99
+ let interactionManager: GraphInteractionManager | null = null;
100
+ const searchManager = new GraphSearchManager();
101
+ let tooltipManager: TooltipManager | null = null;
102
+ let cleanupKeyboard: (() => void) | null = null;
103
+ let disconnectResize: (() => void) | null = null;
104
+
105
+ // State
106
+ let positionedNodes: PositionedNode[] = [];
107
+ let positionedEdges: PositionedEdge[] = [];
108
+ let adjacencyMap = new Map<string, Set<string>>();
109
+ let hoveredNodeId: string | null = null;
110
+ let selectedNodeIds = new Set<string>();
111
+ let animFrameId: number | null = null;
112
+ let needsRender = false;
113
+ let isGesturing = false;
114
+ let gestureTimeout: ReturnType<typeof setTimeout> | null = null;
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Helpers
118
+ // ---------------------------------------------------------------------------
119
+
120
+ function markGesture(): void {
121
+ isGesturing = true;
122
+ if (gestureTimeout !== null) clearTimeout(gestureTimeout);
123
+ gestureTimeout = setTimeout(() => {
124
+ isGesturing = false;
125
+ gestureTimeout = null;
126
+ needsRender = true;
127
+ scheduleRender();
128
+ }, 150);
129
+ }
130
+
131
+ function getContainerDimensions(): { width: number; height: number } {
132
+ const rect = container.getBoundingClientRect();
133
+ return {
134
+ width: Math.max(rect.width || 600, 100),
135
+ height: Math.max(rect.height || 400, 100),
136
+ };
137
+ }
138
+
139
+ function compile(): GraphCompilation {
140
+ const { width, height } = getContainerDimensions();
141
+ const darkMode = resolveDarkMode(options?.darkMode);
142
+
143
+ const compileOpts: CompileOptions = {
144
+ width,
145
+ height,
146
+ theme: options?.theme,
147
+ darkMode,
148
+ };
149
+
150
+ return compileGraph(currentSpec, compileOpts);
151
+ }
152
+
153
+ function buildAdjacencyMap(edges: CompiledGraphEdge[]): Map<string, Set<string>> {
154
+ const map = new Map<string, Set<string>>();
155
+ for (const edge of edges) {
156
+ if (!map.has(edge.source)) map.set(edge.source, new Set());
157
+ if (!map.has(edge.target)) map.set(edge.target, new Set());
158
+ map.get(edge.source)!.add(edge.target);
159
+ map.get(edge.target)!.add(edge.source);
160
+ }
161
+ return map;
162
+ }
163
+
164
+ function toSimNodes(nodes: CompiledGraphNode[]): SimNode[] {
165
+ return nodes.map((n) => ({
166
+ id: n.id,
167
+ radius: n.radius,
168
+ community: n.community,
169
+ }));
170
+ }
171
+
172
+ function toSimEdges(edges: CompiledGraphEdge[]): SimEdge[] {
173
+ return edges.map((e) => ({
174
+ source: e.source,
175
+ target: e.target,
176
+ }));
177
+ }
178
+
179
+ /**
180
+ * Look up a node's data from the compilation by id.
181
+ * Falls back to an empty object if not found.
182
+ */
183
+ function nodeDataById(nodeId: string): Record<string, unknown> {
184
+ const node = compilation.nodes.find((n) => n.id === nodeId);
185
+ return node?.data ?? {};
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // DOM creation
190
+ // ---------------------------------------------------------------------------
191
+
192
+ function createDOM(): void {
193
+ const { width, height } = getContainerDimensions();
194
+ const isDark = resolveDarkMode(options?.darkMode);
195
+
196
+ // Wrapper
197
+ wrapper = document.createElement('div');
198
+ wrapper.className = 'viz-graph-wrapper';
199
+ if (isDark) {
200
+ container.classList.add('viz-dark');
201
+ } else {
202
+ container.classList.remove('viz-dark');
203
+ }
204
+
205
+ // Chrome (title, subtitle)
206
+ chromeEl = document.createElement('div');
207
+ chromeEl.className = 'viz-graph-chrome';
208
+ renderChrome();
209
+ wrapper.appendChild(chromeEl);
210
+
211
+ // Canvas
212
+ canvas = document.createElement('canvas');
213
+ canvas.className = 'viz-graph-canvas';
214
+ canvas.setAttribute('role', 'img');
215
+ if (compilation.a11y?.altText) {
216
+ canvas.setAttribute('aria-label', compilation.a11y.altText);
217
+ }
218
+ wrapper.appendChild(canvas);
219
+
220
+ // Legend
221
+ legendEl = document.createElement('div');
222
+ legendEl.className = 'viz-graph-legend';
223
+ renderLegend();
224
+ wrapper.appendChild(legendEl);
225
+
226
+ container.appendChild(wrapper);
227
+
228
+ // Size the canvas
229
+ const chromeHeight = chromeEl.getBoundingClientRect().height || 0;
230
+ const canvasHeight = Math.max(height - chromeHeight, 200);
231
+ renderer = new GraphCanvasRenderer(canvas);
232
+ renderer.resize(width, canvasHeight);
233
+ }
234
+
235
+ function renderChrome(): void {
236
+ if (!chromeEl) return;
237
+ let html = '';
238
+
239
+ if (compilation.chrome.title) {
240
+ html += `<h2 class="viz-title">${escapeHtml(compilation.chrome.title.text)}</h2>`;
241
+ }
242
+ if (compilation.chrome.subtitle) {
243
+ html += `<p class="viz-subtitle">${escapeHtml(compilation.chrome.subtitle.text)}</p>`;
244
+ }
245
+
246
+ chromeEl.innerHTML = html;
247
+
248
+ // Hide chrome if empty
249
+ if (!html) {
250
+ chromeEl.style.display = 'none';
251
+ } else {
252
+ chromeEl.style.display = '';
253
+ }
254
+ }
255
+
256
+ function renderLegend(): void {
257
+ if (!legendEl) return;
258
+
259
+ const entries = compilation.legend.entries;
260
+ if (entries.length === 0) {
261
+ legendEl.style.display = 'none';
262
+ return;
263
+ }
264
+
265
+ legendEl.style.display = '';
266
+ let html = '';
267
+ for (const entry of entries) {
268
+ html += '<div class="viz-graph-legend-item">';
269
+ html += `<span class="viz-graph-legend-swatch" style="background:${escapeHtml(entry.color)}"></span>`;
270
+ html += `<span>${escapeHtml(entry.label)}</span>`;
271
+ html += '</div>';
272
+ }
273
+ legendEl.innerHTML = html;
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Simulation and animation
278
+ // ---------------------------------------------------------------------------
279
+
280
+ function initSimulation(): void {
281
+ const simNodes = toSimNodes(compilation.nodes);
282
+ const simEdges = toSimEdges(compilation.edges);
283
+ const config = compilation.simulationConfig;
284
+
285
+ simulation = SimulationManager.create(simNodes, simEdges, {
286
+ chargeStrength: config.chargeStrength,
287
+ linkDistance: config.linkDistance,
288
+ clustering: config.clustering,
289
+ alphaDecay: config.alphaDecay,
290
+ velocityDecay: config.velocityDecay,
291
+ collisionRadius: config.collisionRadius,
292
+ });
293
+
294
+ simulation.onTick((positions, _alpha) => {
295
+ if (destroyed) return;
296
+
297
+ // Build position lookup
298
+ const posMap = new Map<string, { x: number; y: number }>();
299
+ for (const p of positions) {
300
+ posMap.set(p.id, { x: p.x, y: p.y });
301
+ }
302
+
303
+ // Build positioned nodes
304
+ positionedNodes = compilation.nodes.map((node) => {
305
+ const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
306
+ return { ...node, x: pos.x, y: pos.y };
307
+ });
308
+
309
+ // Build positioned edges
310
+ positionedEdges = compilation.edges.map((edge) => {
311
+ const src = posMap.get(edge.source) ?? { x: 0, y: 0 };
312
+ const tgt = posMap.get(edge.target) ?? { x: 0, y: 0 };
313
+ return {
314
+ ...edge,
315
+ sourceX: src.x,
316
+ sourceY: src.y,
317
+ targetX: tgt.x,
318
+ targetY: tgt.y,
319
+ };
320
+ });
321
+
322
+ // Rebuild spatial index
323
+ spatialIndex.rebuild(positionedNodes);
324
+
325
+ needsRender = true;
326
+ scheduleRender();
327
+ });
328
+
329
+ simulation.onSettled(() => {
330
+ // One final fit after simulation settles
331
+ if (canvas && positionedNodes.length > 0 && interactionManager) {
332
+ const { width: cw, height: ch } = getCanvasDimensions();
333
+ const fitTransform = ZoomTransform.fitBounds(positionedNodes, cw, ch);
334
+ interactionManager.setTransform(fitTransform);
335
+ needsRender = true;
336
+ scheduleRender();
337
+ }
338
+ });
339
+ }
340
+
341
+ function getCanvasDimensions(): { width: number; height: number } {
342
+ if (!canvas) return { width: 600, height: 400 };
343
+ const rect = canvas.getBoundingClientRect();
344
+ return {
345
+ width: Math.max(rect.width || 600, 100),
346
+ height: Math.max(rect.height || 400, 100),
347
+ };
348
+ }
349
+
350
+ function scheduleRender(): void {
351
+ if (animFrameId !== null || destroyed) return;
352
+ animFrameId = requestAnimationFrame(renderFrame);
353
+ }
354
+
355
+ function renderFrame(): void {
356
+ animFrameId = null;
357
+ if (destroyed || !renderer || !interactionManager) return;
358
+
359
+ if (needsRender) {
360
+ needsRender = false;
361
+
362
+ const transform = interactionManager.getTransform();
363
+ const state: GraphRenderState = {
364
+ nodes: positionedNodes,
365
+ edges: positionedEdges,
366
+ transform: { x: transform.x, y: transform.y, k: transform.k },
367
+ hoveredNodeId,
368
+ selectedNodeIds,
369
+ adjacencyMap,
370
+ theme: compilation.theme,
371
+ searchMatches: searchManager.getMatches(),
372
+ isGesturing,
373
+ };
374
+
375
+ renderer.render(state);
376
+ }
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // Interaction wiring
381
+ // ---------------------------------------------------------------------------
382
+
383
+ function initInteraction(): void {
384
+ if (!canvas) return;
385
+
386
+ tooltipManager = createTooltipManager(wrapper!);
387
+
388
+ interactionManager = new GraphInteractionManager(canvas, spatialIndex, {
389
+ onTransformChange(_transform) {
390
+ markGesture();
391
+ needsRender = true;
392
+ scheduleRender();
393
+ },
394
+ onHoverChange(nodeId) {
395
+ hoveredNodeId = nodeId;
396
+ needsRender = true;
397
+ scheduleRender();
398
+
399
+ // Show or hide tooltip
400
+ if (nodeId && tooltipManager) {
401
+ const content = compilation.tooltipDescriptors.get(nodeId);
402
+ if (content) {
403
+ const node = positionedNodes.find((n) => n.id === nodeId);
404
+ if (node && interactionManager) {
405
+ const screen = interactionManager.getTransform().graphToScreen(node.x, node.y);
406
+ tooltipManager.show(content, screen.x, screen.y);
407
+ }
408
+ }
409
+ } else {
410
+ tooltipManager?.hide();
411
+ }
412
+ },
413
+ onSelectionChange(nodeIds) {
414
+ selectedNodeIds = new Set(nodeIds);
415
+ needsRender = true;
416
+ scheduleRender();
417
+ options?.onSelectionChange?.(nodeIds);
418
+
419
+ // Fire onNodeClick for the most recently added node
420
+ if (nodeIds.length > 0) {
421
+ const lastId = nodeIds[nodeIds.length - 1];
422
+ options?.onNodeClick?.(nodeDataById(lastId));
423
+ }
424
+ },
425
+ onNodeDragStart(nodeId) {
426
+ // Pin at the node's current position to avoid visual snap to origin
427
+ const node = positionedNodes.find((n) => n.id === nodeId);
428
+ const x = node?.x ?? 0;
429
+ const y = node?.y ?? 0;
430
+ simulation?.pinNode(nodeId, x, y);
431
+ canvas?.classList.add('viz-graph-canvas--dragging');
432
+ },
433
+ onNodeDrag(nodeId, x, y) {
434
+ simulation?.dragNode(nodeId, x, y);
435
+ },
436
+ onNodeDragEnd(nodeId) {
437
+ simulation?.unpinNode(nodeId);
438
+ canvas?.classList.remove('viz-graph-canvas--dragging');
439
+ },
440
+ onDoubleClick(nodeId) {
441
+ options?.onNodeDoubleClick?.(nodeDataById(nodeId));
442
+ },
443
+ });
444
+
445
+ // Wire keyboard navigation
446
+ cleanupKeyboard = attachGraphKeyboardNav({
447
+ canvas,
448
+ getNodes: () => positionedNodes,
449
+ getSelectedIds: () => [...selectedNodeIds],
450
+ getAdjacency: () => adjacencyMap,
451
+ onSelect(nodeId) {
452
+ selectedNodeIds = new Set([nodeId]);
453
+ needsRender = true;
454
+ scheduleRender();
455
+ options?.onNodeClick?.(nodeDataById(nodeId));
456
+ options?.onSelectionChange?.([nodeId]);
457
+ },
458
+ onDeselect() {
459
+ selectedNodeIds.clear();
460
+ needsRender = true;
461
+ scheduleRender();
462
+ options?.onSelectionChange?.([]);
463
+ },
464
+ onZoom(direction) {
465
+ if (!interactionManager || !canvas) return;
466
+ const t = interactionManager.getTransform();
467
+ const { width: cw, height: ch } = getCanvasDimensions();
468
+ const factor = direction === 'in' ? 1.2 : 0.8;
469
+ const newK = t.k * factor;
470
+ const newTransform = t.zoomAt(newK, cw / 2, ch / 2);
471
+ interactionManager.setTransform(newTransform);
472
+ needsRender = true;
473
+ scheduleRender();
474
+ },
475
+ onFitAll() {
476
+ zoomToFit();
477
+ },
478
+ });
479
+
480
+ // Handle node clicks (from interaction manager selection change wiring above)
481
+ // We catch clicks via the interaction manager's onSelectionChange callback
482
+ }
483
+
484
+ // ---------------------------------------------------------------------------
485
+ // Public API methods
486
+ // ---------------------------------------------------------------------------
487
+
488
+ function search(query: string): void {
489
+ if (destroyed) return;
490
+ searchManager.search(query, positionedNodes);
491
+ needsRender = true;
492
+ scheduleRender();
493
+ }
494
+
495
+ function clearSearch(): void {
496
+ if (destroyed) return;
497
+ searchManager.clearSearch();
498
+ needsRender = true;
499
+ scheduleRender();
500
+ }
501
+
502
+ function zoomToFit(): void {
503
+ if (destroyed || !interactionManager || positionedNodes.length === 0) return;
504
+ const { width: cw, height: ch } = getCanvasDimensions();
505
+ const fitTransform = ZoomTransform.fitBounds(positionedNodes, cw, ch);
506
+ interactionManager.setTransform(fitTransform);
507
+ needsRender = true;
508
+ scheduleRender();
509
+ }
510
+
511
+ function zoomToNode(nodeId: string): void {
512
+ if (destroyed || !interactionManager || !canvas) return;
513
+ const node = positionedNodes.find((n) => n.id === nodeId);
514
+ if (!node) return;
515
+
516
+ const { width: cw, height: ch } = getCanvasDimensions();
517
+ // Zoom to 2x and center on node
518
+ const k = 2;
519
+ const tx = cw / 2 - node.x * k;
520
+ const ty = ch / 2 - node.y * k;
521
+ const newTransform = new ZoomTransform(tx, ty, k);
522
+ interactionManager.setTransform(newTransform);
523
+ needsRender = true;
524
+ scheduleRender();
525
+ }
526
+
527
+ function selectNode(nodeId: string): void {
528
+ if (destroyed) return;
529
+ selectedNodeIds = new Set([nodeId]);
530
+ needsRender = true;
531
+ scheduleRender();
532
+ options?.onSelectionChange?.([nodeId]);
533
+ }
534
+
535
+ function getSelectedNodes(): string[] {
536
+ return [...selectedNodeIds];
537
+ }
538
+
539
+ function doResize(): void {
540
+ if (destroyed || !canvas || !renderer || !wrapper) return;
541
+ const { width, height } = getContainerDimensions();
542
+ const chromeHeight = chromeEl?.getBoundingClientRect().height || 0;
543
+ const canvasHeight = Math.max(height - chromeHeight, 200);
544
+ renderer.resize(width, canvasHeight);
545
+ needsRender = true;
546
+ scheduleRender();
547
+ }
548
+
549
+ function update(newSpec: GraphSpec): void {
550
+ if (destroyed) return;
551
+ currentSpec = newSpec;
552
+
553
+ // Tear down old simulation + interaction
554
+ teardownSubsystems();
555
+
556
+ // Recompile
557
+ compilation = compile();
558
+ adjacencyMap = buildAdjacencyMap(compilation.edges);
559
+
560
+ // Update DOM chrome/legend
561
+ renderChrome();
562
+ renderLegend();
563
+
564
+ // Reinit
565
+ initSimulation();
566
+ initInteraction();
567
+
568
+ // Reset state
569
+ hoveredNodeId = null;
570
+ selectedNodeIds = new Set();
571
+ searchManager.clearSearch();
572
+ }
573
+
574
+ function teardownSubsystems(): void {
575
+ if (animFrameId !== null) {
576
+ cancelAnimationFrame(animFrameId);
577
+ animFrameId = null;
578
+ }
579
+ if (cleanupKeyboard) {
580
+ cleanupKeyboard();
581
+ cleanupKeyboard = null;
582
+ }
583
+ interactionManager?.destroy();
584
+ interactionManager = null;
585
+ simulation?.destroy();
586
+ simulation = null;
587
+ tooltipManager?.destroy();
588
+ tooltipManager = null;
589
+ }
590
+
591
+ function destroy(): void {
592
+ if (destroyed) return;
593
+ destroyed = true;
594
+
595
+ if (gestureTimeout !== null) {
596
+ clearTimeout(gestureTimeout);
597
+ gestureTimeout = null;
598
+ }
599
+
600
+ teardownSubsystems();
601
+
602
+ if (disconnectResize) {
603
+ disconnectResize();
604
+ disconnectResize = null;
605
+ }
606
+
607
+ if (wrapper?.parentNode) {
608
+ wrapper.parentNode.removeChild(wrapper);
609
+ }
610
+ wrapper = null;
611
+ canvas = null;
612
+ chromeEl = null;
613
+ legendEl = null;
614
+ renderer = null;
615
+
616
+ container.classList.remove('viz-dark');
617
+ }
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // Initialize
621
+ // ---------------------------------------------------------------------------
622
+
623
+ try {
624
+ compilation = compile();
625
+ adjacencyMap = buildAdjacencyMap(compilation.edges);
626
+ createDOM();
627
+ initSimulation();
628
+ initInteraction();
629
+ } catch (err) {
630
+ console.error('[viz] Graph mount failed:', err);
631
+ // Return a no-op instance so callers don't crash
632
+ return {
633
+ update() {},
634
+ search() {},
635
+ clearSearch() {},
636
+ zoomToFit() {},
637
+ zoomToNode() {},
638
+ selectNode() {},
639
+ getSelectedNodes: () => [],
640
+ resize() {},
641
+ destroy() {},
642
+ };
643
+ }
644
+
645
+ // Responsive resize
646
+ if (options?.responsive !== false) {
647
+ disconnectResize = observeResize(container, () => {
648
+ doResize();
649
+ });
650
+ }
651
+
652
+ return {
653
+ update,
654
+ search,
655
+ clearSearch,
656
+ zoomToFit,
657
+ zoomToNode,
658
+ selectNode,
659
+ getSelectedNodes,
660
+ resize: doResize,
661
+ destroy,
662
+ };
663
+ }
664
+
665
+ // ---------------------------------------------------------------------------
666
+ // Util
667
+ // ---------------------------------------------------------------------------
668
+
669
+ function escapeHtml(str: string): string {
670
+ return str
671
+ .replace(/&/g, '&amp;')
672
+ .replace(/</g, '&lt;')
673
+ .replace(/>/g, '&gt;')
674
+ .replace(/"/g, '&quot;');
675
+ }