@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21

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 (145) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/chunk-DCODCLWI.js +234 -0
  3. package/dist/chunk-DCODCLWI.js.map +1 -0
  4. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  5. package/dist/chunk-EKAUQAAX.js.map +1 -0
  6. package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
  7. package/dist/chunk-KNT5AZ2O.js.map +1 -0
  8. package/dist/chunk-RM5XS6NR.js +113 -0
  9. package/dist/chunk-RM5XS6NR.js.map +1 -0
  10. package/dist/chunk-T32UHMZA.js +282 -0
  11. package/dist/chunk-T32UHMZA.js.map +1 -0
  12. package/dist/export-gif.d.ts +113 -0
  13. package/dist/export-gif.js +21 -0
  14. package/dist/export-gif.js.map +1 -0
  15. package/dist/index.d.ts +283 -24
  16. package/dist/index.js +2012 -763
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.js +7 -69
  21. package/dist/static.js.map +1 -1
  22. package/dist/story/index.d.ts +3 -2
  23. package/dist/story/index.js +5 -3
  24. package/dist/story/index.js.map +1 -1
  25. package/dist/styles.css +1 -1
  26. package/dist/styles.css.map +1 -1
  27. package/package.json +14 -6
  28. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  29. package/dist/chunk-ENXSUXC5.js.map +0 -1
  30. package/src/__test-fixtures__/dom.ts +0 -70
  31. package/src/__test-fixtures__/specs.ts +0 -214
  32. package/src/__tests__/animation.test.ts +0 -358
  33. package/src/__tests__/annotations-render.test.ts +0 -201
  34. package/src/__tests__/compound-labels.test.ts +0 -122
  35. package/src/__tests__/continuous-legend.test.ts +0 -91
  36. package/src/__tests__/crosshair.test.ts +0 -130
  37. package/src/__tests__/edit-events.test.ts +0 -902
  38. package/src/__tests__/events.test.ts +0 -387
  39. package/src/__tests__/export.test.ts +0 -241
  40. package/src/__tests__/font-race.test.ts +0 -199
  41. package/src/__tests__/gradient-ids.test.ts +0 -159
  42. package/src/__tests__/gradient-utils.test.ts +0 -125
  43. package/src/__tests__/known-bugs-render.test.ts +0 -183
  44. package/src/__tests__/map.test.ts +0 -293
  45. package/src/__tests__/mount.test.ts +0 -357
  46. package/src/__tests__/pattern-fills.test.ts +0 -105
  47. package/src/__tests__/resize-timing.test.ts +0 -218
  48. package/src/__tests__/sankey.test.ts +0 -133
  49. package/src/__tests__/selection-events.test.ts +0 -869
  50. package/src/__tests__/series-search.test.ts +0 -311
  51. package/src/__tests__/static.test.ts +0 -141
  52. package/src/__tests__/svg-renderer.test.ts +0 -895
  53. package/src/__tests__/table-keyboard.test.ts +0 -361
  54. package/src/__tests__/table-mount.test.ts +0 -639
  55. package/src/__tests__/tilemap.test.ts +0 -158
  56. package/src/__tests__/tooltip.test.ts +0 -337
  57. package/src/__tests__/transition.test.ts +0 -1777
  58. package/src/__tests__/you-draw-it.test.ts +0 -273
  59. package/src/animation-vars.ts +0 -42
  60. package/src/animation.ts +0 -76
  61. package/src/barlist-mount.ts +0 -383
  62. package/src/barlist-renderer.ts +0 -267
  63. package/src/dom-helpers.ts +0 -17
  64. package/src/export.ts +0 -482
  65. package/src/gradient-utils.ts +0 -138
  66. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  67. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  68. package/src/graph/__tests__/interaction.test.ts +0 -205
  69. package/src/graph/__tests__/keyboard.test.ts +0 -653
  70. package/src/graph/__tests__/search.test.ts +0 -88
  71. package/src/graph/__tests__/simulation.test.ts +0 -233
  72. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  73. package/src/graph/__tests__/zoom.test.ts +0 -203
  74. package/src/graph/canvas-renderer.ts +0 -747
  75. package/src/graph/interaction.ts +0 -367
  76. package/src/graph/keyboard.ts +0 -208
  77. package/src/graph/search.ts +0 -50
  78. package/src/graph/simulation-worker-url.ts +0 -12
  79. package/src/graph/simulation-worker.ts +0 -278
  80. package/src/graph/simulation.ts +0 -422
  81. package/src/graph/spatial-index.ts +0 -121
  82. package/src/graph/types.ts +0 -47
  83. package/src/graph/worker-protocol.ts +0 -70
  84. package/src/graph/zoom.ts +0 -134
  85. package/src/graph-mount.ts +0 -888
  86. package/src/index.ts +0 -76
  87. package/src/interactions/chart-events.ts +0 -139
  88. package/src/interactions/crosshair.ts +0 -233
  89. package/src/interactions/drag-handler.ts +0 -175
  90. package/src/interactions/editing-drags.ts +0 -515
  91. package/src/interactions/index.ts +0 -25
  92. package/src/interactions/keyboard-nav.ts +0 -111
  93. package/src/interactions/legend-interaction.ts +0 -46
  94. package/src/interactions/selection.ts +0 -280
  95. package/src/interactions/tooltip-events.ts +0 -72
  96. package/src/map-camera.ts +0 -88
  97. package/src/map-mount.ts +0 -701
  98. package/src/map-renderer.ts +0 -338
  99. package/src/map-transition.ts +0 -83
  100. package/src/measure-text.ts +0 -134
  101. package/src/mount.ts +0 -1382
  102. package/src/pattern-utils.ts +0 -110
  103. package/src/renderers/annotations.ts +0 -388
  104. package/src/renderers/axes.ts +0 -351
  105. package/src/renderers/brand.ts +0 -87
  106. package/src/renderers/chrome.ts +0 -196
  107. package/src/renderers/endpoint-labels.ts +0 -150
  108. package/src/renderers/legend.ts +0 -311
  109. package/src/renderers/marks.ts +0 -561
  110. package/src/renderers/metrics.ts +0 -63
  111. package/src/renderers/svg-dom.ts +0 -40
  112. package/src/renderers/table-cells.ts +0 -446
  113. package/src/resize-observer.ts +0 -46
  114. package/src/sankey-mount.ts +0 -622
  115. package/src/sankey-renderer.ts +0 -614
  116. package/src/series-search.ts +0 -392
  117. package/src/static.ts +0 -251
  118. package/src/story/__tests__/camera-math.test.ts +0 -145
  119. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  120. package/src/story/__tests__/progress-math.test.ts +0 -95
  121. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  122. package/src/story/__tests__/tween.test.ts +0 -159
  123. package/src/story/camera-math.ts +0 -125
  124. package/src/story/create-chart-story.ts +0 -353
  125. package/src/story/crossfade.ts +0 -70
  126. package/src/story/index.ts +0 -39
  127. package/src/story/progress-math.ts +0 -85
  128. package/src/story/resolve-camera-target.ts +0 -119
  129. package/src/story/scroll-driver.ts +0 -206
  130. package/src/story/story-camera.ts +0 -49
  131. package/src/story/tween.ts +0 -112
  132. package/src/story/types.ts +0 -76
  133. package/src/svg-ids.ts +0 -23
  134. package/src/svg-renderer.ts +0 -383
  135. package/src/table-keyboard.ts +0 -266
  136. package/src/table-mount.ts +0 -567
  137. package/src/table-renderer.ts +0 -414
  138. package/src/text-edit-overlay.ts +0 -255
  139. package/src/theme-tokens.ts +0 -51
  140. package/src/tilemap-mount.ts +0 -511
  141. package/src/tilemap-renderer.ts +0 -396
  142. package/src/tooltip.ts +0 -177
  143. package/src/transition.ts +0 -2816
  144. package/src/you-draw-it.ts +0 -480
  145. /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
