@iloveagents/foundry-web-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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/dist/adapters/index.d.ts +1 -0
  4. package/dist/adapters/index.js +1 -0
  5. package/dist/adapters/neo4j.d.ts +52 -0
  6. package/dist/adapters/neo4j.js +149 -0
  7. package/dist/assistant-ui/graph-client-tools.d.ts +11 -0
  8. package/dist/assistant-ui/graph-client-tools.js +261 -0
  9. package/dist/assistant-ui/graph-context.d.ts +38 -0
  10. package/dist/assistant-ui/graph-context.js +113 -0
  11. package/dist/assistant-ui/graph-panel-content.d.ts +45 -0
  12. package/dist/assistant-ui/graph-panel-content.js +28 -0
  13. package/dist/assistant-ui/graph-panel.d.ts +11 -0
  14. package/dist/assistant-ui/graph-panel.js +121 -0
  15. package/dist/assistant-ui/graph-provenance.d.ts +17 -0
  16. package/dist/assistant-ui/graph-provenance.js +31 -0
  17. package/dist/assistant-ui/graph-result-seeder.d.ts +73 -0
  18. package/dist/assistant-ui/graph-result-seeder.js +157 -0
  19. package/dist/assistant-ui/graph-tool-registry.d.ts +25 -0
  20. package/dist/assistant-ui/graph-tool-registry.js +15 -0
  21. package/dist/assistant-ui/graph-tool-ui.d.ts +54 -0
  22. package/dist/assistant-ui/graph-tool-ui.js +77 -0
  23. package/dist/assistant-ui/index.d.ts +17 -0
  24. package/dist/assistant-ui/index.js +17 -0
  25. package/dist/assistant-ui/merge-into-panel.d.ts +21 -0
  26. package/dist/assistant-ui/merge-into-panel.js +72 -0
  27. package/dist/assistant-ui/register-graph-panel.d.ts +8 -0
  28. package/dist/assistant-ui/register-graph-panel.js +18 -0
  29. package/dist/caption-placement.d.ts +119 -0
  30. package/dist/caption-placement.js +146 -0
  31. package/dist/graph-canvas-paint.d.ts +90 -0
  32. package/dist/graph-canvas-paint.js +186 -0
  33. package/dist/graph-canvas.d.ts +49 -0
  34. package/dist/graph-canvas.js +533 -0
  35. package/dist/graph-inspector.d.ts +42 -0
  36. package/dist/graph-inspector.js +106 -0
  37. package/dist/graph-legend.d.ts +19 -0
  38. package/dist/graph-legend.js +20 -0
  39. package/dist/graph-notice.d.ts +19 -0
  40. package/dist/graph-notice.js +30 -0
  41. package/dist/graph-table.d.ts +28 -0
  42. package/dist/graph-table.js +57 -0
  43. package/dist/graph-toolbar.d.ts +22 -0
  44. package/dist/graph-toolbar.js +8 -0
  45. package/dist/graph-tooltip.d.ts +4 -0
  46. package/dist/graph-tooltip.js +55 -0
  47. package/dist/graph-view.d.ts +94 -0
  48. package/dist/graph-view.js +338 -0
  49. package/dist/graph-workspace.d.ts +55 -0
  50. package/dist/graph-workspace.js +100 -0
  51. package/dist/index.d.ts +21 -0
  52. package/dist/index.js +21 -0
  53. package/dist/model.d.ts +206 -0
  54. package/dist/model.js +369 -0
  55. package/dist/styles.css +97 -0
  56. package/dist/theme.d.ts +36 -0
  57. package/dist/theme.js +83 -0
  58. package/dist/use-element-size.d.ts +13 -0
  59. package/dist/use-element-size.js +32 -0
  60. package/dist/use-graph-model.d.ts +101 -0
  61. package/dist/use-graph-model.js +164 -0
  62. package/dist/use-graph-styling.d.ts +55 -0
  63. package/dist/use-graph-styling.js +156 -0
  64. package/dist/use-graph-theme.d.ts +11 -0
  65. package/dist/use-graph-theme.js +54 -0
  66. package/dist/use-graph-view-state.d.ts +42 -0
  67. package/dist/use-graph-view-state.js +145 -0
  68. package/package.json +83 -0
