@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,338 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Suspense, lazy, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react";
3
+ import { cn } from "@iloveagents/foundry-web-primitives";
4
+ import { deepEqual, primaryLabel, } from "./model.js";
5
+ import { GraphLegend } from "./graph-legend.js";
6
+ import { GraphInspector, GRAPH_INSPECTOR_WIDTH } from "./graph-inspector.js";
7
+ import { GraphToolbar } from "./graph-toolbar.js";
8
+ import { GraphEmptyState, GraphLossNotice } from "./graph-notice.js";
9
+ import { GraphTable } from "./graph-table.js";
10
+ import { useElementSize } from "./use-element-size.js";
11
+ import { endpointId, useGraphModel, DEFAULT_LIMITS, } from "./use-graph-model.js";
12
+ import { useGraphStyling } from "./use-graph-styling.js";
13
+ import { useGraphTheme } from "./use-graph-theme.js";
14
+ import { useGraphViewState } from "./use-graph-view-state.js";
15
+ /**
16
+ * The engine is code-split.
17
+ *
18
+ * `react-force-graph-2d` pulls in force-graph and the d3 force/zoom/drag
19
+ * stack. A chat app that shows one graph card in ten conversations should not
20
+ * pay for that on first paint, and a host that only wants the payload types
21
+ * should not pay for it at all.
22
+ */
23
+ const GraphCanvas = lazy(() => import("./graph-canvas.js"));
24
+ /**
25
+ * Is there a canvas that can actually be DRAWN on?
26
+ *
27
+ * `typeof HTMLCanvasElement === "undefined"` was the wrong question. A stock
28
+ * jsdom defines the class and then throws "Not implemented" from
29
+ * `getContext`, so a consumer's test rendering `GraphView` took the engine
30
+ * path and hit unimplemented APIs instead of the readable table this fallback
31
+ * exists to give them. Asking for the context answers what the caller meant.
32
+ *
33
+ * Probed once: the answer cannot change within a page, and this is read on
34
+ * every render.
35
+ */
36
+ let usableCanvas;
37
+ function hasUsableCanvas() {
38
+ if (usableCanvas !== undefined)
39
+ return usableCanvas;
40
+ if (typeof document === "undefined" || typeof HTMLCanvasElement === "undefined") {
41
+ usableCanvas = false;
42
+ return usableCanvas;
43
+ }
44
+ try {
45
+ usableCanvas = document.createElement("canvas").getContext("2d") !== null;
46
+ }
47
+ catch {
48
+ usableCanvas = false;
49
+ }
50
+ return usableCanvas;
51
+ }
52
+ /**
53
+ * Run after the frame that opens the details drawer.
54
+ *
55
+ * Callers frame a node right after selecting it (`graph_select_node` does
56
+ * exactly that), and it is the selection that opens the drawer — so on this
57
+ * tick the width deciding what is visible does not exist yet. Framing now
58
+ * would put the node underneath the panel about to describe it.
59
+ */
60
+ function afterDrawer(run) {
61
+ if (typeof requestAnimationFrame === "function")
62
+ requestAnimationFrame(run);
63
+ else
64
+ run();
65
+ }
66
+ function usePrefersReducedMotion() {
67
+ const [reduced, setReduced] = useState(false);
68
+ useEffect(() => {
69
+ if (typeof window === "undefined" || !window.matchMedia)
70
+ return;
71
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
72
+ const sync = () => setReduced(query.matches);
73
+ sync();
74
+ query.addEventListener("change", sync);
75
+ return () => query.removeEventListener("change", sync);
76
+ }, []);
77
+ return reduced;
78
+ }
79
+ /**
80
+ * An interactive, force-directed graph.
81
+ *
82
+ * Source-agnostic by construction: it takes a {@link GraphPayload} and knows
83
+ * nothing about where the nodes came from. Expansion is a callback, so the view
84
+ * never issues a query of its own.
85
+ */
86
+ export function GraphView({ data, variant = "full", height = 420, className, styling, limits = DEFAULT_LIMITS, onExpandNode, onSelectionChange, onQueryChange, onFilterChange, inspectorActions, forceTable = false, handleRef, }) {
87
+ const [container, setContainer] = useState(null);
88
+ // The canvas is opaque to a screen reader; the table is the same graph as
89
+ // real DOM. Off by default, one button away.
90
+ const [tableRequested, setTableRequested] = useState(false);
91
+ const [canvasHost, setCanvasHost] = useState(null);
92
+ const canvasRef = useRef(null);
93
+ const theme = useGraphTheme(container);
94
+ const reducedMotion = usePrefersReducedMotion();
95
+ const model = useGraphModel(data, limits);
96
+ const styleMap = useGraphStyling(data, theme, styling);
97
+ const captionOf = useCallback((node) => styleMap.styleForNode(node).caption, [styleMap]);
98
+ const state = useGraphViewState(data, captionOf);
99
+ const size = useElementSize(canvasHost);
100
+ // A `Map`, not `{}`. Labels come from the payload and the payload is
101
+ // source-neutral — nothing stops a graph from having a label called
102
+ // `constructor` or `toString`, and both the write (`out[label] ?? 0`) and
103
+ // the legend\'s read would then find an inherited function where a count
104
+ // belongs, so the chip shows a stringified function or `NaN`.
105
+ const counts = useMemo(() => {
106
+ const out = new Map();
107
+ for (const node of model.nodes) {
108
+ const label = primaryLabel(node);
109
+ out.set(label, (out.get(label) ?? 0) + 1);
110
+ }
111
+ return out;
112
+ }, [model.nodes]);
113
+ const { selection, selectNode } = state;
114
+ /**
115
+ * The graph as DRAWN: the capped model, minus anything the legend hides.
116
+ *
117
+ * One derivation, so the canvas, the readable table and the agent cannot
118
+ * disagree about what is on screen. Hiding a label takes its relationships
119
+ * with it — the canvas already does that through `linkVisibility`, and the
120
+ * table showed them until this existed.
121
+ */
122
+ const visibleModel = useMemo(() => {
123
+ if (state.hiddenLabels.size === 0)
124
+ return model;
125
+ const nodes = model.nodes.filter((node) => !state.hiddenLabels.has(primaryLabel(node)));
126
+ const ids = new Set(nodes.map((node) => node.id));
127
+ const links = model.links.filter((link) => {
128
+ const source = endpointId(link.source);
129
+ const target = endpointId(link.target);
130
+ return source !== undefined && target !== undefined && ids.has(source) && ids.has(target);
131
+ });
132
+ return { ...model, nodes, links };
133
+ }, [model, state.hiddenLabels]);
134
+ /**
135
+ * Report changes, and ONLY changes.
136
+ *
137
+ * A host almost always passes these as inline arrows, so their identity is
138
+ * new on every render and an effect that lists them as dependencies re-runs
139
+ * every render — firing "the selection changed" when it did not. That is
140
+ * fine until a listener writes to a store the host also reads, at which
141
+ * point the notification causes the render that causes the notification.
142
+ * (It did: routing the selection into the chat context deadlocked the panel
143
+ * with "Maximum update depth exceeded" the moment a node was selected.)
144
+ *
145
+ * Debouncing or stabilising callbacks host-side would paper over it. The
146
+ * component's own contract is the right place to hold the line: a change
147
+ * callback fires when the value changes.
148
+ */
149
+ /**
150
+ * Identity is not enough to say the selection is unchanged.
151
+ *
152
+ * `reconcileGraphModel` refreshes a node's `labels`/`caption`/`properties`
153
+ * IN PLACE, precisely so the simulation keeps its position — so after a
154
+ * merge that rewrote the selected node, the object is the same object with
155
+ * different data. The drawer re-rendered and showed the new values; the
156
+ * listener never fired, and `syncGraphSelectionContext` snapshots
157
+ * `properties` into the chat context, so the agent went on answering about
158
+ * "this node" from the values before the write.
159
+ *
160
+ * Comparing the VALUE keeps the loop-prevention this guard exists for — it
161
+ * changes only when the data actually changes — while noticing that case.
162
+ * Held as a value rather than a serialized signature so nothing stringifies
163
+ * a node\'s whole property bag on every render, and so a `bigint` property
164
+ * cannot collide with the string of its own digits.
165
+ */
166
+ const selectionValue = selection.node
167
+ ? [
168
+ selection.node.id,
169
+ selection.node.labels ?? [],
170
+ selection.node.caption ?? null,
171
+ selection.node.properties ?? {},
172
+ ]
173
+ : null;
174
+ const notifiedSelection = useRef(selectionValue);
175
+ useEffect(() => {
176
+ if (deepEqual(notifiedSelection.current, selectionValue))
177
+ return;
178
+ notifiedSelection.current = selectionValue;
179
+ onSelectionChange?.(selection.node);
180
+ // Deliberately runs on EVERY render, gated by the comparison above rather
181
+ // than by a dependency list. The case this exists to catch is
182
+ // `reconcileGraphModel` refreshing the selected node IN PLACE — the object
183
+ // is identical, so no dependency can see it; only re-reading the value can.
184
+ // eslint-disable-next-line react-hooks/exhaustive-deps
185
+ });
186
+ /**
187
+ * How much of the canvas the details drawer covers.
188
+ *
189
+ * Framing reads this so a node is centred in what the user can SEE. Zero
190
+ * whenever the drawer is closed, so an inline card is unaffected.
191
+ */
192
+ const showsInspector = variant === "full" && Boolean(selection.node || selection.edge);
193
+ /**
194
+ * Matches AMONG THE DRAWN NODES.
195
+ *
196
+ * `matchedIds` is computed from the payload, which is what the canvas needs
197
+ * — it dims by id and only ever paints visible nodes anyway. The toolbar is
198
+ * different: it states a number to the user. Counting the payload let it
199
+ * report "1 match" with every node on screen dimmed and nothing found,
200
+ * whenever the only hit was one the render cap dropped or the legend hid.
201
+ */
202
+ const visibleMatchCount = useMemo(() => {
203
+ if (state.matchedIds.size === 0)
204
+ return 0;
205
+ return visibleModel.nodes.reduce((count, node) => (state.matchedIds.has(node.id) ? count + 1 : count), 0);
206
+ }, [state.matchedIds, visibleModel.nodes]);
207
+ const inspectorInset = showsInspector ? Math.min(GRAPH_INSPECTOR_WIDTH, size.width * 0.85) : 0;
208
+ const notifiedQuery = useRef(state.query);
209
+ useEffect(() => {
210
+ if (notifiedQuery.current === state.query)
211
+ return;
212
+ notifiedQuery.current = state.query;
213
+ onQueryChange?.(state.query);
214
+ }, [state.query, onQueryChange]);
215
+ // Same shape, same reason: hosts pass an inline arrow, so the change has to
216
+ // be detected from the VALUE. Sorted so a Set rebuilt in a different order
217
+ // is not reported as a change.
218
+ const hiddenLabels = useMemo(() => [...state.hiddenLabels].sort().join("\u0000"), [state.hiddenLabels]);
219
+ const notifiedHidden = useRef(hiddenLabels);
220
+ useEffect(() => {
221
+ if (notifiedHidden.current === hiddenLabels)
222
+ return;
223
+ notifiedHidden.current = hiddenLabels;
224
+ onFilterChange?.(hiddenLabels.length === 0 ? [] : hiddenLabels.split("\u0000"));
225
+ }, [hiddenLabels, onFilterChange]);
226
+ /**
227
+ * Everything the handle answers from, refreshed every render.
228
+ *
229
+ * The handle itself must be ONE object for the life of the view.
230
+ * `GraphPanel` stores `handleRef.current` in the workspace when it connects
231
+ * and reconnects only on a payload, title or visibility change — so a handle
232
+ * rebuilt whenever the view state changed left the workspace, and every
233
+ * client tool through it, holding a closure over the view as it was when the
234
+ * panel opened. `graph_get_view` would answer without the search the user
235
+ * had since typed; `graph_select_node` would accept a node hidden after
236
+ * connection. Nothing surfaced it, because a merge changes the payload and
237
+ * so happens to reconnect.
238
+ *
239
+ * `useGraphViewState` returns a fresh object literal every render, which is
240
+ * what made `state` in a dependency list mean "every render".
241
+ */
242
+ const live = useRef({ model, visibleModel, state, selectNode });
243
+ // Assigned during render on purpose: the handle's methods are only ever
244
+ // called imperatively — by the host, or by a client tool the agent ran —
245
+ // never while rendering, so there is nothing here to tear. Deferring it to
246
+ // an effect would leave the handle a render behind instead.
247
+ live.current = { model, visibleModel, state, selectNode };
248
+ useImperativeHandle(handleRef, () => ({
249
+ zoomToFit: (durationMs) => canvasRef.current?.zoomToFit(durationMs),
250
+ centerOn: (nodeId, durationMs) => afterDrawer(() => canvasRef.current?.centerOn(nodeId, durationMs)),
251
+ reveal: (nodeId, durationMs) => afterDrawer(() => canvasRef.current?.reveal(nodeId, durationMs)),
252
+ search: (term) => live.current.state.setQuery(term),
253
+ select: (nodeId) => {
254
+ const { visibleModel: drawn, selectNode: choose } = live.current;
255
+ if (nodeId === null) {
256
+ choose(null);
257
+ return true;
258
+ }
259
+ // The DRAWN nodes: a label hidden in the legend is as absent from the
260
+ // canvas as one the render cap dropped, and reporting success for it
261
+ // opens a drawer describing something invisible.
262
+ const node = drawn.nodes.find((n) => n.id === nodeId) ?? null;
263
+ if (node)
264
+ choose(node);
265
+ return node !== null;
266
+ },
267
+ unpinAll: () => live.current.state.unpinAll(),
268
+ getViewState: () => {
269
+ const { state, visibleModel, model } = live.current;
270
+ return {
271
+ selection: state.selection.node,
272
+ // A legend filter is a view toggle, not a deletion, so hiding a
273
+ // selected node\'s label does NOT clear the selection — the drawer
274
+ // stays open and its properties are still on screen, which is what
275
+ // the user is reading. But `nodes` below excludes it, so reporting
276
+ // the selection bare made this result contradict itself. Saying
277
+ // whether it is drawn is what lets the agent be precise: "selected,
278
+ // though its label is hidden" rather than a node that is somehow both
279
+ // selected and absent.
280
+ selectionDrawn: state.selection.node
281
+ ? visibleModel.nodes.some((node) => node.id === state.selection.node?.id)
282
+ : false,
283
+ query: state.query,
284
+ hiddenLabels: [...state.hiddenLabels],
285
+ nodes: visibleModel.nodes,
286
+ // Normalised, because force-graph REPLACES a link's `source`/`target`
287
+ // with the node objects once it has run — so handing these out raw
288
+ // put whole simulation objects, mutable layout fields and all, where
289
+ // a caller expects an id.
290
+ edges: visibleModel.links.map((link) => ({
291
+ ...link,
292
+ source: endpointId(link.source) ?? "",
293
+ target: endpointId(link.target) ?? "",
294
+ })),
295
+ selectedEdge: state.selection.edge
296
+ ? {
297
+ ...state.selection.edge,
298
+ source: endpointId(state.selection.edge.source) ?? "",
299
+ target: endpointId(state.selection.edge.target) ?? "",
300
+ }
301
+ : null,
302
+ lossy: model.lossy,
303
+ };
304
+ },
305
+ }),
306
+ // Empty ON PURPOSE — see `live` above. One handle for the life of the
307
+ // view; everything it answers from is read at call time.
308
+ []);
309
+ // `forceTable` is the print path; `tableRequested` is the user asking, from
310
+ // the toolbar; `noCanvas` is the environment having nothing to draw on.
311
+ const noCanvas = !hasUsableCanvas();
312
+ const useTable = forceTable || noCanvas || tableRequested;
313
+ const isEmpty = model.nodes.length === 0;
314
+ return (_jsxs("div", { ref: setContainer, className: cn("flex min-h-0 flex-col gap-2", className), style: { height }, children: [_jsx(GraphToolbar, { query: state.query, onQueryChange: state.setQuery, matchCount: visibleMatchCount,
315
+ // The DRAWN graph, like every other count on this screen. Reading the
316
+ // capped-but-unfiltered model meant switching a label off left the
317
+ // toolbar saying "2 nodes · 1 rels" beside a canvas showing one node
318
+ // and no relationships.
319
+ nodeCount: visibleModel.nodes.length, edgeCount: visibleModel.links.length, onZoomToFit: () => canvasRef.current?.zoomToFit(), onReheat: () => canvasRef.current?.reheat(), pinnedCount: state.pinnedIds.size, onUnpinAll: state.unpinAll, showsTable: useTable,
320
+ // No control where there is no choice: a host that pinned the table
321
+ // with `forceTable`, or an environment with no canvas to go back to.
322
+ onToggleTable: forceTable || noCanvas ? undefined : () => setTableRequested((shown) => !shown), className: "shrink-0" }), _jsx(GraphLegend, { styleMap: styleMap, counts: counts, hiddenLabels: state.hiddenLabels, onToggleLabel: state.toggleLabel, onShowAll: state.showAllLabels, className: "shrink-0" }), (model.lossy || data.truncated) && (_jsx(GraphLossNotice, { loss: model.loss, truncatedByProducer: data.truncated, className: "shrink-0" })), _jsx("div", { className: "flex min-h-0 flex-1", children: _jsxs("div", { ref: setCanvasHost, className: "border-border bg-background relative min-h-0 flex-1 overflow-hidden rounded-lg border", children: [isEmpty ? (_jsx(GraphEmptyState, {})) : useTable ? (_jsx(GraphTable, { model: visibleModel, styleMap: styleMap, onSelectNode: selectNode, onSelectEdge: state.selectEdge, className: "h-full" })) : (
323
+ // `role="img"` wraps the CANVAS ONLY. ARIA exposes the descendants
324
+ // of an image as presentational, so with this on the box that also
325
+ // holds `GraphInspector`, selecting a node deleted the whole
326
+ // drawer — its dialog, its property rows, its Pin/Freeze/Expand
327
+ // controls — from the accessibility tree, at exactly the moment
328
+ // the user asked to inspect something.
329
+ //
330
+ // The canvas itself genuinely is one opaque element to a screen
331
+ // reader: the nodes and relationships are pixels, and no labelling
332
+ // changes that. So the region says what it holds and where the
333
+ // readable form is; the toolbar's button is what gets you there.
334
+ _jsx("div", { role: "img", "aria-label":
335
+ // Same numbers the sighted user is given, for the same reason.
336
+ `Graph: ${visibleModel.nodes.length} nodes, ${visibleModel.links.length} relationships. ` +
337
+ `Use "Show as a table" for a readable version.`, className: "absolute inset-0", children: _jsx(Suspense, { fallback: _jsx(GraphEmptyState, { message: "Loading graph\u2026" }), children: size.width > 0 && size.height > 0 && (_jsx(GraphCanvas, { model: model, styleMap: styleMap, theme: theme, state: state, width: size.width, height: size.height, onExpandNode: onExpandNode, insetRight: inspectorInset, handleRef: canvasRef, reducedMotion: reducedMotion })) }) })), showsInspector && (_jsx(GraphInspector, { selection: selection, colorForLabel: styleMap.colorForLabel, captionOf: captionOf, onClose: state.clearSelection, onExpandNode: onExpandNode, onToggleFrozen: (node) => state.togglePinned(node.id), isFrozen: (node) => state.pinnedIds.has(node.id), actions: selection.node && inspectorActions ? inspectorActions(selection.node) : null }))] }) })] }));
338
+ }
@@ -0,0 +1,55 @@
1
+ import { type GraphNode, type GraphPayload } from "./model.js";
2
+ import type { GraphViewHandle } from "./graph-view.js";
3
+ /**
4
+ * The live graph the user is looking at.
5
+ *
6
+ * This is what turns the panel from a picture into a workspace the agent and
7
+ * the user share. The agent can ask what is on screen, drive the selection,
8
+ * and replace the data in place; the user's own clicks land in the same state,
9
+ * so the next question can be about "this node" without either side having to
10
+ * restate it.
11
+ *
12
+ * Vanilla zustand (not the React entry) so the store itself has no renderer
13
+ * dependency — the client tools that read it run outside React.
14
+ */
15
+ export interface GraphWorkspaceState {
16
+ /** Imperative handle of the mounted panel view, or null when none is open. */
17
+ handle: GraphViewHandle | null;
18
+ title: string | null;
19
+ payload: GraphPayload | null;
20
+ /** What the user (or the agent) currently has selected. */
21
+ selection: GraphNode | null;
22
+ /** The active search term, if any. */
23
+ query: string;
24
+ /**
25
+ * Labels the user has switched OFF in the legend.
26
+ *
27
+ * Published because what the agent sees has to be what the user sees: a
28
+ * hidden label's nodes and relationships are not drawn, and an account of
29
+ * "the graph" that counts them describes a screen nobody is looking at.
30
+ */
31
+ hiddenLabels: readonly string[];
32
+ connect: (handle: GraphViewHandle, title: string, payload: GraphPayload) => void;
33
+ disconnect: (handle: GraphViewHandle) => void;
34
+ setSelection: (node: GraphNode | null) => void;
35
+ setQuery: (query: string) => void;
36
+ setHiddenLabels: (labels: readonly string[]) => void;
37
+ }
38
+ /** Test seam: put the workspace back to "nothing is open", every field of it. */
39
+ export declare const _resetGraphWorkspace: () => void;
40
+ export declare const graphWorkspaceStore: import("zustand").StoreApi<GraphWorkspaceState>;
41
+ /** Is a graph panel currently open? */
42
+ export declare const isGraphOpen: () => boolean;
43
+ /**
44
+ * Find a node by id, or failing that by caption — case-insensitively, exact
45
+ * before partial.
46
+ *
47
+ * The agent refers to nodes the way the user does ("select Metropolis"), not
48
+ * by the database's element id, which it has usually never seen.
49
+ */
50
+ /** Several nodes answer to the same text; the caller must disambiguate. */
51
+ export interface AmbiguousMatch {
52
+ ambiguous: GraphNode[];
53
+ }
54
+ export declare const isAmbiguous: (value: unknown) => value is AmbiguousMatch;
55
+ export declare function findWorkspaceNode(needle: string): GraphNode | AmbiguousMatch | null;
@@ -0,0 +1,100 @@
1
+ import { createStore } from "zustand/vanilla";
2
+ import { primaryLabel, resolveCaption } from "./model.js";
3
+ /**
4
+ * Every field of the workspace at rest, in one place.
5
+ *
6
+ * `disconnect` and the tests both need "nothing is open", and writing that out
7
+ * twice is how a field added later gets left out of one of them — zustand's
8
+ * `setState` MERGES, so a partial reset silently keeps the old value and the
9
+ * tests become order-dependent. `hiddenLabels` had already drifted that way.
10
+ */
11
+ const CLOSED = {
12
+ handle: null,
13
+ title: null,
14
+ payload: null,
15
+ selection: null,
16
+ query: "",
17
+ hiddenLabels: [],
18
+ };
19
+ /** Test seam: put the workspace back to "nothing is open", every field of it. */
20
+ export const _resetGraphWorkspace = () => graphWorkspaceStore.setState({ ...CLOSED });
21
+ export const graphWorkspaceStore = createStore((set, get) => ({
22
+ ...CLOSED,
23
+ connect: (handle, title, payload) => set((state) => {
24
+ // A selection has to belong to the graph it is reported against.
25
+ //
26
+ // React mounts a replacement panel BEFORE unmounting the old one, and
27
+ // `disconnect` deliberately no-ops once a newer panel has claimed the
28
+ // workspace — so without this the previous graph's selection survives
29
+ // into the new one, and `graph_get_view` tells the agent a node is
30
+ // selected that is not on screen.
31
+ //
32
+ // Keeping it when it survives matters just as much: a merge changes
33
+ // `payload` on the SAME panel, and clearing unconditionally would drop
34
+ // the user's selection every time the agent expanded a node.
35
+ // Both conditions, and the first is the one that was missing: ids come
36
+ // from one database, so two panels showing overlapping results share
37
+ // them, and "the id is in the new payload" was letting panel A's
38
+ // selection arrive in panel B as something the user never clicked.
39
+ const samePanel = state.handle === handle;
40
+ const stillThere = state.selection !== null && payload.nodes.some((node) => node.id === state.selection?.id);
41
+ return {
42
+ handle,
43
+ title,
44
+ payload,
45
+ selection: samePanel && stillThere ? state.selection : null,
46
+ query: samePanel ? state.query : "",
47
+ hiddenLabels: samePanel ? state.hiddenLabels : [],
48
+ };
49
+ }),
50
+ disconnect: (handle) => {
51
+ // Guard against a stale unmount clearing a workspace that a NEWER panel
52
+ // has already claimed — React unmounts the old tree after mounting the
53
+ // new one, so the order is not the intuitive one.
54
+ if (get().handle !== handle)
55
+ return;
56
+ set({ ...CLOSED });
57
+ },
58
+ setSelection: (selection) => set({ selection }),
59
+ setQuery: (query) => set({ query }),
60
+ setHiddenLabels: (hiddenLabels) => set({ hiddenLabels }),
61
+ }));
62
+ /** Is a graph panel currently open? */
63
+ export const isGraphOpen = () => graphWorkspaceStore.getState().payload !== null;
64
+ export const isAmbiguous = (value) => typeof value === "object" && value !== null && "ambiguous" in value;
65
+ export function findWorkspaceNode(needle) {
66
+ const { payload } = graphWorkspaceStore.getState();
67
+ if (!payload)
68
+ return null;
69
+ const byId = payload.nodes.find((node) => node.id === needle);
70
+ if (byId)
71
+ return byId;
72
+ const lower = needle.trim().toLowerCase();
73
+ // Every caption `.includes("")`, so a blank selector would match the first
74
+ // node and the tool would report a confident, arbitrary selection. A missing
75
+ // argument has to fail, not pick.
76
+ if (lower.length === 0)
77
+ return null;
78
+ // The SAME resolver the canvas paints with. A local approximation of it
79
+ // (caption / name / title) left every other way a node gets its caption —
80
+ // a producer's `legend.captionKey`, `displayName`, `label`, the first short
81
+ // string property — visible on screen and unselectable by the text on it.
82
+ const captionOf = (node) => resolveCaption(node, payload.legend?.[primaryLabel(node)]?.captionKey).toLowerCase();
83
+ // Duplicate captions are ordinary in a real graph — two people with the
84
+ // same name, two films with the same title — and `find` answered those with
85
+ // whichever the producer happened to return first. The tool then reported a
86
+ // confident selection and the agent described the wrong node, with nothing
87
+ // on screen to show it had guessed. An exact match on ONE node is an answer;
88
+ // more than one is a question for the caller, who has the ids.
89
+ const exact = payload.nodes.filter((node) => captionOf(node) === lower);
90
+ if (exact.length === 1)
91
+ return exact[0] ?? null;
92
+ if (exact.length > 1)
93
+ return { ambiguous: exact };
94
+ const partial = payload.nodes.filter((node) => captionOf(node).includes(lower));
95
+ if (partial.length === 1)
96
+ return partial[0] ?? null;
97
+ if (partial.length > 1)
98
+ return { ambiguous: partial };
99
+ return null;
100
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@iloveagents/foundry-web-graph` — the source-agnostic graph visualizer.
3
+ *
4
+ * Nothing exported from here knows about Neo4j, assistant-ui, or the chat
5
+ * shell. The Neo4j mapping is `@iloveagents/foundry-web-graph/adapters/neo4j`;
6
+ * the chat card and panel renderer are
7
+ * `@iloveagents/foundry-web-graph/assistant-ui`.
8
+ */
9
+ export { GRAPH_PAYLOAD_KIND, GRAPH_PAYLOAD_VERSION, DEFAULT_CAPTION_KEYS, coerceGraphPayload, emptyGraphPayload, isGraphPayload, mergeGraphPayloads, primaryLabel, pruneDanglingEdges, resolveCaption, type GraphEdge, type GraphNode, type GraphPayload, } from "./model.js";
10
+ export { GraphView, type GraphViewHandle, type GraphViewProps } from "./graph-view.js";
11
+ export { GraphLegend, type GraphLegendProps } from "./graph-legend.js";
12
+ export { GraphInspector, type GraphInspectorProps } from "./graph-inspector.js";
13
+ export { GraphToolbar, type GraphToolbarProps } from "./graph-toolbar.js";
14
+ export { GraphTable, type GraphTableProps } from "./graph-table.js";
15
+ export { GraphEmptyState, GraphLossNotice } from "./graph-notice.js";
16
+ export { DEFAULT_LIMITS, reconcileGraphModel, selectConnectedCore, useGraphModel, type EngineLink, type EngineNode, type GraphModel, type GraphModelLimits, type GraphModelLoss, } from "./use-graph-model.js";
17
+ export { useGraphStyling, type GraphStyleMap, type GraphStyling, type NodeStyle, } from "./use-graph-styling.js";
18
+ export { useGraphViewState, type GraphSelection, type GraphViewState, } from "./use-graph-view-state.js";
19
+ export { useGraphTheme } from "./use-graph-theme.js";
20
+ export { FALLBACK_THEME, resolveGraphTheme, type GraphTheme } from "./theme.js";
21
+ export { findWorkspaceNode, graphWorkspaceStore, isGraphOpen, type GraphWorkspaceState, } from "./graph-workspace.js";
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@iloveagents/foundry-web-graph` — the source-agnostic graph visualizer.
3
+ *
4
+ * Nothing exported from here knows about Neo4j, assistant-ui, or the chat
5
+ * shell. The Neo4j mapping is `@iloveagents/foundry-web-graph/adapters/neo4j`;
6
+ * the chat card and panel renderer are
7
+ * `@iloveagents/foundry-web-graph/assistant-ui`.
8
+ */
9
+ export { GRAPH_PAYLOAD_KIND, GRAPH_PAYLOAD_VERSION, DEFAULT_CAPTION_KEYS, coerceGraphPayload, emptyGraphPayload, isGraphPayload, mergeGraphPayloads, primaryLabel, pruneDanglingEdges, resolveCaption, } from "./model.js";
10
+ export { GraphView } from "./graph-view.js";
11
+ export { GraphLegend } from "./graph-legend.js";
12
+ export { GraphInspector } from "./graph-inspector.js";
13
+ export { GraphToolbar } from "./graph-toolbar.js";
14
+ export { GraphTable } from "./graph-table.js";
15
+ export { GraphEmptyState, GraphLossNotice } from "./graph-notice.js";
16
+ export { DEFAULT_LIMITS, reconcileGraphModel, selectConnectedCore, useGraphModel, } from "./use-graph-model.js";
17
+ export { useGraphStyling, } from "./use-graph-styling.js";
18
+ export { useGraphViewState, } from "./use-graph-view-state.js";
19
+ export { useGraphTheme } from "./use-graph-theme.js";
20
+ export { FALLBACK_THEME, resolveGraphTheme } from "./theme.js";
21
+ export { findWorkspaceNode, graphWorkspaceStore, isGraphOpen, } from "./graph-workspace.js";