@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,106 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect } from "react";
3
+ import { Maximize2, Snowflake, X } from "lucide-react";
4
+ import { cn } from "@iloveagents/foundry-web-primitives";
5
+ import { primaryLabel, resolveCaption, safeStringify, } from "./model.js";
6
+ /** Render a property value without collapsing structure into "[object Object]". */
7
+ function formatValue(value) {
8
+ if (value === null || value === undefined)
9
+ return "—";
10
+ if (typeof value === "string")
11
+ return value;
12
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
13
+ return String(value);
14
+ }
15
+ if (Array.isArray(value))
16
+ return value.map(formatValue).join(", ");
17
+ // `safeStringify`, not `JSON.stringify`: a structured property containing a
18
+ // `bigint` (`{ count: 10n }`) THROWS, and the fallback then renders
19
+ // "[object Object]" — hiding the value in the one panel whose entire job is
20
+ // showing it. Cycles are handled for the same reason.
21
+ return safeStringify(value);
22
+ }
23
+ /**
24
+ * Label/value rows, matching the field list a downstream app already uses for
25
+ * "inspect this item".
26
+ *
27
+ * A fixed label column rather than a two-column grid: property names in a
28
+ * graph are short and repetitive (`name`, `year`, `title`), values are not, so
29
+ * an auto-sized label column jitters between selections and makes the drawer
30
+ * feel like it is redrawing itself every time you click.
31
+ */
32
+ function PropertyRows({ properties }) {
33
+ const entries = Object.entries(properties ?? {});
34
+ if (entries.length === 0) {
35
+ return (_jsx("p", { className: "text-muted-foreground rounded-xl border border-dashed px-3 py-6 text-center text-sm", children: "No properties." }));
36
+ }
37
+ return (_jsx("div", { className: "divide-border/60 border-border/70 divide-y rounded-xl border", children: entries.map(([key, value]) => (_jsx("div", { className: "px-3 py-2", children: _jsxs("div", { className: "flex min-h-9 items-start gap-3", children: [_jsx("span", { className: "text-muted-foreground w-28 shrink-0 truncate pt-0.5 text-sm", title: key, children: key }), _jsx("div", { className: "text-foreground min-w-0 flex-1 break-words text-sm", children: formatValue(value) })] }) }, key))) }));
38
+ }
39
+ /**
40
+ * The drawer's width in pixels, matching `w-80` below.
41
+ *
42
+ * Exported because the canvas has to know how much of itself is covered:
43
+ * a node hidden behind the drawer is a node you selected and then could not
44
+ * see. Kept as a number rather than measured, so the pan can be issued in
45
+ * the same frame the drawer opens.
46
+ */
47
+ export const GRAPH_INSPECTOR_WIDTH = 320;
48
+ /**
49
+ * Details for the selected node or relationship.
50
+ *
51
+ * A drawer over the canvas, not a rail beside it. A rail is the obvious
52
+ * layout and it is wrong here: it takes its width from the graph permanently,
53
+ * so selecting a node — the one gesture that means "I want to look at this
54
+ * more closely" — is also the gesture that shrinks what you are looking at.
55
+ * Over the canvas, the graph keeps its full width and the drawer is dismissed
56
+ * the moment you are done with it.
57
+ *
58
+ * Also the accessible surface for the graph: the canvas itself is opaque to a
59
+ * screen reader, so everything a sighted user learns by hovering has to be
60
+ * readable here as ordinary DOM.
61
+ */
62
+ export function GraphInspector({ selection, colorForLabel, captionOf, onClose, onExpandNode, onToggleFrozen, isFrozen, actions, className, }) {
63
+ const { node, edge } = selection;
64
+ const open = Boolean(node || edge);
65
+ // Escape closes, matching every other dismissible surface. Bound to the
66
+ // window rather than the drawer because the canvas keeps keyboard focus —
67
+ // people select with the mouse and expect Escape to work without clicking
68
+ // into the panel first.
69
+ useEffect(() => {
70
+ if (!open)
71
+ return;
72
+ const onKey = (event) => {
73
+ if (event.key !== "Escape" || event.defaultPrevented)
74
+ return;
75
+ // One press, one dismissal. The host's tool panel listens for Escape on
76
+ // the window too, and it was registered first (it mounts before a
77
+ // selection exists), so in the bubble phase it ran first and closed the
78
+ // WHOLE graph panel out from under the drawer this key was meant for.
79
+ //
80
+ // Hence capture: a capture listener on `window` runs before every bubble
81
+ // listener on it, and `stopPropagation` there ends the trip — the event
82
+ // never reaches the target and never bubbles back. `preventDefault` as
83
+ // well, for any handler that guards on `defaultPrevented` instead.
84
+ event.stopPropagation();
85
+ event.preventDefault();
86
+ onClose();
87
+ };
88
+ window.addEventListener("keydown", onKey, true);
89
+ return () => window.removeEventListener("keydown", onKey, true);
90
+ }, [open, onClose]);
91
+ if (!node && !edge)
92
+ return null;
93
+ const frozen = node ? Boolean(isFrozen?.(node)) : false;
94
+ return (
95
+ // Non-modal, and pointer-transparent everywhere but the drawer itself.
96
+ // No scrim, no click-catcher: with the graph covered by an invisible
97
+ // button you cannot click the next node, pan, or zoom while a node's
98
+ // details are open — which is most of what you do with a graph. Dismissal
99
+ // is the close button, Escape, or simply selecting something else.
100
+ _jsx("div", { className: cn("pointer-events-none absolute inset-0 z-20 flex justify-end", className), children: _jsxs("aside", { role: "dialog", "aria-modal": "false", "aria-label": node ? "Selected node" : "Selected relationship", className: "foundry-graph-drawer border-border bg-background pointer-events-auto flex h-full w-80 max-w-[85%] flex-col border-l shadow-2xl", children: [_jsxs("header", { className: "border-border flex items-start justify-between gap-3 border-b px-4 py-3", children: [_jsxs("div", { className: "min-w-0", children: [node ? (_jsxs("p", { className: "text-muted-foreground flex flex-wrap items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.14em]", children: [_jsx("span", { "aria-hidden": "true", className: "size-2 shrink-0 rounded-full", style: { backgroundColor: colorForLabel(primaryLabel(node)) } }), (node.labels?.length ? node.labels : ["Node"]).join(" · ")] })) : (_jsx("p", { className: "text-muted-foreground text-[11px] font-semibold uppercase tracking-[0.14em]", children: "Relationship" })), _jsx("h2", { className: "text-foreground truncate text-base font-semibold", title: headingOf(), children: headingOf() })] }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close details", className: "border-border text-muted-foreground hover:bg-muted focus-visible:ring-ring rounded-lg border p-1.5 transition-colors focus-visible:outline-none focus-visible:ring-1", children: _jsx(X, { className: "size-4" }) })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto px-4 py-4", children: _jsx(PropertyRows, { properties: (node ?? edge).properties }) }), node && (onExpandNode || onToggleFrozen || actions) && (_jsxs("footer", { className: "border-border flex items-center justify-end gap-1.5 border-t px-4 py-3", children: [actions, onToggleFrozen && (_jsxs("button", { type: "button", onClick: () => onToggleFrozen(node), "aria-pressed": frozen, title: frozen ? "Let the layout move this node" : "Freeze this node in place", className: cn("border-border hover:bg-muted focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-1", frozen ? "text-primary" : "text-muted-foreground"), children: [_jsx(Snowflake, { className: "size-3.5" }), frozen ? "Frozen" : "Freeze"] })), onExpandNode && (_jsxs("button", { type: "button", onClick: () => onExpandNode(node), title: "Load this node's neighbours", className: "border-border hover:bg-muted text-foreground focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-1", children: [_jsx(Maximize2, { className: "size-3.5" }), "Expand"] }))] }))] }) }));
101
+ function headingOf() {
102
+ if (node)
103
+ return captionOf(node) || resolveCaption(node);
104
+ return edge?.type ?? "RELATED";
105
+ }
106
+ }
@@ -0,0 +1,19 @@
1
+ import type { GraphStyleMap } from "./use-graph-styling.js";
2
+ export interface GraphLegendProps {
3
+ styleMap: GraphStyleMap;
4
+ /** Node count per label, so the legend doubles as a breakdown. */
5
+ counts: ReadonlyMap<string, number>;
6
+ hiddenLabels: Set<string>;
7
+ onToggleLabel: (label: string) => void;
8
+ onShowAll: () => void;
9
+ className?: string;
10
+ }
11
+ /**
12
+ * Colour key for node labels, and the filter control.
13
+ *
14
+ * Clicking a label hides that type — the fastest way to make a dense graph
15
+ * readable, and the one interaction Bloom users reach for first. Rendered as
16
+ * real `<button>`s so the filter is keyboard-reachable; the canvas beside it
17
+ * is not.
18
+ */
19
+ export declare function GraphLegend({ styleMap, counts, hiddenLabels, onToggleLabel, onShowAll, className, }: GraphLegendProps): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "@iloveagents/foundry-web-primitives";
3
+ /**
4
+ * Colour key for node labels, and the filter control.
5
+ *
6
+ * Clicking a label hides that type — the fastest way to make a dense graph
7
+ * readable, and the one interaction Bloom users reach for first. Rendered as
8
+ * real `<button>`s so the filter is keyboard-reachable; the canvas beside it
9
+ * is not.
10
+ */
11
+ export function GraphLegend({ styleMap, counts, hiddenLabels, onToggleLabel, onShowAll, className, }) {
12
+ if (styleMap.labels.length === 0)
13
+ return null;
14
+ return (_jsxs("div", { className: cn("flex flex-wrap items-center gap-1.5", className), children: [styleMap.labels.map((label) => {
15
+ const hidden = hiddenLabels.has(label);
16
+ return (_jsxs("button", { type: "button", onClick: () => onToggleLabel(label), "aria-pressed": !hidden, title: hidden ? `Show ${label}` : `Hide ${label}`, className: cn("inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs transition-colors", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", hidden
17
+ ? "border-border text-muted-foreground opacity-60"
18
+ : "border-border bg-card text-foreground hover:bg-accent"), children: [_jsx("span", { "aria-hidden": "true", className: "size-2 shrink-0 rounded-full", style: { backgroundColor: styleMap.colorForLabel(label) } }), _jsx("span", { className: cn(hidden && "line-through"), children: label }), _jsx("span", { className: "text-muted-foreground tabular-nums", children: counts.get(label) ?? 0 })] }, label));
19
+ }), hiddenLabels.size > 0 && (_jsx("button", { type: "button", onClick: onShowAll, className: "rounded-full px-2 py-0.5 text-xs text-muted-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", children: "Show all" }))] }));
20
+ }
@@ -0,0 +1,19 @@
1
+ import type { GraphModelLoss } from "./use-graph-model.js";
2
+ /** Shown in place of the canvas when there is nothing to draw. */
3
+ export declare function GraphEmptyState({ message, className }: {
4
+ message?: string;
5
+ className?: string;
6
+ }): import("react/jsx-runtime").JSX.Element;
7
+ /**
8
+ * Say what was withheld, and why.
9
+ *
10
+ * A graph that silently renders 1,500 of 40,000 nodes is worse than one that
11
+ * refuses to render: the user reads the picture as the whole answer. Each
12
+ * cause is named separately because the fixes differ — a cap means "narrow the
13
+ * query", dangling edges mean "the projection is incomplete".
14
+ */
15
+ export declare function GraphLossNotice({ loss, truncatedByProducer, className, }: {
16
+ loss: GraphModelLoss;
17
+ truncatedByProducer?: boolean;
18
+ className?: string;
19
+ }): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AlertTriangle, Network } from "lucide-react";
3
+ import { cn } from "@iloveagents/foundry-web-primitives";
4
+ /** Shown in place of the canvas when there is nothing to draw. */
5
+ export function GraphEmptyState({ message, className }) {
6
+ return (_jsxs("div", { className: cn("text-muted-foreground flex h-full flex-col items-center justify-center gap-2 p-6 text-center", className), children: [_jsx(Network, { className: "size-6 opacity-50", "aria-hidden": "true" }), _jsx("p", { className: "text-sm", children: message ?? "No graph data to display." })] }));
7
+ }
8
+ /**
9
+ * Say what was withheld, and why.
10
+ *
11
+ * A graph that silently renders 1,500 of 40,000 nodes is worse than one that
12
+ * refuses to render: the user reads the picture as the whole answer. Each
13
+ * cause is named separately because the fixes differ — a cap means "narrow the
14
+ * query", dangling edges mean "the projection is incomplete".
15
+ */
16
+ export function GraphLossNotice({ loss, truncatedByProducer, className, }) {
17
+ const reasons = [];
18
+ if (truncatedByProducer)
19
+ reasons.push("the query hit the server's result cap");
20
+ if (loss.cappedNodes > 0)
21
+ reasons.push(`${loss.cappedNodes} nodes over the render limit`);
22
+ if (loss.cappedEdges > 0)
23
+ reasons.push(`${loss.cappedEdges} relationships over the render limit`);
24
+ if (loss.danglingEdges > 0) {
25
+ reasons.push(`${loss.danglingEdges} relationships whose endpoints weren't returned`);
26
+ }
27
+ if (reasons.length === 0)
28
+ return null;
29
+ return (_jsxs("p", { role: "status", className: cn("text-muted-foreground flex items-start gap-1.5 text-xs", className), children: [_jsx(AlertTriangle, { className: "mt-px size-3.5 shrink-0", "aria-hidden": "true" }), _jsxs("span", { children: ["Showing a partial graph \u2014 ", reasons.join("; "), "."] })] }));
30
+ }
@@ -0,0 +1,28 @@
1
+ import { type GraphEdge, type GraphNode } from "./model.js";
2
+ import { type GraphModel } from "./use-graph-model.js";
3
+ import type { GraphStyleMap } from "./use-graph-styling.js";
4
+ export interface GraphTableProps {
5
+ model: GraphModel;
6
+ styleMap: GraphStyleMap;
7
+ onSelectNode?: (node: GraphNode) => void;
8
+ /**
9
+ * Open a relationship in the inspector.
10
+ *
11
+ * The drawer has always handled edges; only the canvas could reach them.
12
+ * This table IS the accessibility surface — the canvas is one opaque element
13
+ * to a screen reader — so without this a keyboard user could read that a
14
+ * relationship exists and never see a single one of its properties, while a
15
+ * mouse user clicks the same line and gets all of them.
16
+ */
17
+ onSelectEdge?: (edge: GraphEdge) => void;
18
+ className?: string;
19
+ }
20
+ /**
21
+ * The same graph as a table.
22
+ *
23
+ * This is the accessible surface, not a developer escape hatch: a `<canvas>` is
24
+ * opaque to a screen reader, so without this the graph is simply unavailable to
25
+ * anyone using one. It is also what renders when there is no canvas at all —
26
+ * jsdom in tests, and print.
27
+ */
28
+ export declare function GraphTable({ model, styleMap, onSelectNode, onSelectEdge, className, }: GraphTableProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,57 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { cn } from "@iloveagents/foundry-web-primitives";
3
+ import { useMemo } from "react";
4
+ import { primaryLabel } from "./model.js";
5
+ import { endpointId } from "./use-graph-model.js";
6
+ /**
7
+ * The same graph as a table.
8
+ *
9
+ * This is the accessible surface, not a developer escape hatch: a `<canvas>` is
10
+ * opaque to a screen reader, so without this the graph is simply unavailable to
11
+ * anyone using one. It is also what renders when there is no canvas at all —
12
+ * jsdom in tests, and print.
13
+ */
14
+ export function GraphTable({ model, styleMap, onSelectNode, onSelectEdge, className, }) {
15
+ // Counted once, not per row. The old version filtered the whole link array
16
+ // for every node — quadratic, and this is the ACCESSIBILITY path, where a
17
+ // capped graph can still carry 1,500 nodes and 3,000 edges.
18
+ //
19
+ // Endpoints are read through a helper because force-graph replaces the ids
20
+ // with the node objects once it has run: after a canvas render, comparing
21
+ // against `node.id` would silently give every row a degree of zero.
22
+ const degrees = useMemo(() => {
23
+ const counts = new Map();
24
+ const bump = (id) => {
25
+ if (id === undefined)
26
+ return;
27
+ counts.set(id, (counts.get(id) ?? 0) + 1);
28
+ };
29
+ for (const link of model.links) {
30
+ bump(endpointId(link.source));
31
+ bump(endpointId(link.target));
32
+ }
33
+ return counts;
34
+ }, [model.links]);
35
+ // The relationships, resolved to the captions the nodes are listed under.
36
+ // A degree count says a node is connected; it does not say to WHAT, by which
37
+ // type, or in which direction — which is the whole content of a graph. A
38
+ // screen-reader user reading only the node table could not tell two
39
+ // completely different graphs apart.
40
+ const captionFor = useMemo(() => {
41
+ const byId = new Map(model.nodes.map((node) => [node.id, styleMap.styleForNode(node).caption]));
42
+ return (id) => (id === undefined ? "—" : (byId.get(id) ?? id));
43
+ }, [model.nodes, styleMap]);
44
+ return (_jsxs("div", { className: cn("flex flex-col gap-4 overflow-auto", className), children: [_jsxs("table", { className: "w-full border-collapse text-left text-xs", children: [_jsxs("caption", { className: "sr-only", children: ["Nodes: ", model.nodes.length, " of them, in a graph with ", model.links.length, " relationships"] }), _jsx("thead", { className: "text-muted-foreground sticky top-0", children: _jsxs("tr", { className: "border-border bg-card border-b", children: [_jsx("th", { scope: "col", className: "px-2 py-1 font-medium", children: "Label" }), _jsx("th", { scope: "col", className: "px-2 py-1 font-medium", children: "Caption" }), _jsx("th", { scope: "col", className: "px-2 py-1 font-medium", children: "Relationships" })] }) }), _jsx("tbody", { children: model.nodes.map((node) => {
45
+ const degree = degrees.get(node.id) ?? 0;
46
+ return (_jsxs("tr", { className: "border-border/60 border-b last:border-0", children: [_jsx("td", { className: "text-muted-foreground px-2 py-1", children: primaryLabel(node) }), _jsx("td", { className: "px-2 py-1", children: onSelectNode ? (_jsx("button", { type: "button", onClick: () => onSelectNode(node), className: "focus-visible:ring-ring rounded underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-1", children: styleMap.styleForNode(node).caption })) : (styleMap.styleForNode(node).caption) }), _jsx("td", { className: "text-muted-foreground px-2 py-1 tabular-nums", children: degree })] }, node.id));
47
+ }) })] }), model.links.length > 0 && (_jsxs("table", { className: "w-full border-collapse text-left text-xs", children: [_jsxs("caption", { className: "sr-only", children: ["Relationships: ", model.links.length, " of them, as source, type and target", onSelectEdge ? "; the type opens its properties" : ""] }), _jsx("thead", { className: "text-muted-foreground sticky top-0", children: _jsxs("tr", { className: "border-border bg-card border-b", children: [_jsx("th", { scope: "col", className: "px-2 py-1 font-medium", children: "From" }), _jsx("th", { scope: "col", className: "px-2 py-1 font-medium", children: "Type" }), _jsx("th", { scope: "col", className: "px-2 py-1 font-medium", children: "To" })] }) }), _jsx("tbody", { children: model.links.map((link) => (_jsxs("tr", { className: "border-border/60 border-b last:border-0", children: [_jsx("td", { className: "px-2 py-1", children: captionFor(endpointId(link.source)) }), _jsx("td", { className: "text-muted-foreground px-2 py-1", children: onSelectEdge ? (_jsx("button", { type: "button",
48
+ // Endpoints normalised for the same reason
49
+ // `getViewState` normalises them: force-graph swaps a
50
+ // link\'s `source`/`target` for the node objects once it
51
+ // has run, and the inspector reads ids.
52
+ onClick: () => onSelectEdge({
53
+ ...link,
54
+ source: endpointId(link.source) ?? "",
55
+ target: endpointId(link.target) ?? "",
56
+ }), className: "hover:text-foreground focus-visible:ring-ring rounded text-left underline decoration-dotted underline-offset-2 focus-visible:outline-none focus-visible:ring-1", children: link.type ?? "RELATED" })) : ((link.type ?? "RELATED")) }), _jsx("td", { className: "px-2 py-1", children: captionFor(endpointId(link.target)) })] }, link.id))) })] }))] }));
57
+ }
@@ -0,0 +1,22 @@
1
+ export interface GraphToolbarProps {
2
+ query: string;
3
+ onQueryChange: (value: string) => void;
4
+ /** Number of nodes matching the query — shown next to the search box. */
5
+ matchCount: number;
6
+ nodeCount: number;
7
+ edgeCount: number;
8
+ onZoomToFit: () => void;
9
+ onReheat: () => void;
10
+ pinnedCount: number;
11
+ onUnpinAll: () => void;
12
+ /**
13
+ * Whether the table is showing, and how to swap. Omitted where there is no
14
+ * choice to offer — a host that pinned the view with `forceTable`, or an
15
+ * environment with no canvas at all.
16
+ */
17
+ showsTable?: boolean;
18
+ onToggleTable?: () => void;
19
+ className?: string;
20
+ }
21
+ /** Search, layout controls and element counts. */
22
+ export declare function GraphToolbar({ query, onQueryChange, matchCount, nodeCount, edgeCount, onZoomToFit, onReheat, pinnedCount, onUnpinAll, showsTable, onToggleTable, className, }: GraphToolbarProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Maximize2, PinOff, Play, Search, Share2, Table2, X } from "lucide-react";
3
+ import { cn } from "@iloveagents/foundry-web-primitives";
4
+ const iconButton = "text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring inline-flex size-7 items-center justify-center rounded focus-visible:outline-none focus-visible:ring-1";
5
+ /** Search, layout controls and element counts. */
6
+ export function GraphToolbar({ query, onQueryChange, matchCount, nodeCount, edgeCount, onZoomToFit, onReheat, pinnedCount, onUnpinAll, showsTable, onToggleTable, className, }) {
7
+ return (_jsxs("div", { className: cn("flex items-center gap-2", className), children: [_jsxs("div", { className: "border-border bg-card focus-within:ring-ring flex min-w-0 flex-1 items-center gap-1.5 rounded-md border px-2 py-1 focus-within:ring-1", children: [_jsx(Search, { className: "text-muted-foreground size-3.5 shrink-0", "aria-hidden": "true" }), _jsx("input", { type: "search", value: query, onChange: (event) => onQueryChange(event.target.value), placeholder: "Search nodes\u2026", "aria-label": "Search nodes", className: "text-foreground placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-xs outline-none" }), query.length > 0 && (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-muted-foreground shrink-0 text-xs tabular-nums", title: `${matchCount} matching ${matchCount === 1 ? "node" : "nodes"}`, "aria-label": `${matchCount} matching ${matchCount === 1 ? "node" : "nodes"}`, children: matchCount }), _jsx("button", { type: "button", onClick: () => onQueryChange(""), "aria-label": "Clear search", className: "text-muted-foreground hover:text-foreground shrink-0", children: _jsx(X, { className: "size-3" }) })] }))] }), _jsxs("span", { className: "text-muted-foreground shrink-0 text-xs tabular-nums", children: [nodeCount, " nodes \u00B7 ", edgeCount, " rels"] }), onToggleTable && (_jsxs("button", { type: "button", onClick: onToggleTable, title: showsTable ? "Show the graph" : "Show as a table", "aria-pressed": showsTable, className: iconButton, children: [showsTable ? _jsx(Share2, { className: "size-3.5" }) : _jsx(Table2, { className: "size-3.5" }), _jsx("span", { className: "sr-only", children: showsTable ? "Show the graph" : "Show as a table" })] })), pinnedCount > 0 && (_jsxs("button", { type: "button", onClick: onUnpinAll, title: "Unpin all nodes", className: iconButton, children: [_jsx(PinOff, { className: "size-3.5" }), _jsx("span", { className: "sr-only", children: "Unpin all nodes" })] })), _jsxs("button", { type: "button", onClick: onReheat, title: "Restart layout", className: iconButton, children: [_jsx(Play, { className: "size-3.5" }), _jsx("span", { className: "sr-only", children: "Restart layout" })] }), _jsxs("button", { type: "button", onClick: onZoomToFit, title: "Zoom to fit", className: iconButton, children: [_jsx(Maximize2, { className: "size-3.5" }), _jsx("span", { className: "sr-only", children: "Zoom to fit" })] })] }));
8
+ }
@@ -0,0 +1,4 @@
1
+ import { type GraphStyleMap } from "./use-graph-styling.js";
2
+ import type { GraphEdge, GraphNode } from "./model.js";
3
+ export declare function nodeTooltip(node: GraphNode, styleMap: GraphStyleMap): HTMLElement;
4
+ export declare function edgeTooltip(edge: GraphEdge): HTMLElement;
@@ -0,0 +1,55 @@
1
+ import { describeEdge, describeNode } from "./use-graph-styling.js";
2
+ /**
3
+ * Build the hover tooltip as a DOM element, NOT as an HTML string.
4
+ *
5
+ * `force-graph`'s tooltip (`float-tooltip`) does `selection.html(content)` when
6
+ * given a string — i.e. `innerHTML`. Node captions and property values come
7
+ * straight out of the database, so a string tooltip would execute markup stored
8
+ * in the graph. Returning an `HTMLElement` takes the library's other branch,
9
+ * which appends the node as-is, and everything here is set via `textContent`.
10
+ *
11
+ * Do not "simplify" this back into a template literal.
12
+ */
13
+ function tooltipElement(heading, rows) {
14
+ const root = document.createElement("div");
15
+ const title = document.createElement("div");
16
+ title.style.fontWeight = "600";
17
+ title.textContent = heading;
18
+ title.style.marginBottom = rows.length ? "0.125rem" : "0";
19
+ root.appendChild(title);
20
+ for (const [key, value] of rows) {
21
+ const row = document.createElement("div");
22
+ // The token, not a hardcoded grey — the tooltip sits on the host's
23
+ // popover surface (see `.float-tooltip-kap` in styles.css).
24
+ row.style.color = "var(--muted-foreground)";
25
+ row.textContent = `${key}: ${value}`;
26
+ root.appendChild(row);
27
+ }
28
+ return root;
29
+ }
30
+ /** Property rows to preview in a tooltip — enough to identify, not a dump. */
31
+ const MAX_TOOLTIP_ROWS = 4;
32
+ const MAX_TOOLTIP_VALUE_CHARS = 80;
33
+ function previewRows(properties) {
34
+ const rows = [];
35
+ for (const [key, value] of Object.entries(properties ?? {})) {
36
+ if (rows.length >= MAX_TOOLTIP_ROWS)
37
+ break;
38
+ if (value === null || value === undefined)
39
+ continue;
40
+ const text = String(value);
41
+ rows.push([
42
+ key,
43
+ text.length > MAX_TOOLTIP_VALUE_CHARS
44
+ ? `${text.slice(0, MAX_TOOLTIP_VALUE_CHARS - 1)}…`
45
+ : text,
46
+ ]);
47
+ }
48
+ return rows;
49
+ }
50
+ export function nodeTooltip(node, styleMap) {
51
+ return tooltipElement(describeNode(node, styleMap.styleForNode(node).caption), previewRows(node.properties));
52
+ }
53
+ export function edgeTooltip(edge) {
54
+ return tooltipElement(describeEdge(edge), previewRows(edge.properties));
55
+ }
@@ -0,0 +1,94 @@
1
+ import { type ReactNode, type Ref } from "react";
2
+ import { type GraphEdge, type GraphNode, type GraphPayload } from "./model.js";
3
+ import { type GraphModelLimits } from "./use-graph-model.js";
4
+ import { type GraphStyling } from "./use-graph-styling.js";
5
+ export interface GraphViewHandle {
6
+ zoomToFit: (durationMs?: number) => void;
7
+ centerOn: (nodeId: string, durationMs?: number) => void;
8
+ /** Smallest pan that brings a node into view; a no-op if it already is. */
9
+ reveal: (nodeId: string, durationMs?: number) => void;
10
+ search: (term: string) => void;
11
+ /**
12
+ * Select a node by id, or clear with `null`. Returns whether it worked:
13
+ * a node in the payload can still be absent from what is RENDERED, because
14
+ * `selectConnectedCore` caps a large graph — and a caller that assumed
15
+ * success would tell the user about a node that is not on screen.
16
+ */
17
+ select: (nodeId: string | null) => boolean;
18
+ unpinAll: () => void;
19
+ /**
20
+ * What the view is actually showing — the single honest answer to "what is
21
+ * on screen", which is why anything REPORTING on the graph reads it.
22
+ *
23
+ * `nodes`/`edges` are what is DRAWN: the payload capped to the connected
24
+ * core, then filtered by the legend. Counting the payload instead described
25
+ * a graph the user was not looking at, in two directions at once — the
26
+ * agent naming nodes the cap had dropped, and a hidden label still counted.
27
+ *
28
+ * It also exists because the view OUTLIVES the workspace it publishes to.
29
+ * Closing the tool panel disconnects the workspace but does not unmount this
30
+ * component, so on reopening, the drawer and the search box still show what
31
+ * they showed — while the change callbacks, which fire only on change, have
32
+ * nothing to report. A host reconnecting reads this instead of guessing.
33
+ */
34
+ getViewState: () => GraphViewState_Reported;
35
+ }
36
+ /** What {@link GraphViewHandle.getViewState} reports. */
37
+ export interface GraphViewState_Reported {
38
+ selection: GraphNode | null;
39
+ /** A relationship can be selected too, and the drawer shows it. */
40
+ selectedEdge: GraphEdge | null;
41
+ query: string;
42
+ hiddenLabels: string[];
43
+ /** Whether the selected node is among the DRAWN nodes. False when none. */
44
+ selectionDrawn: boolean;
45
+ /** Nodes actually drawn: capped to the connected core, legend filter applied. */
46
+ nodes: GraphNode[];
47
+ /** Relationships actually drawn, re-filtered against the surviving nodes. */
48
+ edges: GraphEdge[];
49
+ /** Whether anything was dropped to get here — the cap, or a dangling edge. */
50
+ lossy: boolean;
51
+ }
52
+ export interface GraphViewProps {
53
+ data: GraphPayload;
54
+ /** `inline` is the compact card form; `full` adds the details drawer. */
55
+ variant?: "inline" | "full";
56
+ height?: number | string;
57
+ className?: string;
58
+ styling?: GraphStyling;
59
+ limits?: GraphModelLimits;
60
+ /**
61
+ * Called when the user asks to expand a node (double-click, or the
62
+ * inspector's Expand button). The host runs whatever query that means and
63
+ * feeds a larger payload back in through `data` — the view never queries.
64
+ */
65
+ onExpandNode?: (node: GraphNode) => void;
66
+ onSelectionChange?: (node: GraphNode | null) => void;
67
+ /** Fires when the search term changes, whoever changed it. */
68
+ onQueryChange?: (query: string) => void;
69
+ /**
70
+ * Fires when the legend filter changes: the labels currently switched OFF.
71
+ *
72
+ * Hosts publish it so anything REPORTING on the graph agrees with what is
73
+ * drawn — a hidden label's nodes and relationships are not on screen, and
74
+ * counting them describes a screen nobody is looking at.
75
+ */
76
+ onFilterChange?: (hiddenLabels: readonly string[]) => void;
77
+ /**
78
+ * Extra footer actions for the details drawer, rendered for the selected
79
+ * node. The host owns them — the view has no opinion about what "pin to
80
+ * chat" or "open the record" mean.
81
+ */
82
+ inspectorActions?: (node: GraphNode) => ReactNode;
83
+ /** Render the table instead of the canvas. Used by tests and print. */
84
+ forceTable?: boolean;
85
+ handleRef?: Ref<GraphViewHandle>;
86
+ }
87
+ /**
88
+ * An interactive, force-directed graph.
89
+ *
90
+ * Source-agnostic by construction: it takes a {@link GraphPayload} and knows
91
+ * nothing about where the nodes came from. Expansion is a callback, so the view
92
+ * never issues a query of its own.
93
+ */
94
+ export declare function GraphView({ data, variant, height, className, styling, limits, onExpandNode, onSelectionChange, onQueryChange, onFilterChange, inspectorActions, forceTable, handleRef, }: GraphViewProps): import("react/jsx-runtime").JSX.Element;