@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,206 @@
1
+ /**
2
+ * The graph payload contract.
3
+ *
4
+ * Deliberately source-agnostic: nothing here knows about Neo4j, Cypher, or any
5
+ * other store. A backend tool, a REST response, or a hand-built fixture all
6
+ * produce the same shape, and {@link GraphView} renders it. Store-specific
7
+ * mapping lives in `adapters/` (see `adapters/neo4j.ts`) and is never imported
8
+ * from this module — a unit test enforces that.
9
+ */
10
+ /** Discriminator carried by every payload, so a tool card can sniff an
11
+ * arbitrary JSON result and decide whether it is renderable as a graph. */
12
+ export declare const GRAPH_PAYLOAD_KIND: "foundry.graph";
13
+ /** Payload format version. Bump only for a breaking shape change. */
14
+ export declare const GRAPH_PAYLOAD_VERSION: 1;
15
+ export interface GraphNode {
16
+ /** Stable identity. Edges reference nodes by this value. */
17
+ id: string;
18
+ /**
19
+ * Type tags. Neo4j labels map straight onto this, but so does a single
20
+ * `["Person"]` from any other source. The FIRST label drives colour and
21
+ * legend grouping.
22
+ */
23
+ labels?: string[];
24
+ /** Text drawn on the node. When absent, {@link resolveCaption} picks one. */
25
+ caption?: string;
26
+ properties?: Record<string, unknown>;
27
+ }
28
+ export interface GraphEdge {
29
+ id: string;
30
+ /** {@link GraphNode.id} of the edge's origin. */
31
+ source: string;
32
+ /** {@link GraphNode.id} of the edge's destination. */
33
+ target: string;
34
+ /** Relationship type — drawn along the edge and used for legend grouping. */
35
+ type?: string;
36
+ properties?: Record<string, unknown>;
37
+ }
38
+ export interface GraphPayload {
39
+ kind: typeof GRAPH_PAYLOAD_KIND;
40
+ version: typeof GRAPH_PAYLOAD_VERSION;
41
+ nodes: GraphNode[];
42
+ edges: GraphEdge[];
43
+ /** The producer hit a cap and dropped elements. Surfaced in the UI. */
44
+ truncated?: boolean;
45
+ /** Totals BEFORE truncation, when the producer knows them. */
46
+ stats?: {
47
+ nodeCount?: number;
48
+ edgeCount?: number;
49
+ };
50
+ /** Per-label styling hints from the producer. Overridden by host styling. */
51
+ legend?: Record<string, {
52
+ color?: string;
53
+ captionKey?: string;
54
+ }>;
55
+ /**
56
+ * Producer-supplied context about how this graph was obtained.
57
+ *
58
+ * Deliberately open: every source has something different worth carrying, and
59
+ * a closed type would force each of them to fork the contract. Conventional
60
+ * keys, which the starter presets emit and the UI reads when present:
61
+ *
62
+ * - `query` — the statement that produced this graph, for a panel title
63
+ * - `elapsedMs`, `database` — provenance
64
+ * - `scores` — `{ id, score }[]` from a similarity search
65
+ * - `rows` — scalar results, when the query returned no graph at all
66
+ *
67
+ * Never put user-supplied query PARAMETERS here: this travels back into the
68
+ * conversation and into any transcript store.
69
+ */
70
+ meta?: Record<string, unknown>;
71
+ /**
72
+ * Never present on a successful payload — declared so TypeScript rejects it
73
+ * at the construction site.
74
+ *
75
+ * The AG-UI runner parses every tool result and marks the whole tool call
76
+ * failed if the parsed object has a top-level `error` key
77
+ * (`onToolCallResultEvent` in `@iloveagents/foundry-agent`). A payload that
78
+ * reported, say, a partial failure under `error` would render as a red failed
79
+ * tool call no matter how much good data it carried. Producers signal trouble
80
+ * with `truncated` and `meta` instead; a genuine failure returns a plain
81
+ * `{ error }` object and no payload at all.
82
+ */
83
+ error?: never;
84
+ }
85
+ /**
86
+ * Structural check. Strict on the discriminator — an arbitrary object that
87
+ * happens to carry `nodes` and `edges` is NOT a graph payload, because a tool
88
+ * card uses this to decide whether to take over the render.
89
+ */
90
+ export declare function isGraphPayload(value: unknown): value is GraphPayload;
91
+ /**
92
+ * Best-effort extraction of a payload from a tool result.
93
+ *
94
+ * Tool results reach the UI in several shapes depending on how the agent
95
+ * framework serialized them: the payload itself, a JSON string of it, a
96
+ * double-encoded JSON string (a backend that returned a string, which the
97
+ * AG-UI runner then parsed once), or an envelope with the payload one level
98
+ * down (`{ graph: … }`, `{ result: … }`). All of them are common enough that
99
+ * making each caller handle them would just spread the same bug around.
100
+ *
101
+ * Returns `null` rather than throwing — a non-graph result is an ordinary
102
+ * outcome, not an error.
103
+ */
104
+ export declare function coerceGraphPayload(value: unknown): GraphPayload | null;
105
+ /** An empty, valid payload — the render path's "nothing yet" value. */
106
+ export declare function emptyGraphPayload(): GraphPayload;
107
+ /** Property keys tried, in order, when a node carries no explicit caption. */
108
+ export declare const DEFAULT_CAPTION_KEYS: readonly ["name", "title", "label", "caption", "displayName", "id"];
109
+ /**
110
+ * Pick the text to draw on a node.
111
+ *
112
+ * `captionKey` (from the payload legend or host styling) wins; then the
113
+ * conventional keys above; then the first short string property; then the id.
114
+ * Never returns an empty string — an unlabelled circle is unreadable.
115
+ */
116
+ export declare function resolveCaption(node: GraphNode, captionKey?: string): string;
117
+ /** First label, or a stable fallback so untyped nodes still group together. */
118
+ export declare const primaryLabel: (node: GraphNode) => string;
119
+ /**
120
+ * Drop edges whose endpoints are missing, and report how many went.
121
+ *
122
+ * A Cypher result routinely returns relationships whose far end was not in the
123
+ * projection. force-graph throws on an unresolvable link id, so this has to
124
+ * happen before the data reaches the canvas — and silently dropping them
125
+ * without a count makes "why is my graph missing edges?" unanswerable.
126
+ */
127
+ export declare function pruneDanglingEdges(payload: GraphPayload): {
128
+ nodes: GraphNode[];
129
+ edges: GraphEdge[];
130
+ dropped: number;
131
+ };
132
+ /**
133
+ * Union two payloads, right-hand side winning on id collisions.
134
+ *
135
+ * What "expand" and "the agent just added a node" both need: the new part has
136
+ * to join the graph on screen, not replace it. Replacing is what makes an
137
+ * explorer feel like a search box — every step throws away the context of the
138
+ * step before, and the position of every node the user had already placed.
139
+ *
140
+ * The right-hand side wins because it is the fresher read of the same thing:
141
+ * a node returned again after a property was set carries the new value.
142
+ */
143
+ export declare function mergeGraphPayloads(base: GraphPayload, addition: GraphPayload): GraphPayload;
144
+ /**
145
+ * `JSON.stringify` that survives what a graph actually carries.
146
+ *
147
+ * This is the DISPLAY and TRANSPORT form: the panel's `content` string behind
148
+ * the Copy button, the raw-result view on the card, and the tool results the
149
+ * model reads. It is deliberately NOT the form used to decide whether a value
150
+ * changed — see {@link deepEqual}, and the note below for why the two must
151
+ * stay apart.
152
+ *
153
+ * `properties` is `unknown` by design — it is whatever the database had — and
154
+ * the Neo4j driver hands back native `bigint` when configured with
155
+ * `useBigInt`, which `JSON.stringify` THROWS on. So opening the panel for an
156
+ * otherwise perfect payload could fail before anything rendered. Bigints
157
+ * become their decimal string (which is what the inspector already displays),
158
+ * and a cycle becomes `"[circular]"` rather than an exception.
159
+ *
160
+ * That string tag is LOSSY — `10n` and `"10"` come out the same — and that is
161
+ * correct here. A reader wants `10`, and so does the model: a stretch of this
162
+ * function's history tagged the bigint to keep an equality check honest, and
163
+ * the cost was a node property reaching the agent as `{"__bigint":"10"}`
164
+ * instead of a number. Equality is a different job; give it {@link deepEqual}.
165
+ */
166
+ export declare function safeStringify(value: unknown, space?: number): string;
167
+ /**
168
+ * A structurally identical value that `JSON.stringify` cannot choke on.
169
+ *
170
+ * For anything that will be TRANSPORTED by someone else\'s serializer — the
171
+ * chat context rides along on every turn and is serialized by the HTTP agent,
172
+ * far from here — a raw `properties` object is a hazard: one native `bigint`
173
+ * and the request body throws, which does not degrade, it stops the user
174
+ * sending messages at all while that node is selected.
175
+ */
176
+ export declare function jsonSafe<T>(value: T): T;
177
+ /**
178
+ * Did this value actually change?
179
+ *
180
+ * Compared structurally, WITHOUT serialising — which is the whole point. This
181
+ * question was answered three times by stringifying with a tag for `bigint`,
182
+ * and each tag collided with the data it competed against: `10n` as `"10"`
183
+ * collides with the string `"10"`, as `"10n"` with the string `"10n"`, and as
184
+ * `{__bigint:"10"}` with a property that happens to be that object. There is
185
+ * no fourth tag that works, because `properties` is `unknown` — any encoding
186
+ * can be imitated by data shaped like the encoding.
187
+ *
188
+ * Comparing types directly has no encoding to imitate: `typeof 10n` is not
189
+ * `typeof "10"`, and the question is settled in one step.
190
+ *
191
+ * It also drops a subtler bug the string form carried. Those replacers marked
192
+ * every visited object in one `WeakSet`, so the SECOND appearance of a shared
193
+ * but perfectly acyclic sub-object (the same `properties` on two nodes)
194
+ * serialised as `"[circular]"` — making values that differ there compare
195
+ * equal. Cycles are tracked as PAIRS here, so a repeat is only a cycle when
196
+ * both sides repeat together.
197
+ */
198
+ /**
199
+ * `Object.hasOwn`, which this repo\'s ES2020 target does not have.
200
+ *
201
+ * Needed wherever a key comes from the payload: labels and relationship types
202
+ * are whatever the producer called them, and `record["constructor"]` finds a
203
+ * function on the prototype rather than reporting the key as absent.
204
+ */
205
+ export declare function hasOwn(target: object, key: string): boolean;
206
+ export declare function deepEqual(a: unknown, b: unknown, seen?: Map<object, Set<object>>): boolean;
package/dist/model.js ADDED
@@ -0,0 +1,369 @@
1
+ /**
2
+ * The graph payload contract.
3
+ *
4
+ * Deliberately source-agnostic: nothing here knows about Neo4j, Cypher, or any
5
+ * other store. A backend tool, a REST response, or a hand-built fixture all
6
+ * produce the same shape, and {@link GraphView} renders it. Store-specific
7
+ * mapping lives in `adapters/` (see `adapters/neo4j.ts`) and is never imported
8
+ * from this module — a unit test enforces that.
9
+ */
10
+ /** Discriminator carried by every payload, so a tool card can sniff an
11
+ * arbitrary JSON result and decide whether it is renderable as a graph. */
12
+ export const GRAPH_PAYLOAD_KIND = "foundry.graph";
13
+ /** Payload format version. Bump only for a breaking shape change. */
14
+ export const GRAPH_PAYLOAD_VERSION = 1;
15
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
16
+ const isStringArray = (value) => value === undefined || (Array.isArray(value) && value.every((item) => typeof item === "string"));
17
+ /**
18
+ * A node, checked past its id.
19
+ *
20
+ * The optional fields are checked because this predicate decides whether the
21
+ * graph card TAKES OVER a tool result, and everything downstream then trusts
22
+ * the shape: `labels: [42]` was accepted as a graph and crashed the first
23
+ * search, in `matchNodes`, on `toLowerCase()`. Rejecting it renders the result
24
+ * as an ordinary tool card, which is the right outcome for something that is
25
+ * not a graph.
26
+ */
27
+ const isNode = (value) => isRecord(value) &&
28
+ typeof value.id === "string" &&
29
+ value.id.length > 0 &&
30
+ isStringArray(value.labels) &&
31
+ (value.caption === undefined || typeof value.caption === "string") &&
32
+ (value.properties === undefined || isRecord(value.properties));
33
+ const isEdge = (value) => isRecord(value) &&
34
+ typeof value.id === "string" &&
35
+ typeof value.source === "string" &&
36
+ typeof value.target === "string" &&
37
+ (value.type === undefined || typeof value.type === "string") &&
38
+ (value.properties === undefined || isRecord(value.properties));
39
+ /**
40
+ * Structural check. Strict on the discriminator — an arbitrary object that
41
+ * happens to carry `nodes` and `edges` is NOT a graph payload, because a tool
42
+ * card uses this to decide whether to take over the render.
43
+ */
44
+ export function isGraphPayload(value) {
45
+ if (!isRecord(value))
46
+ return false;
47
+ if (value.kind !== GRAPH_PAYLOAD_KIND)
48
+ return false;
49
+ if (value.version !== GRAPH_PAYLOAD_VERSION)
50
+ return false;
51
+ return (Array.isArray(value.nodes) &&
52
+ Array.isArray(value.edges) &&
53
+ value.nodes.every(isNode) &&
54
+ value.edges.every(isEdge) &&
55
+ isLegend(value.legend));
56
+ }
57
+ /**
58
+ * Producer styling hints, checked for the same reason the nodes are: whatever
59
+ * passes here is trusted from then on. `legend: { Person: null }` used to be
60
+ * accepted and then crash `useGraphStyling` on `hint.color`.
61
+ */
62
+ const isLegend = (value) => value === undefined ||
63
+ (isRecord(value) &&
64
+ Object.values(value).every((hint) => isRecord(hint) &&
65
+ (hint.color === undefined || typeof hint.color === "string") &&
66
+ (hint.captionKey === undefined || typeof hint.captionKey === "string")));
67
+ /**
68
+ * Best-effort extraction of a payload from a tool result.
69
+ *
70
+ * Tool results reach the UI in several shapes depending on how the agent
71
+ * framework serialized them: the payload itself, a JSON string of it, a
72
+ * double-encoded JSON string (a backend that returned a string, which the
73
+ * AG-UI runner then parsed once), or an envelope with the payload one level
74
+ * down (`{ graph: … }`, `{ result: … }`). All of them are common enough that
75
+ * making each caller handle them would just spread the same bug around.
76
+ *
77
+ * Returns `null` rather than throwing — a non-graph result is an ordinary
78
+ * outcome, not an error.
79
+ */
80
+ export function coerceGraphPayload(value) {
81
+ return coerceAtDepth(value, 0);
82
+ }
83
+ /**
84
+ * Bound on how far to chase a payload through strings and envelopes.
85
+ *
86
+ * Tool results are attacker-influenced in any app whose graph holds
87
+ * user-supplied text, so the search has to terminate on adversarial input:
88
+ * a deeply nested `{result:{result:{…}}}`, or a string encoded a thousand
89
+ * times. Six is far more than any real serializer produces.
90
+ */
91
+ const MAX_COERCE_DEPTH = 6;
92
+ const ENVELOPE_KEYS = ["graph", "result", "data", "payload"];
93
+ function coerceAtDepth(value, depth) {
94
+ if (depth > MAX_COERCE_DEPTH)
95
+ return null;
96
+ if (typeof value === "string") {
97
+ const trimmed = value.trim();
98
+ // Cheap reject before paying for a parse of a long prose result. `{` is a
99
+ // payload; `"` is a payload that was encoded one extra time.
100
+ if (!trimmed.startsWith("{") && !trimmed.startsWith('"'))
101
+ return null;
102
+ try {
103
+ return coerceAtDepth(JSON.parse(trimmed), depth + 1);
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ if (isGraphPayload(value))
110
+ return value;
111
+ if (!isRecord(value))
112
+ return null;
113
+ for (const key of ENVELOPE_KEYS) {
114
+ const nested = value[key];
115
+ if (nested === undefined || nested === value)
116
+ continue;
117
+ const found = coerceAtDepth(nested, depth + 1);
118
+ if (found)
119
+ return found;
120
+ }
121
+ return null;
122
+ }
123
+ /** An empty, valid payload — the render path's "nothing yet" value. */
124
+ export function emptyGraphPayload() {
125
+ return { kind: GRAPH_PAYLOAD_KIND, version: GRAPH_PAYLOAD_VERSION, nodes: [], edges: [] };
126
+ }
127
+ /** Property keys tried, in order, when a node carries no explicit caption. */
128
+ export const DEFAULT_CAPTION_KEYS = [
129
+ "name",
130
+ "title",
131
+ "label",
132
+ "caption",
133
+ "displayName",
134
+ "id",
135
+ ];
136
+ /**
137
+ * Pick the text to draw on a node.
138
+ *
139
+ * `captionKey` (from the payload legend or host styling) wins; then the
140
+ * conventional keys above; then the first short string property; then the id.
141
+ * Never returns an empty string — an unlabelled circle is unreadable.
142
+ */
143
+ export function resolveCaption(node, captionKey) {
144
+ if (node.caption)
145
+ return node.caption;
146
+ const props = node.properties ?? {};
147
+ if (captionKey) {
148
+ const explicit = props[captionKey];
149
+ if (typeof explicit === "string" && explicit.length > 0)
150
+ return explicit;
151
+ if (typeof explicit === "number")
152
+ return String(explicit);
153
+ }
154
+ for (const key of DEFAULT_CAPTION_KEYS) {
155
+ const candidate = props[key];
156
+ if (typeof candidate === "string" && candidate.length > 0)
157
+ return candidate;
158
+ }
159
+ for (const candidate of Object.values(props)) {
160
+ if (typeof candidate === "string" && candidate.length > 0 && candidate.length <= 60) {
161
+ return candidate;
162
+ }
163
+ }
164
+ return node.id;
165
+ }
166
+ /** First label, or a stable fallback so untyped nodes still group together. */
167
+ export const primaryLabel = (node) => node.labels?.[0] ?? "Node";
168
+ /**
169
+ * Drop edges whose endpoints are missing, and report how many went.
170
+ *
171
+ * A Cypher result routinely returns relationships whose far end was not in the
172
+ * projection. force-graph throws on an unresolvable link id, so this has to
173
+ * happen before the data reaches the canvas — and silently dropping them
174
+ * without a count makes "why is my graph missing edges?" unanswerable.
175
+ */
176
+ export function pruneDanglingEdges(payload) {
177
+ const ids = new Set(payload.nodes.map((n) => n.id));
178
+ const edges = payload.edges.filter((e) => ids.has(e.source) && ids.has(e.target));
179
+ return { nodes: payload.nodes, edges, dropped: payload.edges.length - edges.length };
180
+ }
181
+ /**
182
+ * Union two payloads, right-hand side winning on id collisions.
183
+ *
184
+ * What "expand" and "the agent just added a node" both need: the new part has
185
+ * to join the graph on screen, not replace it. Replacing is what makes an
186
+ * explorer feel like a search box — every step throws away the context of the
187
+ * step before, and the position of every node the user had already placed.
188
+ *
189
+ * The right-hand side wins because it is the fresher read of the same thing:
190
+ * a node returned again after a property was set carries the new value.
191
+ */
192
+ export function mergeGraphPayloads(base, addition) {
193
+ const nodes = new Map(base.nodes.map((node) => [node.id, node]));
194
+ for (const node of addition.nodes)
195
+ nodes.set(node.id, node);
196
+ const edges = new Map(base.edges.map((edge) => [edge.id, edge]));
197
+ for (const edge of addition.edges)
198
+ edges.set(edge.id, edge);
199
+ return {
200
+ kind: GRAPH_PAYLOAD_KIND,
201
+ version: GRAPH_PAYLOAD_VERSION,
202
+ nodes: [...nodes.values()],
203
+ edges: [...edges.values()],
204
+ // Either side hitting a cap means the union is incomplete.
205
+ truncated: base.truncated || addition.truncated ? true : undefined,
206
+ // `stats` means totals BEFORE truncation, so it can only be stated when
207
+ // nothing was truncated — there the retained counts ARE those totals.
208
+ // Once either producer capped, the union's true total is not derivable
209
+ // from anything here: each side knows its own pre-cap count, and how much
210
+ // those two sets OVERLAP is exactly what the capped rows would have said.
211
+ // Publishing the retained counts under this field understated a merge of
212
+ // a 500-node truncated read as ~300 and called it a total; the field is
213
+ // optional, so the honest answer is to omit it and let `truncated` carry
214
+ // the "incomplete" signal on its own.
215
+ stats: base.truncated || addition.truncated
216
+ ? undefined
217
+ : { nodeCount: nodes.size, edgeCount: edges.size },
218
+ // The legend describes labels, which accumulate; meta describes the most
219
+ // recent query, which does not.
220
+ legend: base.legend || addition.legend ? { ...base.legend, ...addition.legend } : undefined,
221
+ meta: addition.meta ?? base.meta,
222
+ };
223
+ }
224
+ /**
225
+ * `JSON.stringify` that survives what a graph actually carries.
226
+ *
227
+ * This is the DISPLAY and TRANSPORT form: the panel's `content` string behind
228
+ * the Copy button, the raw-result view on the card, and the tool results the
229
+ * model reads. It is deliberately NOT the form used to decide whether a value
230
+ * changed — see {@link deepEqual}, and the note below for why the two must
231
+ * stay apart.
232
+ *
233
+ * `properties` is `unknown` by design — it is whatever the database had — and
234
+ * the Neo4j driver hands back native `bigint` when configured with
235
+ * `useBigInt`, which `JSON.stringify` THROWS on. So opening the panel for an
236
+ * otherwise perfect payload could fail before anything rendered. Bigints
237
+ * become their decimal string (which is what the inspector already displays),
238
+ * and a cycle becomes `"[circular]"` rather than an exception.
239
+ *
240
+ * That string tag is LOSSY — `10n` and `"10"` come out the same — and that is
241
+ * correct here. A reader wants `10`, and so does the model: a stretch of this
242
+ * function's history tagged the bigint to keep an equality check honest, and
243
+ * the cost was a node property reaching the agent as `{"__bigint":"10"}`
244
+ * instead of a number. Equality is a different job; give it {@link deepEqual}.
245
+ */
246
+ export function safeStringify(value, space) {
247
+ // ANCESTORS of the value being written, not everything ever seen. A single
248
+ // visited-set calls the second sighting of a shared object a cycle, and two
249
+ // nodes sharing one `properties` object is an ordinary thing for a hand-built
250
+ // payload or a browser-side adapter to produce — so the panel's copied
251
+ // content, and the tool results the agent reads, carried a literal
252
+ // "[circular]" where real data belonged. A repeat is only a cycle if the
253
+ // object is on the path back to the root.
254
+ //
255
+ // `JSON.stringify` walks depth-first and calls the replacer with `this` bound
256
+ // to the container it is writing into, which is what makes the path
257
+ // reconstructible: pop until the top of the stack is this item's parent.
258
+ const path = [];
259
+ return JSON.stringify(value, function (_key, item) {
260
+ if (typeof item === "bigint")
261
+ return item.toString();
262
+ if (typeof item !== "object" || item === null)
263
+ return item;
264
+ while (path.length > 0 && path[path.length - 1] !== this)
265
+ path.pop();
266
+ if (path.includes(item))
267
+ return "[circular]";
268
+ path.push(item);
269
+ return item;
270
+ }, space);
271
+ }
272
+ /**
273
+ * A structurally identical value that `JSON.stringify` cannot choke on.
274
+ *
275
+ * For anything that will be TRANSPORTED by someone else\'s serializer — the
276
+ * chat context rides along on every turn and is serialized by the HTTP agent,
277
+ * far from here — a raw `properties` object is a hazard: one native `bigint`
278
+ * and the request body throws, which does not degrade, it stops the user
279
+ * sending messages at all while that node is selected.
280
+ */
281
+ export function jsonSafe(value) {
282
+ return JSON.parse(safeStringify(value));
283
+ }
284
+ /**
285
+ * Did this value actually change?
286
+ *
287
+ * Compared structurally, WITHOUT serialising — which is the whole point. This
288
+ * question was answered three times by stringifying with a tag for `bigint`,
289
+ * and each tag collided with the data it competed against: `10n` as `"10"`
290
+ * collides with the string `"10"`, as `"10n"` with the string `"10n"`, and as
291
+ * `{__bigint:"10"}` with a property that happens to be that object. There is
292
+ * no fourth tag that works, because `properties` is `unknown` — any encoding
293
+ * can be imitated by data shaped like the encoding.
294
+ *
295
+ * Comparing types directly has no encoding to imitate: `typeof 10n` is not
296
+ * `typeof "10"`, and the question is settled in one step.
297
+ *
298
+ * It also drops a subtler bug the string form carried. Those replacers marked
299
+ * every visited object in one `WeakSet`, so the SECOND appearance of a shared
300
+ * but perfectly acyclic sub-object (the same `properties` on two nodes)
301
+ * serialised as `"[circular]"` — making values that differ there compare
302
+ * equal. Cycles are tracked as PAIRS here, so a repeat is only a cycle when
303
+ * both sides repeat together.
304
+ */
305
+ /**
306
+ * `Object.hasOwn`, which this repo\'s ES2020 target does not have.
307
+ *
308
+ * Needed wherever a key comes from the payload: labels and relationship types
309
+ * are whatever the producer called them, and `record["constructor"]` finds a
310
+ * function on the prototype rather than reporting the key as absent.
311
+ */
312
+ export function hasOwn(target, key) {
313
+ return Object.prototype.hasOwnProperty.call(target, key);
314
+ }
315
+ function isDate(value) {
316
+ return Object.prototype.toString.call(value) === "[object Date]";
317
+ }
318
+ /** Own enumerable keys describe this object completely. */
319
+ function isPlainObject(value) {
320
+ const proto = Object.getPrototypeOf(value);
321
+ return proto === Object.prototype || proto === null;
322
+ }
323
+ export function deepEqual(a, b, seen) {
324
+ if (Object.is(a, b))
325
+ return true;
326
+ // Different types can never be equal, which is exactly the bigint case.
327
+ if (typeof a !== typeof b)
328
+ return false;
329
+ if (typeof a !== "object" || a === null || b === null)
330
+ return false;
331
+ const left = a;
332
+ const right = b;
333
+ const pairs = seen ?? new Map();
334
+ const already = pairs.get(left);
335
+ // Assumed equal while the comparison is in flight: two structures that
336
+ // recurse identically are equal, and re-entering would not terminate.
337
+ if (already?.has(right))
338
+ return true;
339
+ if (already)
340
+ already.add(right);
341
+ else
342
+ pairs.set(left, new Set([right]));
343
+ if (Array.isArray(left) || Array.isArray(right)) {
344
+ if (!Array.isArray(left) || !Array.isArray(right))
345
+ return false;
346
+ if (left.length !== right.length)
347
+ return false;
348
+ return left.every((item, index) => deepEqual(item, right[index], pairs));
349
+ }
350
+ // Enumerable keys only describe a PLAIN object. A `Date`, `Map`, `Set` or
351
+ // `RegExp` keeps its state internally and has none, so comparing key sets
352
+ // said two different dates were the same value — and this guard's entire job
353
+ // is deciding whether something changed. `Date` is worth spelling out
354
+ // because it is what a host or an adapter actually puts in a property; every
355
+ // other exotic object falls through to "not equal unless identical", which
356
+ // is the safe direction: a spurious change costs one redundant update, a
357
+ // missed one loses the user's data silently.
358
+ if (isDate(left) || isDate(right)) {
359
+ return isDate(left) && isDate(right) && Object.is(left.getTime(), right.getTime());
360
+ }
361
+ if (!isPlainObject(left) || !isPlainObject(right))
362
+ return false;
363
+ const leftKeys = Object.keys(left);
364
+ const rightKeys = new Set(Object.keys(right));
365
+ if (leftKeys.length !== rightKeys.size)
366
+ return false;
367
+ return leftKeys.every((key) => rightKeys.has(key) &&
368
+ deepEqual(left[key], right[key], pairs));
369
+ }
@@ -0,0 +1,97 @@
1
+ /* Graph palette.
2
+ *
3
+ * Eight categorical hues, evenly spaced around the wheel and held at a
4
+ * constant lightness and chroma so no single label shouts louder than the
5
+ * others — a palette where one colour is darker reads as a hierarchy that
6
+ * isn't there. Spacing is even rather than "pleasing" on purpose: the job is
7
+ * telling `Person` from `Movie` at a glance, including for the common
8
+ * red/green and blue/purple confusions.
9
+ *
10
+ * The violet at `--graph-1` is the same family as the shell's `--primary`, so
11
+ * the first (and in most graphs, largest) label sits inside the app's identity
12
+ * instead of fighting it.
13
+ *
14
+ * These are plain custom properties, not `@theme` tokens: they are read from
15
+ * the DOM by `resolveGraphTheme()` and painted onto a canvas, never turned
16
+ * into Tailwind utilities. Importing this file is optional — without it the
17
+ * graph falls back to the host's `--chart-1..5`, and then to a built-in
18
+ * palette.
19
+ *
20
+ * Dark values lift lightness and drop chroma: the same colour at the same
21
+ * chroma glows against a dark ground and bleeds into its own caption.
22
+ */
23
+ :root {
24
+ --graph-1: oklch(0.61 0.212 285); /* violet — pairs with --primary */
25
+ --graph-2: oklch(0.65 0.155 200); /* cyan */
26
+ --graph-3: oklch(0.62 0.208 15); /* rose */
27
+ --graph-4: oklch(0.7 0.165 75); /* amber */
28
+ --graph-5: oklch(0.63 0.152 158); /* emerald */
29
+ --graph-6: oklch(0.61 0.223 328); /* fuchsia */
30
+ --graph-7: oklch(0.6 0.19 255); /* blue */
31
+ --graph-8: oklch(0.68 0.17 122); /* lime */
32
+ }
33
+
34
+ .dark {
35
+ --graph-1: oklch(0.72 0.175 285);
36
+ --graph-2: oklch(0.76 0.13 200);
37
+ --graph-3: oklch(0.72 0.17 15);
38
+ --graph-4: oklch(0.79 0.14 75);
39
+ --graph-5: oklch(0.74 0.128 158);
40
+ --graph-6: oklch(0.72 0.18 328);
41
+ --graph-7: oklch(0.71 0.155 255);
42
+ --graph-8: oklch(0.78 0.145 122);
43
+ }
44
+
45
+ /* The hover tooltip.
46
+ *
47
+ * `force-graph` renders it through `float-tooltip`, which injects its own
48
+ * stylesheet: a hardcoded `rgba(0,0,0,0.6)` box with `#eee` text and a 12px
49
+ * sans stack. That is a black box floating over a themed app, and it looks
50
+ * exactly like the third-party widget it is.
51
+ *
52
+ * These rules re-dress it from the host's tokens. They live here rather than
53
+ * in a component because the element is created outside React — there is no
54
+ * render pass to attach a class in.
55
+ */
56
+ .float-tooltip-kap {
57
+ padding: 0.5rem 0.625rem;
58
+ border: 1px solid var(--border);
59
+ border-radius: var(--radius-md, 0.375rem);
60
+ background: var(--popover);
61
+ color: var(--popover-foreground);
62
+ font:
63
+ 12px/1.45 ui-sans-serif,
64
+ system-ui,
65
+ sans-serif;
66
+ box-shadow:
67
+ 0 4px 6px -1px rgb(0 0 0 / 0.1),
68
+ 0 2px 4px -2px rgb(0 0 0 / 0.1);
69
+ max-width: 22rem;
70
+ }
71
+
72
+ /* The details drawer's slide-in.
73
+ *
74
+ * A keyframe, not React state. The obvious implementation — mount at
75
+ * `translate-x-full`, flip to `translate-x-0` in an effect — needs a state
76
+ * write on every selection change, and a selection change is also what
77
+ * re-renders the graph around it. Keyframes run off the render path entirely,
78
+ * so re-selecting cannot feed back into another render.
79
+ */
80
+ @keyframes foundry-graph-drawer-in {
81
+ from {
82
+ transform: translateX(100%);
83
+ }
84
+ to {
85
+ transform: translateX(0);
86
+ }
87
+ }
88
+
89
+ .foundry-graph-drawer {
90
+ animation: foundry-graph-drawer-in 200ms cubic-bezier(0.32, 0.72, 0, 1);
91
+ }
92
+
93
+ @media (prefers-reduced-motion: reduce) {
94
+ .foundry-graph-drawer {
95
+ animation: none;
96
+ }
97
+ }