@opendata-ai/openchart-vanilla 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +327 -0
- package/dist/index.js +4745 -0
- package/dist/index.js.map +1 -0
- package/dist/simulation-worker.js +1196 -0
- package/package.json +58 -0
- package/src/__test-fixtures__/dom.ts +42 -0
- package/src/__test-fixtures__/specs.ts +187 -0
- package/src/__tests__/edit-events.test.ts +747 -0
- package/src/__tests__/events.test.ts +336 -0
- package/src/__tests__/export.test.ts +150 -0
- package/src/__tests__/mount.test.ts +219 -0
- package/src/__tests__/svg-renderer.test.ts +609 -0
- package/src/__tests__/table-mount.test.ts +484 -0
- package/src/__tests__/tooltip.test.ts +201 -0
- package/src/export.ts +105 -0
- package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
- package/src/graph/__tests__/graph-mount.test.ts +213 -0
- package/src/graph/__tests__/interaction.test.ts +205 -0
- package/src/graph/__tests__/keyboard.test.ts +653 -0
- package/src/graph/__tests__/search.test.ts +88 -0
- package/src/graph/__tests__/simulation.test.ts +233 -0
- package/src/graph/__tests__/spatial-index.test.ts +142 -0
- package/src/graph/__tests__/zoom.test.ts +195 -0
- package/src/graph/canvas-renderer.ts +660 -0
- package/src/graph/interaction.ts +359 -0
- package/src/graph/keyboard.ts +208 -0
- package/src/graph/search.ts +50 -0
- package/src/graph/simulation-worker-url.ts +30 -0
- package/src/graph/simulation-worker.ts +265 -0
- package/src/graph/simulation.ts +350 -0
- package/src/graph/spatial-index.ts +121 -0
- package/src/graph/types.ts +44 -0
- package/src/graph/worker-protocol.ts +67 -0
- package/src/graph/zoom.ts +104 -0
- package/src/graph-mount.ts +675 -0
- package/src/index.ts +56 -0
- package/src/mount.ts +1639 -0
- package/src/renderers/table-cells.ts +444 -0
- package/src/resize-observer.ts +46 -0
- package/src/svg-renderer.ts +914 -0
- package/src/table-keyboard.ts +266 -0
- package/src/table-mount.ts +532 -0
- package/src/table-renderer.ts +350 -0
- package/src/tooltip.ts +120 -0
|
@@ -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, '&')
|
|
672
|
+
.replace(/</g, '<')
|
|
673
|
+
.replace(/>/g, '>')
|
|
674
|
+
.replace(/"/g, '"');
|
|
675
|
+
}
|