@lapis-notes/graph 0.1.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 (51) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE.md +8 -0
  3. package/README.md +20 -0
  4. package/dist/embed.d.ts +5 -0
  5. package/dist/embed.js +4 -0
  6. package/dist/graph-build-generation.d.ts +6 -0
  7. package/dist/graph-build-generation.js +13 -0
  8. package/dist/graph-controls-overlay.svelte +840 -0
  9. package/dist/graph-controls-overlay.svelte.d.ts +30 -0
  10. package/dist/graph-data-coordinator.d.ts +41 -0
  11. package/dist/graph-data-coordinator.js +265 -0
  12. package/dist/graph-data.d.ts +23 -0
  13. package/dist/graph-data.js +493 -0
  14. package/dist/graph-embed.svelte +92 -0
  15. package/dist/graph-embed.svelte.d.ts +12 -0
  16. package/dist/graph-file-preview.svelte +115 -0
  17. package/dist/graph-file-preview.svelte.d.ts +12 -0
  18. package/dist/graph-hover-intent.d.ts +22 -0
  19. package/dist/graph-hover-intent.js +85 -0
  20. package/dist/graph-load-alignment.d.ts +2 -0
  21. package/dist/graph-load-alignment.js +3 -0
  22. package/dist/graph-navigation.d.ts +7 -0
  23. package/dist/graph-navigation.js +78 -0
  24. package/dist/graph-node-activation.d.ts +4 -0
  25. package/dist/graph-node-activation.js +16 -0
  26. package/dist/graph-plugin.d.ts +38 -0
  27. package/dist/graph-plugin.js +177 -0
  28. package/dist/graph-problems.d.ts +15 -0
  29. package/dist/graph-problems.js +46 -0
  30. package/dist/graph-query-resolution.d.ts +9 -0
  31. package/dist/graph-query-resolution.js +44 -0
  32. package/dist/graph-renderer.d.ts +226 -0
  33. package/dist/graph-renderer.js +1350 -0
  34. package/dist/graph-settings.d.ts +20 -0
  35. package/dist/graph-settings.js +125 -0
  36. package/dist/graph-types.d.ts +74 -0
  37. package/dist/graph-types.js +1 -0
  38. package/dist/graph-view.d.ts +62 -0
  39. package/dist/graph-view.js +553 -0
  40. package/dist/graph-viewport-alignment.d.ts +6 -0
  41. package/dist/graph-viewport-alignment.js +9 -0
  42. package/dist/index.d.ts +9 -0
  43. package/dist/index.js +8 -0
  44. package/dist/mount-local-component.svelte.d.ts +7 -0
  45. package/dist/mount-local-component.svelte.js +12 -0
  46. package/dist/public-components.d.ts +2 -0
  47. package/dist/public-components.js +2 -0
  48. package/dist/styles.css +441 -0
  49. package/manifest.json +30 -0
  50. package/package.json +84 -0
  51. package/styles.css +1 -0