@@ -1,888 +0,0 @@
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
- /** Show the tryOpenData.ai watermark. Defaults to true. */
39
- watermark?: boolean;
40
- /** Show the built-in tooltip on node/edge hover. Defaults to true. */
41
- tooltip?: boolean;
42
- /** Show the built-in legend. Defaults to true. */
43
- legend?: boolean;
44
- onNodeClick?: (node: Record<string, unknown>) => void;
45
- onNodeDoubleClick?: (node: Record<string, unknown>) => void;
46
- onNodeHover?: (node: Record<string, unknown> | null) => void;
47
- onEdgeHover?: (edge: Record<string, unknown> | null) => void;
48
- onSelectionChange?: (nodeIds: string[]) => void;
49
- }
50
-
51
- export interface GraphInstance {
52
- update(spec: GraphSpec): void;
53
- /** Re-compile encoding/legend/chrome without restarting the simulation. Preserves node positions. */
54
- updateVisuals(spec: GraphSpec): void;
55
- search(query: string): void;
56
- clearSearch(): void;
57
- zoomToFit(): void;
58
- zoomToNode(nodeId: string): void;
59
- selectNode(nodeId: string): void;
60
- getSelectedNodes(): string[];
61
- resize(): void;
62
- destroy(): void;
63
- }
64
-
65
- // ---------------------------------------------------------------------------
66
- // Dark mode resolution
67
- // ---------------------------------------------------------------------------
68
-
69
- function resolveDarkMode(mode?: DarkMode): boolean {
70
- if (mode === 'force') return true;
71
- if (mode === 'off' || mode === undefined) return false;
72
- if (typeof window !== 'undefined' && window.matchMedia) {
73
- return window.matchMedia('(prefers-color-scheme: dark)').matches;
74
- }
75
- return false;
76
- }
77
-
78
- // ---------------------------------------------------------------------------
79
- // Main API
80
- // ---------------------------------------------------------------------------
81
-
82
- /**
83
- * Create a graph instance from a spec and mount it into a container.
84
- *
85
- * @param container - The DOM element to render into.
86
- * @param spec - The graph spec.
87
- * @param options - Mount options.
88
- * @returns A GraphInstance with update/search/zoom/destroy methods.
89
- */
90
- export function createGraph(
91
- container: HTMLElement,
92
- spec: GraphSpec,
93
- options?: GraphMountOptions,
94
- ): GraphInstance {
95
- let currentSpec = spec;
96
- let compilation: GraphCompilation;
97
- let destroyed = false;
98
-
99
- // DOM elements
100
- let wrapper: HTMLElement | null = null;
101
- let canvas: HTMLCanvasElement | null = null;
102
- let chromeEl: HTMLElement | null = null;
103
- let legendEl: HTMLElement | null = null;
104
-
105
- // Subsystems
106
- let renderer: GraphCanvasRenderer | null = null;
107
- let simulation: SimulationManager | null = null;
108
- const spatialIndex = new SpatialIndex();
109
- let interactionManager: GraphInteractionManager | null = null;
110
- const searchManager = new GraphSearchManager();
111
- let tooltipManager: TooltipManager | null = null;
112
- let cleanupKeyboard: (() => void) | null = null;
113
- let disconnectResize: (() => void) | null = null;
114
-
115
- // State
116
- let positionedNodes: PositionedNode[] = [];
117
- let positionedEdges: PositionedEdge[] = [];
118
- let adjacencyMap = new Map<string, Set<string>>();
119
- let nodeDataMap = new Map<string, Record<string, unknown>>();
120
- let edgeDataMap = new Map<string, Record<string, unknown>>();
121
- let hoveredNodeId: string | null = null;
122
- let hoveredEdgeId: string | null = null;
123
- let selectedNodeIds = new Set<string>();
124
- let animFrameId: number | null = null;
125
- let needsRender = false;
126
- let isGesturing = false;
127
- let gestureTimeout: ReturnType<typeof setTimeout> | null = null;
128
- let lastEdgeHitTime = 0;
129
-
130
- // ---------------------------------------------------------------------------
131
- // Helpers
132
- // ---------------------------------------------------------------------------
133
-
134
- function markGesture(): void {
135
- isGesturing = true;
136
- if (gestureTimeout !== null) clearTimeout(gestureTimeout);
137
- gestureTimeout = setTimeout(() => {
138
- isGesturing = false;
139
- gestureTimeout = null;
140
- needsRender = true;
141
- scheduleRender();
142
- }, 150);
143
- }
144
-
145
- function getContainerDimensions(): { width: number; height: number } {
146
- const rect = container.getBoundingClientRect();
147
- return {
148
- width: Math.max(rect.width || 600, 100),
149
- height: Math.max(rect.height || 400, 100),
150
- };
151
- }
152
-
153
- function compile(): GraphCompilation {
154
- const { width, height } = getContainerDimensions();
155
- const darkMode = resolveDarkMode(options?.darkMode);
156
-
157
- const compileOpts: CompileOptions = {
158
- width,
159
- height,
160
- theme: options?.theme,
161
- darkMode,
162
- watermark: options?.watermark,
163
- };
164
-
165
- return compileGraph(currentSpec, compileOpts);
166
- }
167
-
168
- function buildDataMaps(): void {
169
- nodeDataMap = new Map(compilation.nodes.map((n) => [n.id, n.data ?? {}]));
170
- edgeDataMap = new Map(compilation.edges.map((e) => [`${e.source}->${e.target}`, e.data ?? {}]));
171
- }
172
-
173
- function buildAdjacencyMap(edges: CompiledGraphEdge[]): Map<string, Set<string>> {
174
- const map = new Map<string, Set<string>>();
175
- for (const edge of edges) {
176
- if (!map.has(edge.source)) map.set(edge.source, new Set());
177
- if (!map.has(edge.target)) map.set(edge.target, new Set());
178
- map.get(edge.source)!.add(edge.target);
179
- map.get(edge.target)!.add(edge.source);
180
- }
181
- return map;
182
- }
183
-
184
- function toSimNodes(nodes: CompiledGraphNode[]): SimNode[] {
185
- return nodes.map((n) => ({
186
- id: n.id,
187
- radius: n.radius,
188
- community: n.community,
189
- }));
190
- }
191
-
192
- function toSimEdges(edges: CompiledGraphEdge[]): SimEdge[] {
193
- return edges.map((e) => ({
194
- source: e.source,
195
- target: e.target,
196
- }));
197
- }
198
-
199
- /**
200
- * Look up a node's data from the compilation by id.
201
- * Falls back to an empty object if not found.
202
- */
203
- function nodeDataById(nodeId: string): Record<string, unknown> {
204
- return nodeDataMap.get(nodeId) ?? {};
205
- }
206
-
207
- /**
208
- * Point-to-line-segment distance for edge hit testing.
209
- * Returns the shortest distance from point (px, py) to the segment (ax, ay)-(bx, by).
210
- */
211
- function pointToSegmentDist(
212
- px: number,
213
- py: number,
214
- ax: number,
215
- ay: number,
216
- bx: number,
217
- by: number,
218
- ): number {
219
- const dx = bx - ax;
220
- const dy = by - ay;
221
- const lenSq = dx * dx + dy * dy;
222
- if (lenSq === 0) return Math.hypot(px - ax, py - ay);
223
- const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
224
- return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
225
- }
226
-
227
- /**
228
- * Find the edge closest to a graph-space point, within a threshold.
229
- * Returns an edge key "source->target" or null.
230
- */
231
- function hitTestEdge(graphX: number, graphY: number, threshold: number): string | null {
232
- let bestDist = threshold;
233
- let bestEdgeId: string | null = null;
234
-
235
- for (const edge of positionedEdges) {
236
- const dist = pointToSegmentDist(
237
- graphX,
238
- graphY,
239
- edge.sourceX,
240
- edge.sourceY,
241
- edge.targetX,
242
- edge.targetY,
243
- );
244
- if (dist < bestDist) {
245
- bestDist = dist;
246
- bestEdgeId = `${edge.source}->${edge.target}`;
247
- }
248
- }
249
-
250
- return bestEdgeId;
251
- }
252
-
253
- /**
254
- * Look up edge data by edge id ("source->target").
255
- */
256
- function edgeDataById(edgeId: string): Record<string, unknown> | null {
257
- return edgeDataMap.get(edgeId) ?? null;
258
- }
259
-
260
- // ---------------------------------------------------------------------------
261
- // DOM creation
262
- // ---------------------------------------------------------------------------
263
-
264
- function createDOM(): void {
265
- const { width, height } = getContainerDimensions();
266
- const isDark = resolveDarkMode(options?.darkMode);
267
-
268
- // Wrapper
269
- wrapper = document.createElement('div');
270
- wrapper.className = isDark ? 'oc-graph-wrapper oc-dark' : 'oc-graph-wrapper';
271
- if (isDark) {
272
- container.classList.add('oc-dark');
273
- } else {
274
- container.classList.remove('oc-dark');
275
- }
276
-
277
- // Apply theme colors as CSS custom properties so chrome HTML picks them up.
278
- // Without this, consumer-supplied theme.colors.text only affects canvas-drawn
279
- // labels but not the HTML title/subtitle which read from --oc-text.
280
- const resolvedTheme = compilation.theme;
281
- if (resolvedTheme) {
282
- const s = wrapper.style;
283
- s.setProperty('--oc-bg', resolvedTheme.colors.background);
284
- s.setProperty('--oc-text', resolvedTheme.colors.text);
285
- s.setProperty('--oc-text-secondary', resolvedTheme.colors.axis ?? resolvedTheme.colors.text);
286
- s.setProperty('--oc-font-family', resolvedTheme.fonts.family);
287
- s.fontFamily = resolvedTheme.fonts.family;
288
- }
289
-
290
- // Chrome (title, subtitle)
291
- chromeEl = document.createElement('div');
292
- chromeEl.className = 'oc-graph-chrome';
293
- renderChrome();
294
- wrapper.appendChild(chromeEl);
295
-
296
- // Canvas
297
- canvas = document.createElement('canvas');
298
- canvas.className = 'oc-graph-canvas';
299
- canvas.setAttribute('role', 'img');
300
- if (compilation.a11y?.altText) {
301
- canvas.setAttribute('aria-label', compilation.a11y.altText);
302
- }
303
- wrapper.appendChild(canvas);
304
-
305
- // Legend
306
- if (options?.legend !== false) {
307
- legendEl = document.createElement('div');
308
- legendEl.className = 'oc-graph-legend';
309
- renderLegend();
310
- wrapper.appendChild(legendEl);
311
- }
312
-
313
- container.appendChild(wrapper);
314
-
315
- // Canvas uses the full container height; chrome overlays on top
316
- const canvasHeight = Math.max(height, 200);
317
- renderer = new GraphCanvasRenderer(canvas);
318
- renderer.resize(width, canvasHeight);
319
- }
320
-
321
- function renderChrome(): void {
322
- if (!chromeEl) return;
323
- let html = '';
324
-
325
- if (compilation.chrome.title) {
326
- html += `<h2 class="oc-title">${escapeHtml(compilation.chrome.title.text)}</h2>`;
327
- }
328
- if (compilation.chrome.subtitle) {
329
- html += `<p class="oc-subtitle">${escapeHtml(compilation.chrome.subtitle.text)}</p>`;
330
- }
331
-
332
- chromeEl.innerHTML = html;
333
-
334
- // Hide chrome if empty
335
- if (!html) {
336
- chromeEl.style.display = 'none';
337
- } else {
338
- chromeEl.style.display = '';
339
- }
340
- }
341
-
342
- function renderLegend(): void {
343
- if (!legendEl) return;
344
-
345
- const entries = 'entries' in compilation.legend ? compilation.legend.entries : [];
346
- if (entries.length === 0) {
347
- legendEl.style.display = 'none';
348
- return;
349
- }
350
-
351
- legendEl.style.display = '';
352
- let html = '';
353
- for (const entry of entries) {
354
- html += '<div class="oc-graph-legend-item">';
355
- html += `<span class="oc-graph-legend-swatch" style="background:${escapeHtml(entry.color)}"></span>`;
356
- html += `<span>${escapeHtml(entry.label)}</span>`;
357
- html += '</div>';
358
- }
359
- legendEl.innerHTML = html;
360
- }
361
-
362
- // ---------------------------------------------------------------------------
363
- // Simulation and animation
364
- // ---------------------------------------------------------------------------
365
-
366
- function initSimulation(): void {
367
- const simNodes = toSimNodes(compilation.nodes);
368
- const simEdges = toSimEdges(compilation.edges);
369
- const config = compilation.simulationConfig;
370
-
371
- simulation = SimulationManager.create(simNodes, simEdges, {
372
- chargeStrength: config.chargeStrength,
373
- linkDistance: config.linkDistance,
374
- clustering: config.clustering,
375
- alphaDecay: config.alphaDecay,
376
- velocityDecay: config.velocityDecay,
377
- collisionRadius: config.collisionRadius,
378
- collisionPadding: config.collisionPadding,
379
- linkStrength: config.linkStrength,
380
- centerForce: config.centerForce,
381
- });
382
-
383
- let initialSettleDone = false;
384
- let initialFitDone = false;
385
-
386
- simulation.onTick((positions, _alpha) => {
387
- if (destroyed) return;
388
-
389
- // Build position lookup
390
- const posMap = new Map<string, { x: number; y: number }>();
391
- for (const p of positions) {
392
- posMap.set(p.id, { x: p.x, y: p.y });
393
- }
394
-
395
- // Build positioned nodes
396
- positionedNodes = compilation.nodes.map((node) => {
397
- const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
398
- return { ...node, x: pos.x, y: pos.y };
399
- });
400
-
401
- // Build positioned edges
402
- positionedEdges = compilation.edges.map((edge) => {
403
- const src = posMap.get(edge.source) ?? { x: 0, y: 0 };
404
- const tgt = posMap.get(edge.target) ?? { x: 0, y: 0 };
405
- return {
406
- ...edge,
407
- sourceX: src.x,
408
- sourceY: src.y,
409
- targetX: tgt.x,
410
- targetY: tgt.y,
411
- };
412
- });
413
-
414
- // Rebuild spatial index
415
- spatialIndex.rebuild(positionedNodes);
416
-
417
- // Fit the viewport once on the first tick so the graph is visible and
418
- // centered immediately. After that, let the user interact freely while
419
- // the simulation continues settling in the background.
420
- if (!initialFitDone && positionedNodes.length > 0 && interactionManager) {
421
- initialFitDone = true;
422
- const { width: cw, height: ch } = getCanvasDimensions();
423
- const { transform: fitTransform } = ZoomTransform.fitBounds(positionedNodes, cw, ch);
424
- interactionManager.setTransform(fitTransform);
425
- }
426
-
427
- needsRender = true;
428
- scheduleRender();
429
- });
430
-
431
- simulation.onSettled(() => {
432
- if (initialSettleDone) return;
433
- initialSettleDone = true;
434
- });
435
- }
436
-
437
- function getCanvasDimensions(): { width: number; height: number } {
438
- if (!canvas) return { width: 600, height: 400 };
439
- const rect = canvas.getBoundingClientRect();
440
- return {
441
- width: Math.max(rect.width || 600, 100),
442
- height: Math.max(rect.height || 400, 100),
443
- };
444
- }
445
-
446
- function scheduleRender(): void {
447
- if (animFrameId !== null || destroyed) return;
448
- animFrameId = requestAnimationFrame(renderFrame);
449
- }
450
-
451
- function renderFrame(): void {
452
- animFrameId = null;
453
- if (destroyed || !renderer || !interactionManager) return;
454
-
455
- if (needsRender) {
456
- needsRender = false;
457
-
458
- const transform = interactionManager.getTransform();
459
- const state: GraphRenderState = {
460
- nodes: positionedNodes,
461
- edges: positionedEdges,
462
- transform: { x: transform.x, y: transform.y, k: transform.k },
463
- hoveredNodeId,
464
- hoveredEdgeId,
465
- selectedNodeIds,
466
- adjacencyMap,
467
- theme: compilation.theme,
468
- searchMatches: searchManager.getMatches(),
469
- isGesturing,
470
- watermark: compilation.watermark,
471
- };
472
-
473
- renderer.render(state);
474
- }
475
- }
476
-
477
- // ---------------------------------------------------------------------------
478
- // Interaction wiring
479
- // ---------------------------------------------------------------------------
480
-
481
- function initInteraction(): void {
482
- if (!canvas) return;
483
-
484
- if (options?.tooltip !== false) {
485
- tooltipManager = createTooltipManager(wrapper!);
486
- }
487
-
488
- interactionManager = new GraphInteractionManager(canvas, spatialIndex, {
489
- onTransformChange(_transform) {
490
- markGesture();
491
- needsRender = true;
492
- scheduleRender();
493
- },
494
- onHoverChange(nodeId) {
495
- hoveredNodeId = nodeId;
496
- needsRender = true;
497
- scheduleRender();
498
-
499
- // Fire onNodeHover callback
500
- if (nodeId) {
501
- options?.onNodeHover?.(nodeDataById(nodeId));
502
- } else {
503
- options?.onNodeHover?.(null);
504
- }
505
-
506
- // Show or hide tooltip
507
- if (nodeId && tooltipManager) {
508
- // Clear edge hover when hovering a node
509
- if (hoveredEdgeId) {
510
- hoveredEdgeId = null;
511
- options?.onEdgeHover?.(null);
512
- }
513
- const content = compilation.tooltipDescriptors.get(nodeId);
514
- if (content) {
515
- const node = positionedNodes.find((n) => n.id === nodeId);
516
- if (node && interactionManager) {
517
- const screen = interactionManager.getTransform().graphToScreen(node.x, node.y);
518
- tooltipManager.show(content, screen.x, screen.y);
519
- }
520
- }
521
- } else if (!nodeId) {
522
- // Tooltip hiding handled in onBackgroundHover (edge may show tooltip)
523
- // If no edge hover happens, tooltip stays hidden
524
- tooltipManager?.hide();
525
- }
526
- },
527
- onBackgroundHover(graphX, graphY, screenX, screenY) {
528
- // Throttle edge hit testing to avoid O(n) scan on every mousemove
529
- const now = performance.now();
530
- if (now - lastEdgeHitTime < 32) {
531
- // When throttled, clear edge hover so hover-off transitions stay snappy
532
- if (hoveredEdgeId) {
533
- hoveredEdgeId = null;
534
- needsRender = true;
535
- scheduleRender();
536
- options?.onEdgeHover?.(null);
537
- tooltipManager?.hide();
538
- }
539
- return;
540
- }
541
- lastEdgeHitTime = now;
542
-
543
- // Edge hit testing: check proximity to edge line segments
544
- const transform = interactionManager?.getTransform();
545
- const threshold = 5 / (transform?.k ?? 1); // 5px in screen space
546
- const edgeId = hitTestEdge(graphX, graphY, threshold);
547
-
548
- if (edgeId !== hoveredEdgeId) {
549
- hoveredEdgeId = edgeId;
550
- needsRender = true;
551
- scheduleRender();
552
-
553
- if (edgeId) {
554
- const data = edgeDataById(edgeId);
555
- options?.onEdgeHover?.(data);
556
-
557
- // Show edge tooltip
558
- if (tooltipManager && data) {
559
- const fields = Object.entries(data)
560
- .filter(([key]) => key !== 'source' && key !== 'target')
561
- .filter(([, value]) => value != null)
562
- .map(([key, value]) => ({
563
- label: key,
564
- value: typeof value === 'number' ? value.toLocaleString() : String(value),
565
- }));
566
-
567
- const [source, target] = edgeId.split('->');
568
- tooltipManager.show({ title: `${source} → ${target}`, fields }, screenX, screenY);
569
- }
570
- } else {
571
- options?.onEdgeHover?.(null);
572
- tooltipManager?.hide();
573
- }
574
- }
575
- },
576
- onSelectionChange(nodeIds) {
577
- selectedNodeIds = new Set(nodeIds);
578
- needsRender = true;
579
- scheduleRender();
580
- options?.onSelectionChange?.(nodeIds);
581
-
582
- // Fire onNodeClick for the most recently added node
583
- if (nodeIds.length > 0) {
584
- const lastId = nodeIds[nodeIds.length - 1];
585
- options?.onNodeClick?.(nodeDataById(lastId));
586
- }
587
- },
588
- onNodeDragStart(nodeId) {
589
- // Pin at the node's current position to avoid visual snap to origin
590
- const node = positionedNodes.find((n) => n.id === nodeId);
591
- const x = node?.x ?? 0;
592
- const y = node?.y ?? 0;
593
- simulation?.pinNode(nodeId, x, y);
594
- canvas?.classList.add('oc-graph-canvas--dragging');
595
- },
596
- onNodeDrag(nodeId, x, y) {
597
- simulation?.dragNode(nodeId, x, y);
598
- },
599
- onNodeDragEnd(nodeId) {
600
- simulation?.unpinNode(nodeId);
601
- canvas?.classList.remove('oc-graph-canvas--dragging');
602
- },
603
- onDoubleClick(nodeId) {
604
- options?.onNodeDoubleClick?.(nodeDataById(nodeId));
605
- },
606
- });
607
-
608
- // Wire keyboard navigation
609
- cleanupKeyboard = attachGraphKeyboardNav({
610
- canvas,
611
- getNodes: () => positionedNodes,
612
- getSelectedIds: () => [...selectedNodeIds],
613
- getAdjacency: () => adjacencyMap,
614
- onSelect(nodeId) {
615
- selectedNodeIds = new Set([nodeId]);
616
- needsRender = true;
617
- scheduleRender();
618
- options?.onNodeClick?.(nodeDataById(nodeId));
619
- options?.onSelectionChange?.([nodeId]);
620
- },
621
- onDeselect() {
622
- selectedNodeIds.clear();
623
- needsRender = true;
624
- scheduleRender();
625
- options?.onSelectionChange?.([]);
626
- },
627
- onZoom(direction) {
628
- if (!interactionManager || !canvas) return;
629
- const t = interactionManager.getTransform();
630
- const { width: cw, height: ch } = getCanvasDimensions();
631
- const factor = direction === 'in' ? 1.2 : 0.8;
632
- const newK = t.k * factor;
633
- const newTransform = t.zoomAt(newK, cw / 2, ch / 2);
634
- interactionManager.setTransform(newTransform);
635
- needsRender = true;
636
- scheduleRender();
637
- },
638
- onFitAll() {
639
- zoomToFit();
640
- },
641
- });
642
-
643
- // Handle node clicks (from interaction manager selection change wiring above)
644
- // We catch clicks via the interaction manager's onSelectionChange callback
645
- }
646
-
647
- // ---------------------------------------------------------------------------
648
- // Public API methods
649
- // ---------------------------------------------------------------------------
650
-
651
- function search(query: string): void {
652
- if (destroyed) return;
653
- searchManager.search(query, positionedNodes);
654
- needsRender = true;
655
- scheduleRender();
656
- }
657
-
658
- function clearSearch(): void {
659
- if (destroyed) return;
660
- searchManager.clearSearch();
661
- needsRender = true;
662
- scheduleRender();
663
- }
664
-
665
- function zoomToFit(): void {
666
- if (destroyed || !interactionManager || positionedNodes.length === 0) return;
667
- const { width: cw, height: ch } = getCanvasDimensions();
668
- const { transform: fitTransform } = ZoomTransform.fitBounds(positionedNodes, cw, ch);
669
- interactionManager.setTransform(fitTransform);
670
- needsRender = true;
671
- scheduleRender();
672
- }
673
-
674
- function zoomToNode(nodeId: string): void {
675
- if (destroyed || !interactionManager || !canvas) return;
676
- const node = positionedNodes.find((n) => n.id === nodeId);
677
- if (!node) return;
678
-
679
- const { width: cw, height: ch } = getCanvasDimensions();
680
- // Zoom to 2x and center on node
681
- const k = 2;
682
- const tx = cw / 2 - node.x * k;
683
- const ty = ch / 2 - node.y * k;
684
- const newTransform = new ZoomTransform(tx, ty, k);
685
- interactionManager.setTransform(newTransform);
686
- needsRender = true;
687
- scheduleRender();
688
- }
689
-
690
- function selectNode(nodeId: string): void {
691
- if (destroyed) return;
692
- selectedNodeIds = new Set([nodeId]);
693
- needsRender = true;
694
- scheduleRender();
695
- options?.onSelectionChange?.([nodeId]);
696
- }
697
-
698
- function getSelectedNodes(): string[] {
699
- return [...selectedNodeIds];
700
- }
701
-
702
- function doResize(): void {
703
- if (destroyed || !canvas || !renderer || !wrapper) return;
704
- const { width, height } = getContainerDimensions();
705
- const canvasHeight = Math.max(height, 200);
706
- renderer.resize(width, canvasHeight);
707
- needsRender = true;
708
- scheduleRender();
709
- }
710
-
711
- function update(newSpec: GraphSpec): void {
712
- if (destroyed) return;
713
- currentSpec = newSpec;
714
-
715
- // Tear down old simulation + interaction
716
- teardownSubsystems();
717
-
718
- // Recompile
719
- compilation = compile();
720
- adjacencyMap = buildAdjacencyMap(compilation.edges);
721
- buildDataMaps();
722
-
723
- // Update DOM chrome/legend
724
- renderChrome();
725
- renderLegend();
726
-
727
- // Reinit
728
- initSimulation();
729
- initInteraction();
730
-
731
- // Reset state
732
- hoveredNodeId = null;
733
- hoveredEdgeId = null;
734
- selectedNodeIds = new Set();
735
- searchManager.clearSearch();
736
- }
737
-
738
- function updateVisuals(newSpec: GraphSpec): void {
739
- if (destroyed) return;
740
- currentSpec = newSpec;
741
-
742
- // Build a position lookup from current positioned nodes
743
- const posMap = new Map<string, { x: number; y: number }>();
744
- for (const node of positionedNodes) {
745
- posMap.set(node.id, { x: node.x, y: node.y });
746
- }
747
-
748
- // Recompile with new spec (encoding, chrome, nodeOverrides, etc.)
749
- compilation = compile();
750
- adjacencyMap = buildAdjacencyMap(compilation.edges);
751
- buildDataMaps();
752
-
753
- // Transfer positions to new compiled nodes
754
- positionedNodes = compilation.nodes.map((node) => {
755
- const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
756
- return { ...node, x: pos.x, y: pos.y };
757
- });
758
-
759
- // Rebuild positioned edges from existing positions
760
- positionedEdges = compilation.edges.map((edge) => {
761
- const src = posMap.get(edge.source) ?? { x: 0, y: 0 };
762
- const tgt = posMap.get(edge.target) ?? { x: 0, y: 0 };
763
- return {
764
- ...edge,
765
- sourceX: src.x,
766
- sourceY: src.y,
767
- targetX: tgt.x,
768
- targetY: tgt.y,
769
- };
770
- });
771
-
772
- // Rebuild spatial index with updated visuals
773
- spatialIndex.rebuild(positionedNodes);
774
-
775
- // Update DOM chrome/legend
776
- renderChrome();
777
- renderLegend();
778
-
779
- // Re-render canvas without restarting simulation
780
- needsRender = true;
781
- scheduleRender();
782
- }
783
-
784
- function teardownSubsystems(): void {
785
- if (animFrameId !== null) {
786
- cancelAnimationFrame(animFrameId);
787
- animFrameId = null;
788
- }
789
- if (cleanupKeyboard) {
790
- cleanupKeyboard();
791
- cleanupKeyboard = null;
792
- }
793
- interactionManager?.destroy();
794
- interactionManager = null;
795
- simulation?.destroy();
796
- simulation = null;
797
- tooltipManager?.destroy();
798
- tooltipManager = null;
799
- }
800
-
801
- function destroy(): void {
802
- if (destroyed) return;
803
- destroyed = true;
804
-
805
- if (gestureTimeout !== null) {
806
- clearTimeout(gestureTimeout);
807
- gestureTimeout = null;
808
- }
809
-
810
- teardownSubsystems();
811
-
812
- if (disconnectResize) {
813
- disconnectResize();
814
- disconnectResize = null;
815
- }
816
-
817
- if (wrapper?.parentNode) {
818
- wrapper.parentNode.removeChild(wrapper);
819
- }
820
- wrapper = null;
821
- canvas = null;
822
- chromeEl = null;
823
- legendEl = null;
824
- renderer = null;
825
-
826
- container.classList.remove('oc-dark');
827
- }
828
-
829
- // ---------------------------------------------------------------------------
830
- // Initialize
831
- // ---------------------------------------------------------------------------
832
-
833
- try {
834
- compilation = compile();
835
- adjacencyMap = buildAdjacencyMap(compilation.edges);
836
- buildDataMaps();
837
- createDOM();
838
- initSimulation();
839
- initInteraction();
840
- } catch (err) {
841
- console.error('[viz] Graph mount failed:', err);
842
- // Return a no-op instance so callers don't crash
843
- return {
844
- update() {},
845
- updateVisuals() {},
846
- search() {},
847
- clearSearch() {},
848
- zoomToFit() {},
849
- zoomToNode() {},
850
- selectNode() {},
851
- getSelectedNodes: () => [],
852
- resize() {},
853
- destroy() {},
854
- };
855
- }
856
-
857
- // Responsive resize
858
- if (options?.responsive !== false) {
859
- disconnectResize = observeResize(container, () => {
860
- doResize();
861
- });
862
- }
863
-
864
- return {
865
- update,
866
- updateVisuals,
867
- search,
868
- clearSearch,
869
- zoomToFit,
870
- zoomToNode,
871
- selectNode,
872
- getSelectedNodes,
873
- resize: doResize,
874
- destroy,
875
- };
876
- }
877
-
878
- // ---------------------------------------------------------------------------
879
- // Util
880
- // ---------------------------------------------------------------------------
881
-
882
- function escapeHtml(str: string): string {
883
- return str
884
- .replace(/&/g, '&amp;')
885
- .replace(/</g, '&lt;')
886
- .replace(/>/g, '&gt;')
887
- .replace(/"/g, '&quot;');
888
- }