@@ -0,0 +1,533 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react";
3
+ import ForceGraph2D from "react-force-graph-2d";
4
+ import { primaryLabel } from "./model.js";
5
+ import { paintEdgeLabel, paintNode, paintNodePointerArea, truncateCaption, } from "./graph-canvas-paint.js";
6
+ import { edgeTooltip, nodeTooltip } from "./graph-tooltip.js";
7
+ import { LabelTier, captionRect, createCaptionMeasurer, edgeLabelRect, nodeRect, placeLabels, } from "./caption-placement.js";
8
+ /** Matches the platform default well enough that the gesture feels native. */
9
+ const DOUBLE_CLICK_MS = 350;
10
+ /**
11
+ * Upper bound on zoom.
12
+ *
13
+ * `zoomToFit` has no upper bound, so a two- or three-node result frames itself
14
+ * at a scale where the nodes fill the panel like beach balls. Capping keeps a
15
+ * small graph looking like a small graph, while leaving room to zoom into a
16
+ * dense neighbourhood by hand.
17
+ */
18
+ const MAX_ZOOM = 2.2;
19
+ /**
20
+ * How far inside the visible edge a revealed node must sit.
21
+ *
22
+ * Bigger than it looks necessary because captions are drawn BELOW their
23
+ * node: a node exactly on the edge has a legible dot and an unreadable name.
24
+ */
25
+ const REVEAL_MARGIN = 48;
26
+ /**
27
+ * Breathing room around a fitted graph, in screen pixels.
28
+ *
29
+ * Generous on purpose: `zoomToFit` frames the node CENTRES, so a tight padding
30
+ * clips the captions drawn beneath the outermost nodes — which is exactly
31
+ * where the labels you most want to read tend to sit.
32
+ */
33
+ const FIT_PADDING = 64;
34
+ /**
35
+ * How long the simulation runs before freezing.
36
+ *
37
+ * force-graph's 15s default leaves a small graph visibly drifting long after
38
+ * it has found its shape, which reads as the page being busy. Large graphs get
39
+ * less, not more: they will never fully converge, and a slideshow of a
40
+ * thousand nodes rearranging is worse than an imperfect but stable picture.
41
+ */
42
+ const cooldownFor = (nodeCount) => (nodeCount > 600 ? 3500 : 6000);
43
+ /**
44
+ * Ticks to run before the first paint when the user has asked for no motion.
45
+ *
46
+ * These are synchronous, so the number is a budget rather than a quality
47
+ * target: enough for the layout to read as a graph, capped so a large result
48
+ * cannot freeze the tab. d3-force is mostly converged well before 300.
49
+ */
50
+ const warmupFor = (nodeCount) => (nodeCount > 600 ? 120 : 300);
51
+ /** How often to re-frame while the layout is still moving. */
52
+ const FIT_INTERVAL_MS = 150;
53
+ /** Keep re-framing a little past the cooldown so the final shape is included. */
54
+ const FIT_TAIL_MS = 600;
55
+ /**
56
+ * Force tuning.
57
+ *
58
+ * force-graph's defaults are built for abstract network diagrams: weak
59
+ * repulsion and a short link distance, which packs a labelled graph into a
60
+ * knot where every caption overlaps its neighbours. These spread it out enough
61
+ * that the captions have room, without stringing small graphs across the whole
62
+ * canvas.
63
+ *
64
+ * Both scale with size — a 20-node graph wants more space per node than a
65
+ * 1,000-node one, which would otherwise fly apart.
66
+ */
67
+ function tuneForces(engine, nodeCount, radiusOf) {
68
+ if (!engine)
69
+ return;
70
+ const spread = Math.max(0.5, Math.min(1, 60 / Math.max(nodeCount, 1)));
71
+ const charge = engine.d3Force("charge");
72
+ if (charge && typeof charge.strength === "function") {
73
+ const base = -120 * spread - 60;
74
+ // Repulsion proportional to size. force-graph registers no collision
75
+ // force, so without this a big hub and its caption sit on top of the
76
+ // small nodes around it — and the hubs are exactly the nodes whose labels
77
+ // you most want to read.
78
+ charge.strength((node) => base * (0.7 + radiusOf(node) / 8));
79
+ }
80
+ const link = engine.d3Force("link");
81
+ if (link && typeof link.distance === "function") {
82
+ link.distance(28 * spread + 22);
83
+ }
84
+ engine.d3Force("graphCohesion", cohesionForce());
85
+ }
86
+ /**
87
+ * A weak pull toward the origin.
88
+ *
89
+ * The link force only acts WITHIN a connected component, and charge pushes
90
+ * everything apart — so a result with two components has nothing holding them
91
+ * together. They drift to opposite corners, `zoomToFit` frames the empty space
92
+ * between them, and a six-node answer renders as two specks with a void in the
93
+ * middle. (Seen on "people who worked on more than one film": two clusters,
94
+ * most of the panel empty.)
95
+ *
96
+ * Written by hand rather than importing `d3-force`: force-graph bundles its
97
+ * own copy, and pulling in a second one to get `forceX` would ship the whole
98
+ * module twice. A d3 force is just a function with an `initialize` hook.
99
+ */
100
+ /** Namespaces edge labels in the placement set, which node ids also live in. */
101
+ const EDGE_LABEL_PREFIX = "edge:";
102
+ /** Set equality by membership — cheaper than rebuilding and re-rendering. */
103
+ /**
104
+ * How often the caption placement may be recomputed, in milliseconds.
105
+ *
106
+ * Driven from `onRenderFramePre`, which fires ~60 times a second, so it needs a
107
+ * throttle. Ten passes a second is imperceptible for labels appearing and
108
+ * disappearing, and the placement is skipped entirely when it comes out the
109
+ * same (see `sameIds`), so a settled graph costs a cheap scan and no renders.
110
+ */
111
+ const CAPTION_INTERVAL_MS = 100;
112
+ /** Set equality by membership — cheaper than rebuilding and re-rendering. */
113
+ /**
114
+ * TEMPORARY probe: does `onRenderFramePre` actually fire?
115
+ *
116
+ * `onEngineTick` and `onEngineStop` are in the same typings and never run
117
+ * through the react-kapsule wrapper, so the frame hook cannot be assumed to
118
+ * work either. Logs once and gets deleted along with this comment as soon as
119
+ * the answer is recorded in AGENTS.md.
120
+ */
121
+ let framesSeen = 0;
122
+ function probeFrameHook() {
123
+ framesSeen += 1;
124
+ if (framesSeen === 1 || framesSeen === 60) {
125
+ console.info(`[foundry-graph probe] onRenderFramePre fired ${framesSeen}x`);
126
+ }
127
+ }
128
+ function sameIds(a, b) {
129
+ if (a.size !== b.size)
130
+ return false;
131
+ for (const id of a)
132
+ if (!b.has(id))
133
+ return false;
134
+ return true;
135
+ }
136
+ function cohesionForce() {
137
+ let nodes = [];
138
+ const force = (alpha) => {
139
+ // Proportional to distance, so distant components are pulled hardest and
140
+ // nodes already near the centre are barely touched.
141
+ const k = alpha * 0.09;
142
+ for (const node of nodes) {
143
+ if (node.fx != null || node.fy != null)
144
+ continue; // pinned: user's choice wins
145
+ if (typeof node.x === "number")
146
+ node.vx = (node.vx ?? 0) - node.x * k;
147
+ if (typeof node.y === "number")
148
+ node.vy = (node.vy ?? 0) - node.y * k;
149
+ }
150
+ };
151
+ force.initialize = (initial) => {
152
+ nodes = initial;
153
+ };
154
+ return force;
155
+ }
156
+ /**
157
+ * The force-directed canvas.
158
+ *
159
+ * The ONLY module in this package that imports `react-force-graph-2d`, so the
160
+ * engine can be code-split away from the data model and the surrounding chrome
161
+ * (a guard test enforces the boundary). Everything it needs is passed in
162
+ * already computed — it owns no state of its own beyond the engine handle.
163
+ */
164
+ export function GraphCanvas({ model, styleMap, theme, state, width, height, onExpandNode, insetRight = 0, handleRef, reducedMotion = false, }) {
165
+ const engineRef = useRef(undefined);
166
+ const insetRightRef = useRef(insetRight);
167
+ insetRightRef.current = insetRight;
168
+ /**
169
+ * Frame the graph into what the user can SEE, not into the whole canvas.
170
+ *
171
+ * `zoomToFit` knows nothing about the details drawer, so a fit ran while one
172
+ * is open settles half the graph underneath it — and because a merge changes
173
+ * the model, the fit loop it restarts would undo the `reveal` that had just
174
+ * brought the selected node clear. Its padding is uniform, so the extra room
175
+ * it leaves on the other three sides is corrected by nudging the camera.
176
+ */
177
+ const fit = useCallback((durationMs = 0) => {
178
+ const engine = engineRef.current;
179
+ if (!engine)
180
+ return;
181
+ const inset = insetRightRef.current;
182
+ engine.zoomToFit(durationMs, FIT_PADDING + inset / 2);
183
+ if (inset <= 0)
184
+ return;
185
+ const zoom = engine.zoom() || 1;
186
+ const centre = engine.centerAt();
187
+ engine.centerAt(centre.x + inset / 2 / zoom, centre.y, durationMs);
188
+ }, []);
189
+ /**
190
+ * Restart the layout.
191
+ *
192
+ * `d3ReheatSimulation` only sets `alpha` back to 1 and un-pauses the engine
193
+ * — the ticks themselves happen in the render loop, one per frame, and under
194
+ * reduced motion `cooldownTicks`/`cooldownTime` are 0, so the engine stops
195
+ * again on the very next frame having ticked nothing. Reheat was a button
196
+ * that did nothing at all for exactly the users who cannot see a layout
197
+ * settle and most need it re-run.
198
+ *
199
+ * The warmup ticks are the settle those users get, and they run
200
+ * SYNCHRONOUSLY whenever force-graph re-runs its update — which a new
201
+ * `graphData` identity is what causes. So: reset alpha, then re-identify the
202
+ * data. Same restart, arrived at without motion.
203
+ */
204
+ const [layoutEpoch, setLayoutEpoch] = useState(0);
205
+ const reheat = useCallback(() => {
206
+ engineRef.current?.d3ReheatSimulation();
207
+ if (reducedMotion)
208
+ setLayoutEpoch((epoch) => epoch + 1);
209
+ }, [reducedMotion]);
210
+ /**
211
+ * Memoised, and that is load-bearing twice over.
212
+ *
213
+ * An inline `{ nodes, links }` is a new object on every render, and
214
+ * react-kapsule forwards a prop whose identity changed — so a hover, a
215
+ * search keystroke or a selection re-ran force-graph's whole update: the
216
+ * warmup ticks again (all of them, synchronously, under reduced motion) and
217
+ * `resetCountdown`, which restarts the cooldown clock. A graph the user was
218
+ * hovering therefore never reached its settle. Handing the same object back
219
+ * until the model or the layout epoch actually changes is what makes the
220
+ * update mean "the graph changed".
221
+ */
222
+ const graphData = useMemo(() => ({ nodes: model.nodes, links: model.links }), [model.nodes, model.links, layoutEpoch]);
223
+ useImperativeHandle(handleRef, () => ({
224
+ zoomToFit: (durationMs = 400) => fit(reducedMotion ? 0 : durationMs),
225
+ centerOn: (nodeId, durationMs = 400) => {
226
+ const node = model.nodes.find((candidate) => candidate.id === nodeId);
227
+ if (!node || typeof node.x !== "number" || typeof node.y !== "number")
228
+ return;
229
+ // Offset by half the covered strip, so the node lands in the middle of
230
+ // what is visible rather than the middle of the canvas. Centring a node
231
+ // underneath the panel that describes it is the failure this exists to
232
+ // prevent — and it is the agent's own `graph_select_node` that would
233
+ // most often cause it.
234
+ const zoom = engineRef.current?.zoom() || 1;
235
+ engineRef.current?.centerAt(node.x + insetRight / 2 / zoom, node.y, reducedMotion ? 0 : durationMs);
236
+ },
237
+ reveal: (nodeId, durationMs = 400) => {
238
+ const engine = engineRef.current;
239
+ const node = model.nodes.find((candidate) => candidate.id === nodeId);
240
+ if (!engine || !node || typeof node.x !== "number" || typeof node.y !== "number")
241
+ return;
242
+ const screen = engine.graph2ScreenCoords(node.x, node.y);
243
+ const right = width - insetRight - REVEAL_MARGIN;
244
+ const bottom = height - REVEAL_MARGIN;
245
+ // Signed distance out of the comfortable box, zero when inside it.
246
+ const dx = Math.max(0, screen.x - right) + Math.min(0, screen.x - REVEAL_MARGIN);
247
+ const dy = Math.max(0, screen.y - bottom) + Math.min(0, screen.y - REVEAL_MARGIN);
248
+ if (dx === 0 && dy === 0)
249
+ return;
250
+ const zoom = engine.zoom() || 1;
251
+ const centre = engine.centerAt();
252
+ engine.centerAt(centre.x + dx / zoom, centre.y + dy / zoom, reducedMotion ? 0 : durationMs);
253
+ },
254
+ reheat,
255
+ }), [model.nodes, reducedMotion, insetRight, width, height, reheat]);
256
+ const { selection, hoveredId, matchedIds, hiddenLabels, pinnedIds, query, selectNode, selectEdge, setHoveredId, togglePinned, } = state;
257
+ const isHidden = useCallback((node) => hiddenLabels.has(primaryLabel(node)), [hiddenLabels]);
258
+ const isLinkHidden = useCallback((link) => {
259
+ const source = link.source;
260
+ const target = link.target;
261
+ // force-graph replaces the endpoint ids with the node objects once the
262
+ // graph is loaded, and hands back the raw id before that.
263
+ return ((typeof source === "object" && source !== null && isHidden(source)) ||
264
+ (typeof target === "object" && target !== null && isHidden(target)));
265
+ }, [isHidden]);
266
+ /**
267
+ * Select on click, expand on double-click.
268
+ *
269
+ * `react-force-graph-2d` exposes no double-click event — only click, right-click
270
+ * and hover — so the gesture is reconstructed here. Bloom expands on
271
+ * double-click and that is the muscle memory people bring, so "no library
272
+ * support" is not a good enough reason to move it onto a different gesture.
273
+ *
274
+ * The first click still selects, which is what makes this feel right: the
275
+ * inspector opens immediately and the expand lands on top of it.
276
+ */
277
+ const lastClickRef = useRef(null);
278
+ const handleNodeClick = useCallback((node) => {
279
+ const id = String(node.id);
280
+ const now = Date.now();
281
+ const previous = lastClickRef.current;
282
+ lastClickRef.current = { id, at: now };
283
+ selectNode(node);
284
+ if (onExpandNode && previous && previous.id === id && now - previous.at <= DOUBLE_CLICK_MS) {
285
+ lastClickRef.current = null;
286
+ onExpandNode(node);
287
+ }
288
+ }, [onExpandNode, selectNode]);
289
+ /**
290
+ * Captions that can be drawn without overprinting each other.
291
+ *
292
+ * Recomputed from SCREEN positions, so it has to run after the engine has
293
+ * placed the nodes and again whenever the view moves. `onRenderFramePre`
294
+ * would be the natural home — reset per frame, no staleness — but callbacks
295
+ * on this wrapper are not to be trusted (`onEngineTick` and `onEngineStop`
296
+ * never fire), so this is driven from React and the frame hook is only used
297
+ * as a signal that the view changed.
298
+ */
299
+ const measure = useMemo(() => createCaptionMeasurer(), []);
300
+ // Relationship types are painted at 9px, not 11px — a different font, so a
301
+ // different cache.
302
+ const measureEdge = useMemo(() => createCaptionMeasurer("9px ui-sans-serif, system-ui, sans-serif"), []);
303
+ const [captioned, setCaptioned] = useState(() => new Set());
304
+ const captionInputsRef = useRef({ selectedId: "", hoveredId: "", matchedIds: new Set() });
305
+ const recomputeCaptions = useCallback(() => {
306
+ const engine = engineRef.current;
307
+ if (!engine || width === 0 || height === 0)
308
+ return;
309
+ const zoom = engine.zoom() || 1;
310
+ const { selectedId, hoveredId: hovered, matchedIds: matches } = captionInputsRef.current;
311
+ const screens = new Map();
312
+ const obstacles = [];
313
+ const candidates = [];
314
+ for (const node of model.nodes) {
315
+ if (typeof node.x !== "number" || typeof node.y !== "number")
316
+ continue;
317
+ if (isHidden(node))
318
+ continue;
319
+ const style = styleMap.styleForNode(node);
320
+ const screen = engine.graph2ScreenCoords(node.x, node.y);
321
+ const id = String(node.id);
322
+ const radius = style.radius * zoom;
323
+ screens.set(id, { x: screen.x, y: screen.y, radius });
324
+ obstacles.push({ id, rect: nodeRect(screen.x, screen.y, radius) });
325
+ if (!style.caption)
326
+ continue;
327
+ candidates.push({
328
+ id,
329
+ rect: captionRect(screen.x, screen.y, radius, measure(truncateCaption(style.caption))),
330
+ tier: id === selectedId || id === hovered
331
+ ? LabelTier.Pinned
332
+ : matches.has(id)
333
+ ? LabelTier.Match
334
+ : LabelTier.Caption,
335
+ weight: style.radius,
336
+ exempt: [id],
337
+ });
338
+ }
339
+ // Relationship types compete in the same pass, one tier down. They overlap
340
+ // captions as readily as captions overlap each other, and a `DIRECTED` you
341
+ // cannot read is a smaller loss than a `Metropolis` you cannot read.
342
+ for (const link of model.links) {
343
+ const type = link.type;
344
+ if (!type)
345
+ continue;
346
+ const source = screens.get(String(link.source?.id ?? link.source));
347
+ const target = screens.get(String(link.target?.id ?? link.target));
348
+ if (!source || !target)
349
+ continue;
350
+ const sourceId = String(link.source?.id ?? link.source);
351
+ const targetId = String(link.target?.id ?? link.target);
352
+ candidates.push({
353
+ id: `${EDGE_LABEL_PREFIX}${link.id}`,
354
+ rect: edgeLabelRect(source.x, source.y, target.x, target.y, measureEdge(type)),
355
+ tier: LabelTier.Edge,
356
+ weight: 0,
357
+ exempt: [sourceId, targetId],
358
+ });
359
+ }
360
+ const next = placeLabels(candidates, { obstacles, viewport: { width, height } });
361
+ // Replace only on a real change: this runs on every frame the view moves,
362
+ // and a new Set each time would re-render the whole canvas for nothing.
363
+ setCaptioned((prev) => (sameIds(prev, next) ? prev : next));
364
+ }, [model.nodes, model.links, styleMap, isHidden, measure, measureEdge, width, height]);
365
+ const lastPlacedRef = useRef(0);
366
+ const handleFramePre = useCallback(() => {
367
+ const now = Date.now();
368
+ if (now - lastPlacedRef.current < CAPTION_INTERVAL_MS)
369
+ return;
370
+ lastPlacedRef.current = now;
371
+ recomputeCaptions();
372
+ }, [recomputeCaptions]);
373
+ /**
374
+ * Keep the whole graph in frame while the layout settles.
375
+ *
376
+ * Driven from React on an interval rather than from force-graph's
377
+ * `onEngineTick` / `onEngineStop`: those callbacks do not fire through the
378
+ * react-kapsule wrapper (verified in a browser — the imperative
379
+ * `zoomToFit` works, the callbacks never run), so anything hung off them
380
+ * silently never happens.
381
+ *
382
+ * Without this the first seconds show whatever fraction of the graph the
383
+ * random initial layout put on screen, usually wildly zoomed in. Re-framing
384
+ * as it forms means the layout is legible the whole time.
385
+ *
386
+ * It stops once the simulation has settled: a view that re-frames itself
387
+ * after the user has panned somewhere is worse than one that never fits.
388
+ * Re-runs on resize, because the panel is drag-resizable and has a
389
+ * fullscreen toggle.
390
+ */
391
+ // `styleMap` is rebuilt whenever the THEME changes, because it carries the
392
+ // resolved canvas colours — and the fit loop below must not restart for a
393
+ // colour. A user who switched to dark mode after arranging the graph lost
394
+ // their view and could not get it back for the ~6.6s the loop runs. Read
395
+ // through a ref so only the layout inputs are dependencies.
396
+ const radiusRef = useRef((node) => styleMap.styleForNode(node).radius);
397
+ radiusRef.current = (node) => styleMap.styleForNode(node).radius;
398
+ useEffect(() => {
399
+ if (width === 0 || height === 0)
400
+ return;
401
+ tuneForces(engineRef.current, model.nodes.length, (node) => radiusRef.current(node));
402
+ const deadline = Date.now() + cooldownFor(model.nodes.length) + FIT_TAIL_MS;
403
+ // Fit immediately so nothing is off-screen even for one frame.
404
+ fit();
405
+ const timer = setInterval(() => {
406
+ if (Date.now() > deadline) {
407
+ clearInterval(timer);
408
+ return;
409
+ }
410
+ fit();
411
+ }, FIT_INTERVAL_MS);
412
+ return () => clearInterval(timer);
413
+ // `fit` reads the drawer's width through a ref rather than taking it as a
414
+ // dependency: opening the drawer must not restart the fit loop, or the
415
+ // click that means "tell me about this node" would re-frame the graph the
416
+ // user had arranged. `reveal` is what handles the drawer opening.
417
+ // eslint-disable-next-line react-hooks/exhaustive-deps
418
+ }, [model, width, height, fit]);
419
+ // Selection, hover and search decide a caption's priority, so a change to
420
+ // any of them re-places. Held in a ref as well so the framing interval and
421
+ // the zoom handler can read them without being rebuilt on every hover.
422
+ useEffect(() => {
423
+ captionInputsRef.current = {
424
+ selectedId: selection.node ? String(selection.node.id) : "",
425
+ hoveredId: hoveredId ?? "",
426
+ matchedIds,
427
+ };
428
+ recomputeCaptions();
429
+ }, [selection.node, hoveredId, matchedIds, recomputeCaptions]);
430
+ // A search is active when it can MATCH something, which is the trimmed term.
431
+ // `matchNodes` trims and returns nothing for whitespace, so testing the raw
432
+ // length instead dimmed every node as a non-match: typing a space into the
433
+ // toolbar, or `graph_search(" ")`, greyed the entire graph out.
434
+ const searching = query.trim().length > 0;
435
+ const nodeCanvasObject = useCallback((node, ctx, globalScale) => {
436
+ const graphNode = node;
437
+ const isSelected = selection.node?.id === graphNode.id;
438
+ const isHovered = hoveredId === graphNode.id;
439
+ const isMatch = searching && matchedIds.has(graphNode.id);
440
+ paintNode(node, {
441
+ style: styleMap.styleForNode(graphNode),
442
+ selected: isSelected,
443
+ hovered: isHovered,
444
+ // A search dims the misses so the hits stand out; with no search
445
+ // running, nothing is dimmed.
446
+ dimmed: searching && !isMatch,
447
+ pinned: pinnedIds.has(graphNode.id),
448
+ // Placement decides, and it already knows about selection, hover
449
+ // and search — see `recomputeCaptions`. Re-adding "always show the
450
+ // selected node" here would put back the overlaps.
451
+ showCaption: captioned.has(graphNode.id),
452
+ }, theme, ctx, globalScale);
453
+ }, [styleMap, selection.node?.id, hoveredId, searching, matchedIds, pinnedIds, theme, captioned]);
454
+ const nodePointerAreaPaint = useCallback((node, color, ctx) => {
455
+ paintNodePointerArea(node, color, styleMap.styleForNode(node), ctx);
456
+ }, [styleMap]);
457
+ const linkCanvasObject = useCallback((link, ctx, globalScale) => {
458
+ // Only worth drawing while the graph is sparse enough to read.
459
+ if (model.links.length > 300)
460
+ return;
461
+ if (!captioned.has(`${EDGE_LABEL_PREFIX}${link.id}`))
462
+ return;
463
+ paintEdgeLabel(link, link.type ?? "", theme, ctx, globalScale);
464
+ }, [model.links.length, theme, captioned]);
465
+ /**
466
+ * Freezing is a fact about the ENGINE, not about a React Set.
467
+ *
468
+ * The Freeze button and the right-click action only ever added an id to
469
+ * `pinnedIds`; the simulation never heard about it, so a node the UI marked
470
+ * frozen carried on drifting. The inverse was broken too: `onNodeDragEnd`
471
+ * writes `fx`/`fy` directly, so "Unpin all" cleared the set and left every
472
+ * dragged node nailed in place with no marker to say why.
473
+ *
474
+ * One reconciliation, both directions. Mutating the node objects is the
475
+ * interface force-graph offers — they are the same objects the simulation
476
+ * holds — so the reheat is what makes the change visible.
477
+ */
478
+ useEffect(() => {
479
+ let changed = false;
480
+ for (const node of model.nodes) {
481
+ const shouldPin = pinnedIds.has(node.id);
482
+ const isPinned = node.fx != null || node.fy != null;
483
+ if (shouldPin === isPinned)
484
+ continue;
485
+ if (shouldPin) {
486
+ node.fx = node.x;
487
+ node.fy = node.y;
488
+ }
489
+ else {
490
+ node.fx = undefined;
491
+ node.fy = undefined;
492
+ }
493
+ changed = true;
494
+ }
495
+ if (changed)
496
+ reheat();
497
+ }, [pinnedIds, model.nodes, reheat]);
498
+ return (_jsx(ForceGraph2D, { ref: engineRef, graphData: graphData, width: width, height: height, backgroundColor: "transparent",
499
+ // Cap the zoom. `zoomToFit` has no upper bound, so a three-node result
500
+ // frames itself at a scale where the nodes fill the panel like beach
501
+ // balls. Capping keeps a small graph looking like a small graph.
502
+ maxZoom: MAX_ZOOM, nodeRelSize: 4, nodeVisibility: (node) => !isHidden(node),
503
+ // Hiding a label has to hide its relationships too. `nodeVisibility`
504
+ // alone leaves the links drawn, so filtering a type out of the legend
505
+ // left arrows pointing at nothing — which reads as missing nodes rather
506
+ // than as a filter doing its job.
507
+ linkVisibility: (link) => !isLinkHidden(link),
508
+ // MUST return an element, never a string: force-graph's tooltip assigns
509
+ // a string via innerHTML, which would execute markup stored in the graph.
510
+ nodeLabel: ((node) => nodeTooltip(node, styleMap)), linkLabel: ((link) => edgeTooltip(link)), nodeCanvasObject: nodeCanvasObject, nodePointerAreaPaint: nodePointerAreaPaint, linkCanvasObjectMode: () => "after", linkCanvasObject: linkCanvasObject,
511
+ // border is too faint to follow across a canvas; mutedForeground reads
512
+ // as a line without competing with the nodes.
513
+ linkColor: () => theme.mutedForeground, linkWidth: 1.1, linkDirectionalArrowLength: 5, linkDirectionalArrowRelPos: 1, linkDirectionalArrowColor: () => theme.mutedForeground, d3VelocityDecay: 0.32,
514
+ // Every way a label can move — the layout settling, a pan, a zoom, or the
515
+ // user dragging one node — shows up as a rendered frame, and nothing else
516
+ // reports all four. `onRenderFramePre` DOES fire through this wrapper,
517
+ // unlike `onEngineTick`/`onEngineStop`; verified in a browser.
518
+ onRenderFramePre: handleFramePre, onNodeClick: handleNodeClick, onNodeRightClick: (node) => togglePinned(String(node.id)), onLinkClick: (link) => selectEdge(link), onNodeHover: (node) => setHoveredId(node ? String(node.id) : null), onBackgroundClick: () => state.clearSelection(), onNodeDragEnd: (node) => {
519
+ // Dragging pins: the position the user chose is the point of dragging.
520
+ node.fx = node.x;
521
+ node.fy = node.y;
522
+ if (!pinnedIds.has(String(node.id)))
523
+ togglePinned(String(node.id));
524
+ },
525
+ // Reduced motion means "do not ANIMATE the settle", not "do not settle".
526
+ // `cooldownTime: 0` alone stopped the simulation before it had arranged
527
+ // anything, so these users got the engine's initial placement — a
528
+ // scatter with edges crossing at random, which is not a graph. The ticks
529
+ // run synchronously before the first paint instead: the same layout,
530
+ // arrived at without motion. Bounded, because they block.
531
+ warmupTicks: reducedMotion ? warmupFor(model.nodes.length) : 0, cooldownTicks: reducedMotion ? 0 : undefined, cooldownTime: reducedMotion ? 0 : cooldownFor(model.nodes.length) }));
532
+ }
533
+ export default GraphCanvas;
@@ -0,0 +1,42 @@
1
+ import { type ReactNode } from "react";
2
+ import { type GraphNode } from "./model.js";
3
+ import type { GraphSelection } from "./use-graph-view-state.js";
4
+ /**
5
+ * The drawer's width in pixels, matching `w-80` below.
6
+ *
7
+ * Exported because the canvas has to know how much of itself is covered:
8
+ * a node hidden behind the drawer is a node you selected and then could not
9
+ * see. Kept as a number rather than measured, so the pan can be issued in
10
+ * the same frame the drawer opens.
11
+ */
12
+ export declare const GRAPH_INSPECTOR_WIDTH = 320;
13
+ export interface GraphInspectorProps {
14
+ selection: GraphSelection;
15
+ colorForLabel: (label: string) => string;
16
+ /** The same caption the canvas draws — resolved once by the styling hook. */
17
+ captionOf: (node: GraphNode) => string;
18
+ onClose: () => void;
19
+ /** Rendered as an "Expand" action when the host supplies one. */
20
+ onExpandNode?: (node: GraphNode) => void;
21
+ /** Freeze the node where it sits, so the layout stops moving it around. */
22
+ onToggleFrozen?: (node: GraphNode) => void;
23
+ isFrozen?: (node: GraphNode) => boolean;
24
+ /** Host-supplied actions for the footer — e.g. "pin to chat". */
25
+ actions?: ReactNode;
26
+ className?: string;
27
+ }
28
+ /**
29
+ * Details for the selected node or relationship.
30
+ *
31
+ * A drawer over the canvas, not a rail beside it. A rail is the obvious
32
+ * layout and it is wrong here: it takes its width from the graph permanently,
33
+ * so selecting a node — the one gesture that means "I want to look at this
34
+ * more closely" — is also the gesture that shrinks what you are looking at.
35
+ * Over the canvas, the graph keeps its full width and the drawer is dismissed
36
+ * the moment you are done with it.
37
+ *
38
+ * Also the accessible surface for the graph: the canvas itself is opaque to a
39
+ * screen reader, so everything a sighted user learns by hovering has to be
40
+ * readable here as ordinary DOM.
41
+ */
42
+ export declare function GraphInspector({ selection, colorForLabel, captionOf, onClose, onExpandNode, onToggleFrozen, isFrozen, actions, className, }: GraphInspectorProps): import("react/jsx-runtime").JSX.Element | null;