@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.
- package/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.js +1 -0
- package/dist/adapters/neo4j.d.ts +52 -0
- package/dist/adapters/neo4j.js +149 -0
- package/dist/assistant-ui/graph-client-tools.d.ts +11 -0
- package/dist/assistant-ui/graph-client-tools.js +261 -0
- package/dist/assistant-ui/graph-context.d.ts +38 -0
- package/dist/assistant-ui/graph-context.js +113 -0
- package/dist/assistant-ui/graph-panel-content.d.ts +45 -0
- package/dist/assistant-ui/graph-panel-content.js +28 -0
- package/dist/assistant-ui/graph-panel.d.ts +11 -0
- package/dist/assistant-ui/graph-panel.js +121 -0
- package/dist/assistant-ui/graph-provenance.d.ts +17 -0
- package/dist/assistant-ui/graph-provenance.js +31 -0
- package/dist/assistant-ui/graph-result-seeder.d.ts +73 -0
- package/dist/assistant-ui/graph-result-seeder.js +157 -0
- package/dist/assistant-ui/graph-tool-registry.d.ts +25 -0
- package/dist/assistant-ui/graph-tool-registry.js +15 -0
- package/dist/assistant-ui/graph-tool-ui.d.ts +54 -0
- package/dist/assistant-ui/graph-tool-ui.js +77 -0
- package/dist/assistant-ui/index.d.ts +17 -0
- package/dist/assistant-ui/index.js +17 -0
- package/dist/assistant-ui/merge-into-panel.d.ts +21 -0
- package/dist/assistant-ui/merge-into-panel.js +72 -0
- package/dist/assistant-ui/register-graph-panel.d.ts +8 -0
- package/dist/assistant-ui/register-graph-panel.js +18 -0
- package/dist/caption-placement.d.ts +119 -0
- package/dist/caption-placement.js +146 -0
- package/dist/graph-canvas-paint.d.ts +90 -0
- package/dist/graph-canvas-paint.js +186 -0
- package/dist/graph-canvas.d.ts +49 -0
- package/dist/graph-canvas.js +533 -0
- package/dist/graph-inspector.d.ts +42 -0
- package/dist/graph-inspector.js +106 -0
- package/dist/graph-legend.d.ts +19 -0
- package/dist/graph-legend.js +20 -0
- package/dist/graph-notice.d.ts +19 -0
- package/dist/graph-notice.js +30 -0
- package/dist/graph-table.d.ts +28 -0
- package/dist/graph-table.js +57 -0
- package/dist/graph-toolbar.d.ts +22 -0
- package/dist/graph-toolbar.js +8 -0
- package/dist/graph-tooltip.d.ts +4 -0
- package/dist/graph-tooltip.js +55 -0
- package/dist/graph-view.d.ts +94 -0
- package/dist/graph-view.js +338 -0
- package/dist/graph-workspace.d.ts +55 -0
- package/dist/graph-workspace.js +100 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +21 -0
- package/dist/model.d.ts +206 -0
- package/dist/model.js +369 -0
- package/dist/styles.css +97 -0
- package/dist/theme.d.ts +36 -0
- package/dist/theme.js +83 -0
- package/dist/use-element-size.d.ts +13 -0
- package/dist/use-element-size.js +32 -0
- package/dist/use-graph-model.d.ts +101 -0
- package/dist/use-graph-model.js +164 -0
- package/dist/use-graph-styling.d.ts +55 -0
- package/dist/use-graph-styling.js +156 -0
- package/dist/use-graph-theme.d.ts +11 -0
- package/dist/use-graph-theme.js +54 -0
- package/dist/use-graph-view-state.d.ts +42 -0
- package/dist/use-graph-view-state.js +145 -0
- 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;
|