@@ -0,0 +1,1350 @@
1
+ import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY, } from "d3-force";
2
+ import { graphForceSliderToStrength, graphRepelForceMagnitude, } from "./graph-settings";
3
+ import { adjustTransformForViewportResize } from "./graph-viewport-alignment";
4
+ import { GraphHoverIntent } from "./graph-hover-intent";
5
+ function simulationNodeId(value) {
6
+ return typeof value === "object" ? value.id : String(value);
7
+ }
8
+ function nodeRadius(node, settings) {
9
+ return settings.display.nodeSize + Math.log2(node.refCount + 1) * 2.6;
10
+ }
11
+ const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
12
+ const ENTRANCE_PREWARM_TICKS = 12;
13
+ const REDUCED_MOTION_SETTLE_TICKS = 240;
14
+ const EMPHASIS_FRAME_MS = 1000 / 60;
15
+ const EMPHASIS_DECAY_PER_FRAME = 0.9;
16
+ const EMPHASIS_EPSILON = 0.001;
17
+ export const GRAPH_D3_ALPHA_DECAY = 1 - Math.pow(0.001, 1 / 300);
18
+ export const GRAPH_D3_VELOCITY_DECAY = 0.4;
19
+ export function graphPhyllotaxisPosition(index, spacing = 18) {
20
+ const radius = spacing * Math.sqrt(Math.max(index, 0));
21
+ const angle = index * GOLDEN_ANGLE;
22
+ return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };
23
+ }
24
+ export function graphNodeWorldRadius(baseRadius, zoom) {
25
+ return baseRadius / Math.sqrt(Math.max(zoom, GRAPH_MIN_ZOOM));
26
+ }
27
+ export function graphNodeScreenRadius(baseRadius, zoom) {
28
+ return baseRadius * Math.sqrt(Math.max(zoom, GRAPH_MIN_ZOOM));
29
+ }
30
+ export function graphNodeSupportsPreview(node) {
31
+ return Boolean(node.type === "note" &&
32
+ node.exists &&
33
+ node.path &&
34
+ /\.(?:md|markdown)$/iu.test(node.path));
35
+ }
36
+ export function graphNodePreviewRect(options) {
37
+ const { node, transform, canvasRect } = options;
38
+ if (typeof node.x !== "number" || typeof node.y !== "number") {
39
+ return { x: canvasRect.left, y: canvasRect.top, width: 0, height: 0 };
40
+ }
41
+ const radius = graphNodeScreenRadius(node.radius, transform.k);
42
+ const centerX = canvasRect.left + node.x * transform.k + transform.x;
43
+ const centerY = canvasRect.top + node.y * transform.k + transform.y;
44
+ return {
45
+ x: centerX - radius,
46
+ y: centerY - radius,
47
+ width: radius * 2,
48
+ height: radius * 2,
49
+ };
50
+ }
51
+ export function graphEmphasisAlpha(kind, active, progress) {
52
+ if (active)
53
+ return 1;
54
+ const target = kind === "node" ? 0.12 : kind === "link" ? 0.05 : 0;
55
+ return 1 - clamp(progress, 0, 1) * (1 - target);
56
+ }
57
+ export function advanceGraphEmphasis(previous, target, elapsedMs) {
58
+ const resolvedTarget = clamp(target, 0, 1);
59
+ const resolvedPrevious = clamp(previous, 0, 1);
60
+ if (Math.abs(resolvedTarget - resolvedPrevious) <= EMPHASIS_EPSILON) {
61
+ return resolvedTarget;
62
+ }
63
+ const decay = Math.pow(EMPHASIS_DECAY_PER_FRAME, Math.max(0, elapsedMs) / EMPHASIS_FRAME_MS);
64
+ const next = resolvedPrevious * decay + resolvedTarget * (1 - decay);
65
+ return Math.abs(resolvedTarget - next) <= EMPHASIS_EPSILON
66
+ ? resolvedTarget
67
+ : next;
68
+ }
69
+ export function graphLinkScreenWidth(value) {
70
+ return clamp(Number.isFinite(value) ? value : 1, 0.1, 5);
71
+ }
72
+ export function graphLinkUsesAccentPaint(hasEmphasis, incidentToSource) {
73
+ return hasEmphasis && incidentToSource;
74
+ }
75
+ export function createGraphLinkDegreeMap(nodes, links) {
76
+ const degrees = new Map(nodes.map((node) => [node.id, 0]));
77
+ for (const link of links) {
78
+ const sourceId = simulationNodeId(link.source);
79
+ const targetId = simulationNodeId(link.target);
80
+ degrees.set(sourceId, (degrees.get(sourceId) ?? 0) + 1);
81
+ degrees.set(targetId, (degrees.get(targetId) ?? 0) + 1);
82
+ }
83
+ return degrees;
84
+ }
85
+ export function graphDegreeNormalizedLinkStrength(link, degrees, sliderValue) {
86
+ const sourceDegree = Math.max(degrees.get(simulationNodeId(link.source)) ?? 0, 1);
87
+ const targetDegree = Math.max(degrees.get(simulationNodeId(link.target)) ?? 0, 1);
88
+ return (graphForceSliderToStrength(sliderValue) /
89
+ Math.min(sourceDegree, targetDegree));
90
+ }
91
+ export function createGraphForceSimulation(nodes, links, settings) {
92
+ const degrees = createGraphLinkDegreeMap(nodes, links);
93
+ return forceSimulation(nodes)
94
+ .force("link", forceLink(links)
95
+ .id((node) => node.id)
96
+ .distance(settings.forces.linkDistance)
97
+ .strength((link) => graphDegreeNormalizedLinkStrength(link, degrees, settings.forces.linkForce)))
98
+ .force("charge", forceManyBody()
99
+ .strength(-graphRepelForceMagnitude(settings.forces.repelForce))
100
+ .distanceMin(30))
101
+ .force("center-x", forceX(0).strength(graphForceSliderToStrength(settings.forces.centerForce)))
102
+ .force("center-y", forceY(0).strength(graphForceSliderToStrength(settings.forces.centerForce)))
103
+ .force("collision", forceCollide().radius(60).strength(0.5))
104
+ .alphaDecay(GRAPH_D3_ALPHA_DECAY)
105
+ .velocityDecay(GRAPH_D3_VELOCITY_DECAY)
106
+ .stop();
107
+ }
108
+ function graphNodeChronology(node) {
109
+ if (typeof node.ctime === "number" && node.ctime > 0)
110
+ return node.ctime;
111
+ if (typeof node.mtime === "number" && node.mtime > 0)
112
+ return node.mtime;
113
+ return Number.POSITIVE_INFINITY;
114
+ }
115
+ export function createGraphTimeLapsePlan(graph) {
116
+ const primary = graph.nodes
117
+ .filter((node) => node.type === "note" || node.type === "attachment")
118
+ .sort((left, right) => {
119
+ const chronology = graphNodeChronology(left) - graphNodeChronology(right);
120
+ return Number.isNaN(chronology) || chronology === 0
121
+ ? left.id.localeCompare(right.id)
122
+ : chronology;
123
+ });
124
+ const primaryRank = new Map(primary.map((node, index) => [node.id, index]));
125
+ const auxiliaryRank = new Map();
126
+ for (const link of graph.links) {
127
+ const sourceRank = primaryRank.get(link.source);
128
+ const targetRank = primaryRank.get(link.target);
129
+ if (sourceRank !== undefined && targetRank === undefined) {
130
+ auxiliaryRank.set(link.target, Math.min(auxiliaryRank.get(link.target) ?? Infinity, sourceRank));
131
+ }
132
+ if (targetRank !== undefined && sourceRank === undefined) {
133
+ auxiliaryRank.set(link.source, Math.min(auxiliaryRank.get(link.source) ?? Infinity, targetRank));
134
+ }
135
+ }
136
+ const entries = [
137
+ ...primary.map((node, index) => ({ id: node.id, rank: index, kind: 0 })),
138
+ ...graph.nodes
139
+ .filter((node) => !primaryRank.has(node.id))
140
+ .map((node) => ({
141
+ id: node.id,
142
+ rank: auxiliaryRank.get(node.id) ?? Infinity,
143
+ kind: 1,
144
+ })),
145
+ ];
146
+ entries.sort((left, right) => left.rank - right.rank ||
147
+ left.kind - right.kind ||
148
+ left.id.localeCompare(right.id));
149
+ return entries.map((entry) => entry.id);
150
+ }
151
+ export function graphTimeLapseVisibleCount(elapsedMs, durationMs, total) {
152
+ if (total <= 0)
153
+ return 0;
154
+ if (durationMs <= 0)
155
+ return total;
156
+ return Math.min(total, Math.floor(clamp(elapsedMs / durationMs, 0, 1) * total));
157
+ }
158
+ export function graphNodeIntersectsViewport(options) {
159
+ const { nodeX, nodeY, screenRadius, transform, viewportWidth, viewportHeight, margin = 24, } = options;
160
+ const x = nodeX * transform.k + transform.x;
161
+ const y = nodeY * transform.k + transform.y;
162
+ const radius = screenRadius + margin;
163
+ return (x + radius >= 0 &&
164
+ x - radius <= viewportWidth &&
165
+ y + radius >= 0 &&
166
+ y - radius <= viewportHeight);
167
+ }
168
+ export function graphLinkIntersectsViewport(options) {
169
+ const { sourceX, sourceY, targetX, targetY, transform, viewportWidth, viewportHeight, margin = 24, } = options;
170
+ const x1 = sourceX * transform.k + transform.x;
171
+ const y1 = sourceY * transform.k + transform.y;
172
+ const x2 = targetX * transform.k + transform.x;
173
+ const y2 = targetY * transform.k + transform.y;
174
+ return !(Math.max(x1, x2) < -margin ||
175
+ Math.min(x1, x2) > viewportWidth + margin ||
176
+ Math.max(y1, y2) < -margin ||
177
+ Math.min(y1, y2) > viewportHeight + margin);
178
+ }
179
+ function readStyleValue(styles, property, fallback) {
180
+ return styles.getPropertyValue(property).trim() || fallback;
181
+ }
182
+ function resolveGraphPalette(el) {
183
+ const styles = getComputedStyle(el);
184
+ return {
185
+ link: readStyleValue(styles, "--ui-graph-link", "rgba(100, 116, 139, 0.24)"),
186
+ linkActive: readStyleValue(styles, "--ui-graph-link-active", "rgba(15, 23, 42, 0.48)"),
187
+ nodeNote: readStyleValue(styles, "--ui-graph-node-note", "rgb(58, 127, 246)"),
188
+ nodeAttachment: readStyleValue(styles, "--ui-graph-node-attachment", "rgb(16, 185, 129)"),
189
+ nodeTag: readStyleValue(styles, "--ui-graph-node-tag", "rgb(245, 158, 11)"),
190
+ nodeUnresolved: readStyleValue(styles, "--ui-graph-node-unresolved", "rgb(239, 68, 68)"),
191
+ nodeFocused: readStyleValue(styles, "--graph-node-focused", "rgb(58, 127, 246)"),
192
+ nodeNeutral: readStyleValue(styles, "--ui-graph-node-neutral", "rgb(148, 163, 184)"),
193
+ nodeStroke: readStyleValue(styles, "--ui-graph-node-stroke", "rgba(255, 255, 255, 0.85)"),
194
+ nodeStrokeActive: readStyleValue(styles, "--ui-graph-node-active-stroke", "rgba(15, 23, 42, 0.65)"),
195
+ nodeFocusRing: readStyleValue(styles, "--ui-graph-node-focus-ring", "rgba(15, 23, 42, 0.82)"),
196
+ label: readStyleValue(styles, "--ui-graph-node-label", "rgba(100, 116, 139, 0.92)"),
197
+ labelHover: readStyleValue(styles, "--ui-graph-node-label-hover", "rgb(15, 23, 42)"),
198
+ };
199
+ }
200
+ function nodeColor(node, palette) {
201
+ if (node.primaryColor) {
202
+ return node.primaryColor;
203
+ }
204
+ switch (node.type) {
205
+ case "note":
206
+ return palette.nodeNote;
207
+ case "attachment":
208
+ return palette.nodeAttachment;
209
+ case "tag":
210
+ return palette.nodeTag;
211
+ case "unresolved":
212
+ return palette.nodeUnresolved;
213
+ default:
214
+ return palette.nodeNeutral;
215
+ }
216
+ }
217
+ function linkColor(active, palette) {
218
+ return active ? palette.linkActive : palette.link;
219
+ }
220
+ function clamp(value, min, max) {
221
+ return Math.min(Math.max(value, min), max);
222
+ }
223
+ export const GRAPH_MIN_ZOOM = 1 / 128;
224
+ export const GRAPH_MAX_ZOOM = 8;
225
+ export const GRAPH_MAX_FIT_ZOOM = 1.35;
226
+ export const GRAPH_FOCUS_ZOOM = 1.1;
227
+ export const GRAPH_ZOOM_STEP = 1.5;
228
+ const WHEEL_ZOOM_DELTA_CAP = 240;
229
+ export function clampGraphZoom(value) {
230
+ return clamp(value, GRAPH_MIN_ZOOM, GRAPH_MAX_ZOOM);
231
+ }
232
+ export function graphFitScale(options) {
233
+ const { viewportWidth, viewportHeight, contentWidth, contentHeight, padding, } = options;
234
+ const fitScale = Math.min((viewportWidth - padding * 2) / contentWidth, (viewportHeight - padding * 2) / contentHeight);
235
+ return clamp(fitScale, GRAPH_MIN_ZOOM, GRAPH_MAX_FIT_ZOOM);
236
+ }
237
+ export function graphFitTransform(options) {
238
+ const { viewportWidth, viewportHeight, bounds, padding } = options;
239
+ const contentWidth = Math.max(bounds.maxX - bounds.minX, 1);
240
+ const contentHeight = Math.max(bounds.maxY - bounds.minY, 1);
241
+ const scale = graphFitScale({
242
+ viewportWidth,
243
+ viewportHeight,
244
+ contentWidth,
245
+ contentHeight,
246
+ padding,
247
+ });
248
+ const centerX = (bounds.minX + bounds.maxX) / 2;
249
+ const centerY = (bounds.minY + bounds.maxY) / 2;
250
+ return {
251
+ x: viewportWidth / 2 - centerX * scale,
252
+ y: viewportHeight / 2 - centerY * scale,
253
+ k: scale,
254
+ };
255
+ }
256
+ export function graphFocusTransform(options) {
257
+ const { viewportWidth, viewportHeight, nodeX, nodeY, currentScale } = options;
258
+ const scale = clampGraphZoom(Math.max(currentScale, GRAPH_FOCUS_ZOOM));
259
+ return {
260
+ x: viewportWidth / 2 - nodeX * scale,
261
+ y: viewportHeight / 2 - nodeY * scale,
262
+ k: scale,
263
+ };
264
+ }
265
+ export function graphNodeLabelAlpha(options) {
266
+ if (options.hovered) {
267
+ return 1;
268
+ }
269
+ const labelZoomThreshold = Math.max(1.05, options.textFadeThreshold + 0.35);
270
+ const zoomProgress = clamp((options.zoom - labelZoomThreshold) / 0.55, 0, 1);
271
+ return zoomProgress * (options.context ? 0.82 : 0.72);
272
+ }
273
+ function normalizeWheelDelta(event, pageHeight) {
274
+ const deltaModeScale = event.deltaMode === WheelEvent.DOM_DELTA_LINE
275
+ ? 16
276
+ : event.deltaMode === WheelEvent.DOM_DELTA_PAGE
277
+ ? Math.max(pageHeight, 1)
278
+ : 1;
279
+ return clamp(event.deltaY * deltaModeScale, -WHEEL_ZOOM_DELTA_CAP, WHEEL_ZOOM_DELTA_CAP);
280
+ }
281
+ export class GraphRenderer {
282
+ wrapperEl;
283
+ canvasEl;
284
+ emptyStateEl;
285
+ context;
286
+ callbacks;
287
+ simulation = forceSimulation([]);
288
+ graph = { nodes: [], links: [], centerNodeId: null };
289
+ settings = null;
290
+ nodes = [];
291
+ links = [];
292
+ neighborMap = new Map();
293
+ positions = new Map();
294
+ resizeObserver = null;
295
+ animationFrame = null;
296
+ timeLapseFrame = null;
297
+ timeLapseStartedAt = 0;
298
+ timeLapseDurationMs = 10_000;
299
+ timeLapseOrder = [];
300
+ visibleTimeLapseNodes = null;
301
+ hoveredNodeId = null;
302
+ emphasisNodeId = null;
303
+ focusedNodeId = null;
304
+ emphasisProgress = 0;
305
+ emphasisTarget = 0;
306
+ emphasisFrameAt = 0;
307
+ layoutStartedAt = 0;
308
+ reducedMotion;
309
+ autoCenterNodeId = null;
310
+ autoCenterZoom = false;
311
+ autoFitViewport = false;
312
+ pendingCenterNodeId = null;
313
+ pendingFitViewport = false;
314
+ hasFittedViewport = false;
315
+ lastViewportWidth = 0;
316
+ lastViewportHeight = 0;
317
+ viewportAdjustedByUser = false;
318
+ pointerMode = null;
319
+ pointerId = null;
320
+ pointerDownNodeId = null;
321
+ pointerMoved = false;
322
+ dragNode = null;
323
+ pointerStart = { x: 0, y: 0 };
324
+ panStart = { x: 0, y: 0, k: 1 };
325
+ transform = { x: 0, y: 0, k: 1 };
326
+ hoverIntent;
327
+ modifierDown = false;
328
+ previewNodeId = null;
329
+ previewSuppressedNodeId = null;
330
+ handleDocumentKeyDown = (event) => {
331
+ if (event.key === "Meta" || event.key === "Control") {
332
+ this.modifierDown = true;
333
+ this.openPreviewForHoveredNode();
334
+ }
335
+ };
336
+ handleDocumentKeyUp = (event) => {
337
+ if (event.key === "Meta" || event.key === "Control") {
338
+ this.modifierDown = event.metaKey || event.ctrlKey;
339
+ }
340
+ };
341
+ constructor(containerEl, callbacks) {
342
+ this.callbacks = callbacks;
343
+ this.reducedMotion =
344
+ typeof window.matchMedia === "function" &&
345
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
346
+ this.wrapperEl = document.createElement("div");
347
+ this.wrapperEl.style.position = "relative";
348
+ this.wrapperEl.style.flex = "1";
349
+ this.wrapperEl.style.width = "100%";
350
+ this.wrapperEl.style.height = "100%";
351
+ this.wrapperEl.style.minHeight = "0";
352
+ this.wrapperEl.style.border = "none";
353
+ this.wrapperEl.style.borderRadius = "0";
354
+ this.wrapperEl.style.overflow = "hidden";
355
+ this.wrapperEl.style.background = "inherit";
356
+ this.wrapperEl.style.outline = "none";
357
+ this.wrapperEl.dataset.uiComponent = "graph-canvas";
358
+ this.wrapperEl.dataset.uiPart = "renderer";
359
+ this.wrapperEl.tabIndex = 0;
360
+ this.canvasEl = document.createElement("canvas");
361
+ this.canvasEl.style.width = "100%";
362
+ this.canvasEl.style.height = "100%";
363
+ this.canvasEl.style.display = "block";
364
+ this.canvasEl.style.cursor = "grab";
365
+ this.canvasEl.dataset.uiPart = "canvas";
366
+ this.emptyStateEl = document.createElement("div");
367
+ this.emptyStateEl.style.position = "absolute";
368
+ this.emptyStateEl.style.inset = "0";
369
+ this.emptyStateEl.style.display = "flex";
370
+ this.emptyStateEl.style.alignItems = "center";
371
+ this.emptyStateEl.style.justifyContent = "center";
372
+ this.emptyStateEl.style.color = "var(--ui-graph-node-label, #64748b)";
373
+ this.emptyStateEl.style.fontSize = "0.95rem";
374
+ this.emptyStateEl.style.pointerEvents = "none";
375
+ this.emptyStateEl.dataset.uiPart = "empty-state";
376
+ this.emptyStateEl.textContent = "Graph has no visible nodes.";
377
+ this.wrapperEl.append(this.canvasEl, this.emptyStateEl);
378
+ containerEl.appendChild(this.wrapperEl);
379
+ const context = this.canvasEl.getContext("2d");
380
+ if (!context) {
381
+ throw new Error("Failed to create graph canvas context");
382
+ }
383
+ this.context = context;
384
+ this.hoverIntent = new GraphHoverIntent(() => ({
385
+ activationDelayMs: this.settings?.display.hoverActivationDelayMs ?? 500,
386
+ releaseDelayMs: this.settings?.display.hoverReleaseDelayMs ?? 350,
387
+ }), (nodeId) => {
388
+ this.emphasisNodeId = nodeId;
389
+ this.updateEmphasisTarget();
390
+ this.queueRender();
391
+ });
392
+ this.bindEvents();
393
+ this.wrapperEl.ownerDocument.addEventListener("keydown", this.handleDocumentKeyDown);
394
+ this.wrapperEl.ownerDocument.addEventListener("keyup", this.handleDocumentKeyUp);
395
+ this.observeResize();
396
+ this.resize();
397
+ }
398
+ setGraph(graph, settings) {
399
+ this.stopTimeLapse("graph-changed");
400
+ this.hoveredNodeId = null;
401
+ this.hoverIntent.clear();
402
+ this.closePreview();
403
+ const sameTopology = this.hasSameTopology(graph);
404
+ this.storePositions();
405
+ this.graph = graph;
406
+ this.settings = settings;
407
+ this.emptyStateEl.style.display = graph.nodes.length ? "none" : "flex";
408
+ const nextNodes = graph.nodes.map((node, index) => {
409
+ const previous = this.positions.get(node.id);
410
+ const seed = graphPhyllotaxisPosition(index);
411
+ return {
412
+ ...node,
413
+ radius: nodeRadius(node, settings),
414
+ x: previous?.x ?? seed.x,
415
+ y: previous?.y ?? seed.y,
416
+ vx: previous?.vx ?? 0,
417
+ vy: previous?.vy ?? 0,
418
+ };
419
+ });
420
+ const nodeMap = new Map(nextNodes.map((node) => [node.id, node]));
421
+ const nextLinks = [];
422
+ for (const link of graph.links) {
423
+ const source = nodeMap.get(link.source);
424
+ const target = nodeMap.get(link.target);
425
+ if (!source || !target) {
426
+ continue;
427
+ }
428
+ nextLinks.push({
429
+ id: link.id,
430
+ source,
431
+ target,
432
+ count: link.count,
433
+ directed: link.directed,
434
+ });
435
+ }
436
+ this.nodes = nextNodes;
437
+ this.links = nextLinks;
438
+ this.rebuildNeighborMap();
439
+ this.autoFitViewport = false;
440
+ if (!sameTopology) {
441
+ this.hasFittedViewport = false;
442
+ this.viewportAdjustedByUser = false;
443
+ }
444
+ else {
445
+ this.pendingFitViewport = false;
446
+ }
447
+ this.restartSimulation(sameTopology);
448
+ this.queueRender();
449
+ }
450
+ hasSameTopology(graph) {
451
+ if (this.graph.nodes.length !== graph.nodes.length ||
452
+ this.graph.links.length !== graph.links.length) {
453
+ return false;
454
+ }
455
+ const previousNodeIds = new Set(this.graph.nodes.map((node) => node.id));
456
+ for (const node of graph.nodes) {
457
+ if (!previousNodeIds.has(node.id)) {
458
+ return false;
459
+ }
460
+ }
461
+ const previousLinkIds = new Set(this.graph.links.map((link) => link.id));
462
+ for (const link of graph.links) {
463
+ if (!previousLinkIds.has(link.id)) {
464
+ return false;
465
+ }
466
+ }
467
+ return true;
468
+ }
469
+ focusNode(nodeId, options = {}) {
470
+ this.focusedNodeId = nodeId;
471
+ this.autoCenterNodeId = nodeId;
472
+ this.autoCenterZoom = options.zoom === true;
473
+ this.updateEmphasisTarget();
474
+ if (!nodeId) {
475
+ this.pendingCenterNodeId = null;
476
+ this.queueRender();
477
+ return;
478
+ }
479
+ const node = this.nodes.find((entry) => entry.id === nodeId);
480
+ if (!node) {
481
+ this.focusedNodeId = null;
482
+ this.autoCenterNodeId = null;
483
+ this.autoCenterZoom = false;
484
+ this.updateEmphasisTarget();
485
+ this.queueRender();
486
+ return;
487
+ }
488
+ this.viewportAdjustedByUser = false;
489
+ if (!this.hasViewportSize()) {
490
+ this.pendingCenterNodeId = nodeId;
491
+ this.queueRender();
492
+ return;
493
+ }
494
+ this.pendingFitViewport = false;
495
+ this.applyFocusAlignment(node);
496
+ this.pendingCenterNodeId = null;
497
+ this.queueRender();
498
+ }
499
+ zoomIn() {
500
+ this.zoomAtCenter(GRAPH_ZOOM_STEP);
501
+ }
502
+ zoomOut() {
503
+ this.zoomAtCenter(1 / GRAPH_ZOOM_STEP);
504
+ }
505
+ resetView() {
506
+ this.focusedNodeId = null;
507
+ this.autoCenterNodeId = null;
508
+ this.autoCenterZoom = false;
509
+ this.autoFitViewport = false;
510
+ this.viewportAdjustedByUser = false;
511
+ this.updateEmphasisTarget();
512
+ this.fitGraphToViewport();
513
+ this.queueRender();
514
+ }
515
+ refreshViewport() {
516
+ this.resize();
517
+ }
518
+ startTimeLapse(durationMs = 10_000) {
519
+ if (!this.nodes.length)
520
+ return;
521
+ this.hoveredNodeId = null;
522
+ this.hoverIntent.clear();
523
+ this.stopTimeLapse("stopped", false);
524
+ this.timeLapseDurationMs = Math.max(1, durationMs);
525
+ this.timeLapseOrder = createGraphTimeLapsePlan(this.graph);
526
+ this.visibleTimeLapseNodes = new Set();
527
+ this.timeLapseStartedAt = performance.now();
528
+ this.callbacks.onTimeLapseStateChange?.({
529
+ running: true,
530
+ reason: "started",
531
+ });
532
+ this.scheduleTimeLapseFrame();
533
+ this.queueRender();
534
+ }
535
+ stopTimeLapse(reason = "stopped", notify = true) {
536
+ const wasRunning = this.visibleTimeLapseNodes !== null;
537
+ if (this.timeLapseFrame !== null) {
538
+ cancelAnimationFrame(this.timeLapseFrame);
539
+ this.timeLapseFrame = null;
540
+ }
541
+ this.visibleTimeLapseNodes = null;
542
+ this.timeLapseOrder = [];
543
+ if (wasRunning) {
544
+ this.queueRender();
545
+ if (notify) {
546
+ this.callbacks.onTimeLapseStateChange?.({ running: false, reason });
547
+ }
548
+ }
549
+ }
550
+ isTimeLapseRunning() {
551
+ return this.visibleTimeLapseNodes !== null;
552
+ }
553
+ dismissPreview() {
554
+ this.previewSuppressedNodeId = this.hoveredNodeId;
555
+ this.closePreview();
556
+ }
557
+ destroy() {
558
+ this.hoverIntent.destroy();
559
+ this.closePreview();
560
+ this.wrapperEl.ownerDocument.removeEventListener("keydown", this.handleDocumentKeyDown);
561
+ this.wrapperEl.ownerDocument.removeEventListener("keyup", this.handleDocumentKeyUp);
562
+ this.storePositions();
563
+ this.simulation.stop();
564
+ if (this.animationFrame !== null) {
565
+ cancelAnimationFrame(this.animationFrame);
566
+ this.animationFrame = null;
567
+ }
568
+ if (this.timeLapseFrame !== null) {
569
+ cancelAnimationFrame(this.timeLapseFrame);
570
+ this.timeLapseFrame = null;
571
+ }
572
+ this.resizeObserver?.disconnect();
573
+ this.wrapperEl.remove();
574
+ }
575
+ observeResize() {
576
+ if (typeof ResizeObserver === "undefined") {
577
+ return;
578
+ }
579
+ this.resizeObserver = new ResizeObserver(() => {
580
+ this.resize();
581
+ });
582
+ this.resizeObserver.observe(this.wrapperEl);
583
+ }
584
+ bindEvents() {
585
+ this.wrapperEl.addEventListener("wheel", (event) => {
586
+ event.preventDefault();
587
+ const rect = this.canvasEl.getBoundingClientRect();
588
+ const offsetX = event.clientX - rect.left;
589
+ const offsetY = event.clientY - rect.top;
590
+ const worldX = (offsetX - this.transform.x) / this.transform.k;
591
+ const worldY = (offsetY - this.transform.y) / this.transform.k;
592
+ const wheelDelta = normalizeWheelDelta(event, this.wrapperEl.clientHeight);
593
+ const wheelZoomSensitivity = this.settings?.display.wheelZoomSensitivity ?? 1;
594
+ const zoomFactor = Math.pow(GRAPH_ZOOM_STEP, (-wheelDelta / 120) * wheelZoomSensitivity);
595
+ const nextScale = this.transform.k * zoomFactor;
596
+ this.transform.k = clampGraphZoom(nextScale);
597
+ this.transform.x = offsetX - worldX * this.transform.k;
598
+ this.transform.y = offsetY - worldY * this.transform.k;
599
+ this.viewportAdjustedByUser = true;
600
+ this.queueRender();
601
+ }, { passive: false });
602
+ this.wrapperEl.addEventListener("pointerdown", (event) => {
603
+ if (event.button !== 0) {
604
+ return;
605
+ }
606
+ this.wrapperEl.focus();
607
+ this.hoveredNodeId = null;
608
+ this.hoverIntent.clear();
609
+ this.closePreview();
610
+ this.pointerId = event.pointerId;
611
+ this.pointerStart = { x: event.clientX, y: event.clientY };
612
+ this.panStart = { ...this.transform };
613
+ this.pointerMoved = false;
614
+ const node = this.hitTest(event.clientX, event.clientY);
615
+ this.pointerDownNodeId = node?.id ?? null;
616
+ if (node) {
617
+ this.pointerMode = "drag";
618
+ this.dragNode = node;
619
+ node.fx = node.x ?? 0;
620
+ node.fy = node.y ?? 0;
621
+ this.simulation.alphaTarget(0.25).restart();
622
+ this.canvasEl.style.cursor = "grabbing";
623
+ }
624
+ else {
625
+ this.pointerMode = "pan";
626
+ this.canvasEl.style.cursor = "grabbing";
627
+ }
628
+ this.wrapperEl.setPointerCapture(event.pointerId);
629
+ });
630
+ this.wrapperEl.addEventListener("pointermove", (event) => {
631
+ if (this.pointerMode === "drag" && this.dragNode) {
632
+ const point = this.screenToWorld(event.clientX, event.clientY);
633
+ this.pointerMoved =
634
+ this.pointerMoved ||
635
+ this.pointerDistance(event.clientX, event.clientY) > 3;
636
+ this.dragNode.fx = point.x;
637
+ this.dragNode.fy = point.y;
638
+ this.dragNode.x = point.x;
639
+ this.dragNode.y = point.y;
640
+ this.queueRender();
641
+ return;
642
+ }
643
+ if (this.pointerMode === "pan") {
644
+ this.pointerMoved =
645
+ this.pointerMoved ||
646
+ this.pointerDistance(event.clientX, event.clientY) > 3;
647
+ this.transform.x =
648
+ this.panStart.x + (event.clientX - this.pointerStart.x);
649
+ this.transform.y =
650
+ this.panStart.y + (event.clientY - this.pointerStart.y);
651
+ this.viewportAdjustedByUser = true;
652
+ this.queueRender();
653
+ return;
654
+ }
655
+ const hovered = this.hitTest(event.clientX, event.clientY);
656
+ const hoveredNodeId = hovered?.id ?? null;
657
+ if (hoveredNodeId !== this.hoveredNodeId) {
658
+ this.closePreview();
659
+ this.previewSuppressedNodeId = null;
660
+ this.hoveredNodeId = hoveredNodeId;
661
+ this.hoverIntent.setPointerNode(hoveredNodeId);
662
+ this.canvasEl.style.cursor = hovered ? "pointer" : "grab";
663
+ this.queueRender();
664
+ }
665
+ this.modifierDown = event.metaKey || event.ctrlKey || this.modifierDown;
666
+ if (hovered && this.modifierDown)
667
+ this.openPreview(hovered);
668
+ });
669
+ this.wrapperEl.addEventListener("pointerleave", () => {
670
+ if (this.pointerMode || this.hoveredNodeId === null)
671
+ return;
672
+ this.hoveredNodeId = null;
673
+ this.hoverIntent.setPointerNode(null);
674
+ this.closePreview();
675
+ this.canvasEl.style.cursor = "grab";
676
+ this.queueRender();
677
+ });
678
+ this.wrapperEl.addEventListener("pointerup", (event) => {
679
+ if (this.pointerId !== event.pointerId) {
680
+ return;
681
+ }
682
+ const node = this.hitTest(event.clientX, event.clientY);
683
+ if (!this.pointerMoved &&
684
+ this.pointerDownNodeId &&
685
+ node?.id === this.pointerDownNodeId) {
686
+ this.callbacks.onNodeClick(node, event);
687
+ }
688
+ this.endPointerInteraction(event.pointerId);
689
+ });
690
+ this.wrapperEl.addEventListener("pointercancel", (event) => {
691
+ this.endPointerInteraction(event.pointerId);
692
+ });
693
+ this.wrapperEl.addEventListener("contextmenu", (event) => {
694
+ const node = this.hitTest(event.clientX, event.clientY);
695
+ if (!node) {
696
+ return;
697
+ }
698
+ event.preventDefault();
699
+ this.callbacks.onNodeContextMenu(node, event);
700
+ });
701
+ this.wrapperEl.addEventListener("keydown", (event) => {
702
+ const panDistance = event.shiftKey ? 90 : 36;
703
+ switch (event.key) {
704
+ case "ArrowLeft":
705
+ this.transform.x += panDistance;
706
+ this.viewportAdjustedByUser = true;
707
+ this.queueRender();
708
+ event.preventDefault();
709
+ break;
710
+ case "ArrowRight":
711
+ this.transform.x -= panDistance;
712
+ this.viewportAdjustedByUser = true;
713
+ this.queueRender();
714
+ event.preventDefault();
715
+ break;
716
+ case "ArrowUp":
717
+ this.transform.y += panDistance;
718
+ this.viewportAdjustedByUser = true;
719
+ this.queueRender();
720
+ event.preventDefault();
721
+ break;
722
+ case "ArrowDown":
723
+ this.transform.y -= panDistance;
724
+ this.viewportAdjustedByUser = true;
725
+ this.queueRender();
726
+ event.preventDefault();
727
+ break;
728
+ case "+":
729
+ case "=":
730
+ this.zoomAtCenter(GRAPH_ZOOM_STEP);
731
+ event.preventDefault();
732
+ break;
733
+ case "-":
734
+ this.zoomAtCenter(1 / GRAPH_ZOOM_STEP);
735
+ event.preventDefault();
736
+ break;
737
+ case "Escape":
738
+ this.hoveredNodeId = null;
739
+ this.hoverIntent.clear();
740
+ this.previewSuppressedNodeId = this.previewNodeId;
741
+ this.closePreview();
742
+ this.focusedNodeId = null;
743
+ this.autoCenterNodeId = null;
744
+ this.autoCenterZoom = false;
745
+ this.updateEmphasisTarget();
746
+ this.queueRender();
747
+ event.preventDefault();
748
+ break;
749
+ }
750
+ });
751
+ }
752
+ resize() {
753
+ const rect = this.wrapperEl.getBoundingClientRect();
754
+ const ratio = window.devicePixelRatio || 1;
755
+ this.canvasEl.width = Math.max(1, Math.floor(rect.width * ratio));
756
+ this.canvasEl.height = Math.max(1, Math.floor(rect.height * ratio));
757
+ this.context.setTransform(1, 0, 0, 1, 0, 0);
758
+ this.context.scale(ratio, ratio);
759
+ const viewportWidth = this.wrapperEl.clientWidth;
760
+ const viewportHeight = this.wrapperEl.clientHeight;
761
+ const prevViewportWidth = this.lastViewportWidth;
762
+ const prevViewportHeight = this.lastViewportHeight;
763
+ if (this.hasViewportSize()) {
764
+ if (this.pendingCenterNodeId) {
765
+ const node = this.nodes.find((entry) => entry.id === this.pendingCenterNodeId);
766
+ if (this.pendingFitViewport || !this.hasFittedViewport) {
767
+ this.fitGraphToViewport();
768
+ this.pendingFitViewport = false;
769
+ }
770
+ if (node) {
771
+ this.applyFocusAlignment(node);
772
+ }
773
+ this.pendingCenterNodeId = null;
774
+ }
775
+ else if (this.pendingFitViewport) {
776
+ this.fitGraphToViewport();
777
+ this.pendingFitViewport = false;
778
+ }
779
+ else if (this.autoCenterNodeId) {
780
+ const node = this.nodes.find((entry) => entry.id === this.autoCenterNodeId);
781
+ if (node) {
782
+ this.applyFocusAlignment(node);
783
+ }
784
+ }
785
+ else if (this.autoFitViewport) {
786
+ this.fitGraphToViewport();
787
+ }
788
+ else if (viewportWidth !== prevViewportWidth ||
789
+ viewportHeight !== prevViewportHeight) {
790
+ if (prevViewportWidth > 0 && prevViewportHeight > 0) {
791
+ this.applyResizeAlignment(prevViewportWidth, prevViewportHeight, viewportWidth, viewportHeight);
792
+ }
793
+ else if (!this.viewportAdjustedByUser) {
794
+ this.applyResizeAlignment(viewportWidth, viewportHeight, viewportWidth, viewportHeight);
795
+ }
796
+ }
797
+ }
798
+ this.lastViewportWidth = viewportWidth;
799
+ this.lastViewportHeight = viewportHeight;
800
+ this.queueRender();
801
+ }
802
+ hasViewportSize() {
803
+ return this.wrapperEl.clientWidth > 0 && this.wrapperEl.clientHeight > 0;
804
+ }
805
+ centerNodeInViewport(node) {
806
+ this.transform = {
807
+ ...this.transform,
808
+ x: this.wrapperEl.clientWidth / 2 - (node.x ?? 0) * this.transform.k,
809
+ y: this.wrapperEl.clientHeight / 2 - (node.y ?? 0) * this.transform.k,
810
+ };
811
+ }
812
+ applyFocusAlignment(node) {
813
+ if (this.autoFitViewport || !this.hasFittedViewport) {
814
+ this.fitGraphToViewport();
815
+ }
816
+ if (this.autoCenterZoom) {
817
+ this.transform = graphFocusTransform({
818
+ viewportWidth: this.wrapperEl.clientWidth,
819
+ viewportHeight: this.wrapperEl.clientHeight,
820
+ nodeX: node.x ?? 0,
821
+ nodeY: node.y ?? 0,
822
+ currentScale: this.transform.k,
823
+ });
824
+ return;
825
+ }
826
+ this.centerNodeInViewport(node);
827
+ }
828
+ applyResizeAlignment(prevWidth, prevHeight, nextWidth, nextHeight) {
829
+ if (!this.viewportAdjustedByUser) {
830
+ if (this.focusedNodeId) {
831
+ const node = this.nodes.find((entry) => entry.id === this.focusedNodeId);
832
+ if (node) {
833
+ this.applyFocusAlignment(node);
834
+ return;
835
+ }
836
+ }
837
+ this.fitGraphToViewport();
838
+ return;
839
+ }
840
+ this.transform = adjustTransformForViewportResize(this.transform, prevWidth, prevHeight, nextWidth, nextHeight);
841
+ }
842
+ storePositions() {
843
+ for (const node of this.nodes) {
844
+ if (typeof node.x !== "number" || typeof node.y !== "number") {
845
+ continue;
846
+ }
847
+ this.positions.set(node.id, {
848
+ x: node.x,
849
+ y: node.y,
850
+ vx: node.vx ?? 0,
851
+ vy: node.vy ?? 0,
852
+ });
853
+ }
854
+ }
855
+ fitGraphToViewport() {
856
+ const bounds = this.getBounds();
857
+ if (!bounds) {
858
+ this.transform = {
859
+ x: this.wrapperEl.clientWidth / 2,
860
+ y: this.wrapperEl.clientHeight / 2,
861
+ k: 1,
862
+ };
863
+ return;
864
+ }
865
+ const viewportWidth = Math.max(this.wrapperEl.clientWidth, 1);
866
+ const viewportHeight = Math.max(this.wrapperEl.clientHeight, 1);
867
+ const padding = 48;
868
+ this.transform = graphFitTransform({
869
+ viewportWidth,
870
+ viewportHeight,
871
+ bounds,
872
+ padding,
873
+ });
874
+ this.hasFittedViewport = true;
875
+ }
876
+ getBounds() {
877
+ let bounds = null;
878
+ for (const node of this.nodes) {
879
+ if (typeof node.x !== "number" || typeof node.y !== "number") {
880
+ continue;
881
+ }
882
+ const radius = node.radius + 12;
883
+ if (!bounds) {
884
+ bounds = {
885
+ minX: node.x - radius,
886
+ minY: node.y - radius,
887
+ maxX: node.x + radius,
888
+ maxY: node.y + radius,
889
+ };
890
+ continue;
891
+ }
892
+ bounds.minX = Math.min(bounds.minX, node.x - radius);
893
+ bounds.minY = Math.min(bounds.minY, node.y - radius);
894
+ bounds.maxX = Math.max(bounds.maxX, node.x + radius);
895
+ bounds.maxY = Math.max(bounds.maxY, node.y + radius);
896
+ }
897
+ return bounds;
898
+ }
899
+ rebuildNeighborMap() {
900
+ this.neighborMap = new Map();
901
+ for (const node of this.nodes) {
902
+ this.neighborMap.set(node.id, new Set());
903
+ }
904
+ for (const link of this.links) {
905
+ const sourceId = simulationNodeId(link.source);
906
+ const targetId = simulationNodeId(link.target);
907
+ this.neighborMap.get(sourceId)?.add(targetId);
908
+ this.neighborMap.get(targetId)?.add(sourceId);
909
+ }
910
+ }
911
+ restartSimulation(sameTopology) {
912
+ this.simulation.stop();
913
+ if (!this.settings) {
914
+ return;
915
+ }
916
+ this.layoutStartedAt = performance.now();
917
+ this.simulation = createGraphForceSimulation(this.nodes, this.links, this.settings)
918
+ .on("tick", () => {
919
+ this.applyAutoCenter();
920
+ this.queueRender();
921
+ })
922
+ .on("end", () => {
923
+ this.applyFinalAlignment();
924
+ this.callbacks.onLayoutComplete?.({
925
+ animated: !this.reducedMotion,
926
+ durationMs: Math.round(performance.now() - this.layoutStartedAt),
927
+ nodeCount: this.nodes.length,
928
+ linkCount: this.links.length,
929
+ });
930
+ });
931
+ this.simulation.stop();
932
+ if (this.reducedMotion) {
933
+ this.simulation
934
+ .alpha(sameTopology ? 0.18 : 1)
935
+ .tick(sameTopology ? 80 : REDUCED_MOTION_SETTLE_TICKS);
936
+ this.prepareInitialFit(sameTopology);
937
+ this.applyFinalAlignment();
938
+ this.callbacks.onLayoutComplete?.({
939
+ animated: false,
940
+ durationMs: Math.round(performance.now() - this.layoutStartedAt),
941
+ nodeCount: this.nodes.length,
942
+ linkCount: this.links.length,
943
+ });
944
+ return;
945
+ }
946
+ if (!sameTopology) {
947
+ this.simulation.alpha(1).tick(ENTRANCE_PREWARM_TICKS);
948
+ }
949
+ this.prepareInitialFit(sameTopology);
950
+ this.simulation.alpha(sameTopology ? 0.18 : 0.82).restart();
951
+ }
952
+ prepareInitialFit(sameTopology) {
953
+ if (sameTopology)
954
+ return;
955
+ if (this.hasViewportSize()) {
956
+ this.fitGraphToViewport();
957
+ this.pendingFitViewport = false;
958
+ }
959
+ else {
960
+ this.pendingFitViewport = true;
961
+ }
962
+ }
963
+ applyAutoCenter() {
964
+ if (this.autoCenterNodeId) {
965
+ const node = this.nodes.find((entry) => entry.id === this.autoCenterNodeId);
966
+ if (node && typeof node.x === "number" && typeof node.y === "number") {
967
+ this.transform = {
968
+ ...this.transform,
969
+ x: this.wrapperEl.clientWidth / 2 - node.x * this.transform.k,
970
+ y: this.wrapperEl.clientHeight / 2 - node.y * this.transform.k,
971
+ };
972
+ }
973
+ }
974
+ }
975
+ applyFinalAlignment() {
976
+ if (!this.hasViewportSize()) {
977
+ return;
978
+ }
979
+ if (this.autoCenterNodeId) {
980
+ const node = this.nodes.find((entry) => entry.id === this.autoCenterNodeId);
981
+ if (node) {
982
+ this.applyFocusAlignment(node);
983
+ }
984
+ }
985
+ this.autoCenterNodeId = null;
986
+ this.autoCenterZoom = false;
987
+ this.autoFitViewport = false;
988
+ this.queueRender();
989
+ }
990
+ updateEmphasisTarget() {
991
+ const nextTarget = this.emphasisNodeId || this.focusedNodeId ? 1 : 0;
992
+ if (nextTarget === this.emphasisTarget)
993
+ return;
994
+ if (this.reducedMotion) {
995
+ this.emphasisProgress = nextTarget;
996
+ this.emphasisTarget = nextTarget;
997
+ this.emphasisFrameAt = 0;
998
+ return;
999
+ }
1000
+ const now = performance.now();
1001
+ this.advanceEmphasis(now);
1002
+ this.emphasisTarget = nextTarget;
1003
+ this.emphasisFrameAt = now;
1004
+ }
1005
+ advanceEmphasis(now) {
1006
+ if (this.emphasisProgress === this.emphasisTarget)
1007
+ return false;
1008
+ const elapsed = Math.max(0, now - this.emphasisFrameAt);
1009
+ this.emphasisFrameAt = now;
1010
+ this.emphasisProgress = advanceGraphEmphasis(this.emphasisProgress, this.emphasisTarget, elapsed);
1011
+ return this.emphasisProgress !== this.emphasisTarget;
1012
+ }
1013
+ scheduleTimeLapseFrame() {
1014
+ if (this.timeLapseFrame !== null || !this.visibleTimeLapseNodes)
1015
+ return;
1016
+ this.timeLapseFrame = requestAnimationFrame((now) => {
1017
+ this.timeLapseFrame = null;
1018
+ const visible = this.visibleTimeLapseNodes;
1019
+ if (!visible)
1020
+ return;
1021
+ const elapsed = Math.max(0, now - this.timeLapseStartedAt);
1022
+ const targetCount = graphTimeLapseVisibleCount(elapsed, this.timeLapseDurationMs, this.timeLapseOrder.length);
1023
+ let added = 0;
1024
+ while (visible.size < targetCount && added < 64) {
1025
+ const nodeId = this.timeLapseOrder[visible.size];
1026
+ if (!nodeId)
1027
+ break;
1028
+ visible.add(nodeId);
1029
+ added += 1;
1030
+ }
1031
+ if (added > 0) {
1032
+ this.simulation
1033
+ .alpha(Math.max(this.simulation.alpha(), 0.08))
1034
+ .restart();
1035
+ this.queueRender();
1036
+ }
1037
+ if (elapsed >= this.timeLapseDurationMs &&
1038
+ visible.size >= this.timeLapseOrder.length) {
1039
+ this.stopTimeLapse("completed");
1040
+ return;
1041
+ }
1042
+ this.scheduleTimeLapseFrame();
1043
+ });
1044
+ }
1045
+ isNodeVisibleInTimeLapse(nodeId) {
1046
+ return this.visibleTimeLapseNodes?.has(nodeId) ?? true;
1047
+ }
1048
+ queueRender() {
1049
+ if (this.animationFrame !== null) {
1050
+ return;
1051
+ }
1052
+ this.animationFrame = requestAnimationFrame((now) => {
1053
+ this.animationFrame = null;
1054
+ const continueEmphasis = this.advanceEmphasis(now);
1055
+ this.render();
1056
+ if (continueEmphasis)
1057
+ this.queueRender();
1058
+ });
1059
+ }
1060
+ render() {
1061
+ const width = this.wrapperEl.clientWidth;
1062
+ const height = this.wrapperEl.clientHeight;
1063
+ const palette = resolveGraphPalette(this.wrapperEl);
1064
+ this.context.clearRect(0, 0, width, height);
1065
+ this.context.save();
1066
+ this.context.translate(this.transform.x, this.transform.y);
1067
+ this.context.scale(this.transform.k, this.transform.k);
1068
+ for (const link of this.links) {
1069
+ const source = link.source;
1070
+ const target = link.target;
1071
+ if (!this.isNodeVisibleInTimeLapse(source.id) ||
1072
+ !this.isNodeVisibleInTimeLapse(target.id) ||
1073
+ typeof source.x !== "number" ||
1074
+ typeof source.y !== "number" ||
1075
+ typeof target.x !== "number" ||
1076
+ typeof target.y !== "number" ||
1077
+ !graphLinkIntersectsViewport({
1078
+ sourceX: source.x,
1079
+ sourceY: source.y,
1080
+ targetX: target.x,
1081
+ targetY: target.y,
1082
+ transform: this.transform,
1083
+ viewportWidth: width,
1084
+ viewportHeight: height,
1085
+ })) {
1086
+ continue;
1087
+ }
1088
+ this.drawLink(link, palette);
1089
+ }
1090
+ for (const node of this.nodes) {
1091
+ if (!this.isNodeVisibleInTimeLapse(node.id) ||
1092
+ typeof node.x !== "number" ||
1093
+ typeof node.y !== "number" ||
1094
+ !graphNodeIntersectsViewport({
1095
+ nodeX: node.x,
1096
+ nodeY: node.y,
1097
+ screenRadius: graphNodeScreenRadius(node.radius, this.transform.k),
1098
+ transform: this.transform,
1099
+ viewportWidth: width,
1100
+ viewportHeight: height,
1101
+ })) {
1102
+ continue;
1103
+ }
1104
+ this.drawNode(node, palette);
1105
+ }
1106
+ this.context.restore();
1107
+ for (const node of this.nodes) {
1108
+ if (!this.isNodeVisibleInTimeLapse(node.id))
1109
+ continue;
1110
+ this.drawLabel(node, palette);
1111
+ }
1112
+ }
1113
+ drawLink(link, palette) {
1114
+ const source = link.source;
1115
+ const target = link.target;
1116
+ if (typeof source.x !== "number" ||
1117
+ typeof source.y !== "number" ||
1118
+ typeof target.x !== "number" ||
1119
+ typeof target.y !== "number") {
1120
+ return;
1121
+ }
1122
+ const active = this.isLinkActive(source.id, target.id);
1123
+ const usesAccent = graphLinkUsesAccentPaint(this.hasEmphasisSource(), active);
1124
+ this.context.save();
1125
+ this.context.globalAlpha = graphEmphasisAlpha("link", active, this.emphasisProgress);
1126
+ this.context.beginPath();
1127
+ this.context.moveTo(source.x, source.y);
1128
+ this.context.lineTo(target.x, target.y);
1129
+ this.context.strokeStyle = linkColor(usesAccent, palette);
1130
+ const screenScale = Math.max(this.transform.k, GRAPH_MIN_ZOOM);
1131
+ const screenLineWidth = graphLinkScreenWidth(this.settings?.display.linkThickness ?? 1);
1132
+ this.context.lineWidth = screenLineWidth / screenScale;
1133
+ this.context.stroke();
1134
+ if (!this.settings?.display.showArrows) {
1135
+ this.context.restore();
1136
+ return;
1137
+ }
1138
+ const angle = Math.atan2(target.y - source.y, target.x - source.x);
1139
+ const targetRadius = graphNodeWorldRadius(target.radius, this.transform.k) + 4 / screenScale;
1140
+ const arrowX = target.x - Math.cos(angle) * targetRadius;
1141
+ const arrowY = target.y - Math.sin(angle) * targetRadius;
1142
+ const arrowSize = 6 / screenScale;
1143
+ this.context.beginPath();
1144
+ this.context.moveTo(arrowX, arrowY);
1145
+ this.context.lineTo(arrowX - Math.cos(angle - Math.PI / 7) * arrowSize, arrowY - Math.sin(angle - Math.PI / 7) * arrowSize);
1146
+ this.context.lineTo(arrowX - Math.cos(angle + Math.PI / 7) * arrowSize, arrowY - Math.sin(angle + Math.PI / 7) * arrowSize);
1147
+ this.context.closePath();
1148
+ this.context.fillStyle = linkColor(usesAccent, palette);
1149
+ this.context.fill();
1150
+ this.context.restore();
1151
+ }
1152
+ drawNode(node, palette) {
1153
+ if (typeof node.x !== "number" || typeof node.y !== "number") {
1154
+ return;
1155
+ }
1156
+ const active = this.isNodeActive(node.id);
1157
+ const isEmphasisSource = node.id === this.emphasisNodeId || node.id === this.focusedNodeId;
1158
+ const screenScale = Math.max(this.transform.k, GRAPH_MIN_ZOOM);
1159
+ const radius = graphNodeWorldRadius(node.radius, screenScale);
1160
+ this.context.save();
1161
+ this.context.globalAlpha = graphEmphasisAlpha("node", active, this.emphasisProgress);
1162
+ this.context.beginPath();
1163
+ this.context.arc(node.x, node.y, radius, 0, Math.PI * 2);
1164
+ this.context.fillStyle = isEmphasisSource
1165
+ ? palette.nodeFocused
1166
+ : nodeColor(node, palette);
1167
+ this.context.fill();
1168
+ if (isEmphasisSource) {
1169
+ this.context.lineWidth = 1.5 / screenScale;
1170
+ this.context.strokeStyle = palette.nodeStrokeActive;
1171
+ this.context.stroke();
1172
+ }
1173
+ if (node.id === this.focusedNodeId) {
1174
+ this.context.beginPath();
1175
+ this.context.arc(node.x, node.y, radius + 3.5 / screenScale, 0, Math.PI * 2);
1176
+ this.context.lineWidth = 1.75 / screenScale;
1177
+ this.context.strokeStyle = palette.nodeFocusRing;
1178
+ this.context.stroke();
1179
+ }
1180
+ this.context.restore();
1181
+ }
1182
+ drawLabel(node, palette) {
1183
+ if (typeof node.x !== "number" || typeof node.y !== "number") {
1184
+ return;
1185
+ }
1186
+ const isHoveredLabel = node.id === this.hoveredNodeId;
1187
+ const active = this.isNodeActive(node.id);
1188
+ const alpha = graphNodeLabelAlpha({
1189
+ zoom: this.transform.k,
1190
+ textFadeThreshold: this.settings?.display.textFadeThreshold ?? 0.8,
1191
+ hovered: isHoveredLabel,
1192
+ context: active,
1193
+ }) * graphEmphasisAlpha("label", active, this.emphasisProgress);
1194
+ if (alpha <= 0.05) {
1195
+ return;
1196
+ }
1197
+ const screenX = node.x * this.transform.k + this.transform.x;
1198
+ const screenY = node.y * this.transform.k + this.transform.y;
1199
+ if (!graphNodeIntersectsViewport({
1200
+ nodeX: node.x,
1201
+ nodeY: node.y,
1202
+ screenRadius: graphNodeScreenRadius(node.radius, this.transform.k),
1203
+ transform: this.transform,
1204
+ viewportWidth: this.wrapperEl.clientWidth,
1205
+ viewportHeight: this.wrapperEl.clientHeight,
1206
+ margin: 80,
1207
+ })) {
1208
+ return;
1209
+ }
1210
+ const screenRadius = graphNodeScreenRadius(node.radius, this.transform.k);
1211
+ this.context.save();
1212
+ this.context.globalAlpha = alpha;
1213
+ this.context.font = isHoveredLabel
1214
+ ? "600 11px system-ui"
1215
+ : "500 11px system-ui";
1216
+ this.context.fillStyle = isHoveredLabel
1217
+ ? palette.labelHover
1218
+ : palette.label;
1219
+ this.context.textAlign = "center";
1220
+ this.context.textBaseline = "top";
1221
+ this.context.fillText(node.label, screenX, screenY + screenRadius + 8);
1222
+ this.context.restore();
1223
+ }
1224
+ isNodeActive(nodeId) {
1225
+ if (!this.emphasisNodeId && !this.focusedNodeId) {
1226
+ return true;
1227
+ }
1228
+ if (nodeId === this.emphasisNodeId || nodeId === this.focusedNodeId) {
1229
+ return true;
1230
+ }
1231
+ if (this.emphasisNodeId &&
1232
+ this.neighborMap.get(this.emphasisNodeId)?.has(nodeId)) {
1233
+ return true;
1234
+ }
1235
+ if (this.focusedNodeId &&
1236
+ this.neighborMap.get(this.focusedNodeId)?.has(nodeId)) {
1237
+ return true;
1238
+ }
1239
+ return false;
1240
+ }
1241
+ hasEmphasisSource() {
1242
+ return this.emphasisNodeId !== null || this.focusedNodeId !== null;
1243
+ }
1244
+ isLinkActive(sourceId, targetId) {
1245
+ if (!this.emphasisNodeId && !this.focusedNodeId) {
1246
+ return true;
1247
+ }
1248
+ const hoveredActive = this.emphasisNodeId !== null &&
1249
+ (sourceId === this.emphasisNodeId || targetId === this.emphasisNodeId);
1250
+ const focusedActive = this.focusedNodeId !== null &&
1251
+ (sourceId === this.focusedNodeId || targetId === this.focusedNodeId);
1252
+ return hoveredActive || focusedActive;
1253
+ }
1254
+ hitTest(clientX, clientY) {
1255
+ const point = this.screenToWorld(clientX, clientY);
1256
+ for (let index = this.nodes.length - 1; index >= 0; index -= 1) {
1257
+ const node = this.nodes[index];
1258
+ if (!this.isNodeVisibleInTimeLapse(node.id))
1259
+ continue;
1260
+ if (typeof node.x !== "number" || typeof node.y !== "number") {
1261
+ continue;
1262
+ }
1263
+ const distance = Math.hypot(point.x - node.x, point.y - node.y);
1264
+ if (distance <=
1265
+ graphNodeWorldRadius(node.radius, this.transform.k) +
1266
+ 6 / this.transform.k) {
1267
+ return node;
1268
+ }
1269
+ }
1270
+ return null;
1271
+ }
1272
+ openPreviewForHoveredNode() {
1273
+ const node = this.nodes.find((entry) => entry.id === this.hoveredNodeId);
1274
+ if (node)
1275
+ this.openPreview(node);
1276
+ }
1277
+ openPreview(node) {
1278
+ if (node.id === this.previewNodeId ||
1279
+ node.id === this.previewSuppressedNodeId ||
1280
+ !graphNodeSupportsPreview(node)) {
1281
+ return;
1282
+ }
1283
+ this.previewNodeId = node.id;
1284
+ const anchor = {
1285
+ getBoundingClientRect: () => {
1286
+ const current = this.nodes.find((entry) => entry.id === node.id);
1287
+ const canvasRect = this.canvasEl.getBoundingClientRect();
1288
+ if (!current)
1289
+ return new DOMRect(canvasRect.left, canvasRect.top, 0, 0);
1290
+ const rect = graphNodePreviewRect({
1291
+ node: current,
1292
+ transform: this.transform,
1293
+ canvasRect,
1294
+ });
1295
+ return new DOMRect(rect.x, rect.y, rect.width, rect.height);
1296
+ },
1297
+ };
1298
+ this.callbacks.onNodePreviewChange?.({ node, anchor });
1299
+ }
1300
+ closePreview() {
1301
+ if (this.previewNodeId === null)
1302
+ return;
1303
+ this.previewNodeId = null;
1304
+ this.callbacks.onNodePreviewChange?.(null);
1305
+ }
1306
+ screenToWorld(clientX, clientY) {
1307
+ const rect = this.canvasEl.getBoundingClientRect();
1308
+ const offsetX = clientX - rect.left;
1309
+ const offsetY = clientY - rect.top;
1310
+ return {
1311
+ x: (offsetX - this.transform.x) / this.transform.k,
1312
+ y: (offsetY - this.transform.y) / this.transform.k,
1313
+ };
1314
+ }
1315
+ pointerDistance(clientX, clientY) {
1316
+ return Math.hypot(clientX - this.pointerStart.x, clientY - this.pointerStart.y);
1317
+ }
1318
+ endPointerInteraction(pointerId) {
1319
+ if (this.pointerId !== pointerId) {
1320
+ return;
1321
+ }
1322
+ if (this.dragNode) {
1323
+ this.dragNode.fx = null;
1324
+ this.dragNode.fy = null;
1325
+ this.dragNode = null;
1326
+ this.simulation.alphaTarget(0);
1327
+ }
1328
+ this.autoCenterNodeId = null;
1329
+ this.autoFitViewport = false;
1330
+ this.wrapperEl.releasePointerCapture(pointerId);
1331
+ this.pointerMode = null;
1332
+ this.pointerId = null;
1333
+ this.pointerDownNodeId = null;
1334
+ this.canvasEl.style.cursor = this.hoveredNodeId ? "pointer" : "grab";
1335
+ this.storePositions();
1336
+ }
1337
+ zoomAtCenter(factor) {
1338
+ this.autoCenterNodeId = null;
1339
+ this.autoFitViewport = false;
1340
+ this.viewportAdjustedByUser = true;
1341
+ const centerX = this.wrapperEl.clientWidth / 2;
1342
+ const centerY = this.wrapperEl.clientHeight / 2;
1343
+ const worldX = (centerX - this.transform.x) / this.transform.k;
1344
+ const worldY = (centerY - this.transform.y) / this.transform.k;
1345
+ this.transform.k = clampGraphZoom(this.transform.k * factor);
1346
+ this.transform.x = centerX - worldX * this.transform.k;
1347
+ this.transform.y = centerY - worldY * this.transform.k;
1348
+ this.queueRender();
1349
+ }
1350
+ }