@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,54 @@
1
+ import { useEffect, useState } from "react";
2
+ import { FALLBACK_THEME, resolveGraphTheme } from "./theme.js";
3
+ const sameTheme = (a, b) => a.foreground === b.foreground &&
4
+ a.mutedForeground === b.mutedForeground &&
5
+ a.background === b.background &&
6
+ a.border === b.border &&
7
+ a.ring === b.ring &&
8
+ a.palette.length === b.palette.length &&
9
+ a.palette.every((colour, index) => colour === b.palette[index]);
10
+ /**
11
+ * Keep {@link GraphTheme} in sync with the DOM.
12
+ *
13
+ * Re-resolves when the `class`, `data-theme` or `style` attribute changes
14
+ * anywhere up the tree — between them that is how every theme switcher in this
15
+ * stack signals a flip (Tailwind's `.dark` class, an explicit `data-theme`, or
16
+ * a runtime layer writing variables inline). Watching the element itself is not
17
+ * enough: the class usually lands on `<html>`, far above.
18
+ */
19
+ export function useGraphTheme(element) {
20
+ const [theme, setTheme] = useState(FALLBACK_THEME);
21
+ useEffect(() => {
22
+ if (!element)
23
+ return;
24
+ // Keep the previous object when nothing resolved differently. `theme` is a
25
+ // dependency of the canvas painters and of `styleMap`, so a fresh object
26
+ // per mutation is a re-render and a full repaint — and `style` mutations
27
+ // are not rare: an ancestor's inline width changes on every frame of a
28
+ // panel drag-resize.
29
+ const sync = () => setTheme((previous) => {
30
+ const next = resolveGraphTheme(element);
31
+ return sameTheme(previous, next) ? previous : next;
32
+ });
33
+ sync();
34
+ if (typeof MutationObserver === "undefined")
35
+ return;
36
+ const observer = new MutationObserver(sync);
37
+ // Observe every ancestor: the theme class can sit on <html>, on a
38
+ // ThemeScope wrapper, or on the container itself.
39
+ //
40
+ // `style` is in the filter because a RUNTIME theme layer never touches a
41
+ // class. `ThemeRuntimeProvider` publishes its variables with
42
+ // `root.style.setProperty(...)`, so switching one changed the DOM while
43
+ // the canvas went on painting the previous palette — until some unrelated
44
+ // class or `data-theme` mutation happened to wake this up.
45
+ for (let node = element; node; node = node.parentElement) {
46
+ observer.observe(node, {
47
+ attributes: true,
48
+ attributeFilter: ["class", "data-theme", "style"],
49
+ });
50
+ }
51
+ return () => observer.disconnect();
52
+ }, [element]);
53
+ return theme;
54
+ }
@@ -0,0 +1,42 @@
1
+ import { type GraphEdge, type GraphNode, type GraphPayload } from "./model.js";
2
+ /** What the user currently has selected. `null` when nothing is selected. */
3
+ export interface GraphSelection {
4
+ node: GraphNode | null;
5
+ edge: GraphEdge | null;
6
+ }
7
+ export interface GraphViewState {
8
+ selection: GraphSelection;
9
+ selectNode: (node: GraphNode | null) => void;
10
+ selectEdge: (edge: GraphEdge | null) => void;
11
+ clearSelection: () => void;
12
+ hoveredId: string | null;
13
+ setHoveredId: (id: string | null) => void;
14
+ /** Lowercased search term. Empty string = no search active. */
15
+ query: string;
16
+ setQuery: (value: string) => void;
17
+ /** Ids matching the current query. Empty set when the query is empty. */
18
+ matchedIds: Set<string>;
19
+ /** Labels the user has hidden by clicking the legend. */
20
+ hiddenLabels: Set<string>;
21
+ toggleLabel: (label: string) => void;
22
+ showAllLabels: () => void;
23
+ /** Ids the user pinned by dragging. Pinned nodes hold their position. */
24
+ pinnedIds: Set<string>;
25
+ togglePinned: (id: string) => void;
26
+ unpinAll: () => void;
27
+ }
28
+ /**
29
+ * Search across caption AND every property value.
30
+ *
31
+ * Matching only the caption looks broken the moment someone searches for an
32
+ * id, an email, or a status that the caption rule did not pick — which is most
33
+ * of what people actually search a graph for.
34
+ */
35
+ declare function matchNodes(nodes: GraphNode[], query: string, captionOf: (n: GraphNode) => string): Set<string>;
36
+ /**
37
+ * All the interaction state a graph view carries: selection, hover, search,
38
+ * legend filtering and pinning. Kept out of the view component so the view
39
+ * stays a renderer and this stays unit-testable without a canvas.
40
+ */
41
+ export declare function useGraphViewState(payload: GraphPayload, captionOf: (node: GraphNode) => string): GraphViewState;
42
+ export { matchNodes as __matchNodesForTest };
@@ -0,0 +1,145 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { deepEqual } from "./model.js";
3
+ const EMPTY_SELECTION = { node: null, edge: null };
4
+ /**
5
+ * Search across caption AND every property value.
6
+ *
7
+ * Matching only the caption looks broken the moment someone searches for an
8
+ * id, an email, or a status that the caption rule did not pick — which is most
9
+ * of what people actually search a graph for.
10
+ */
11
+ function matchNodes(nodes, query, captionOf) {
12
+ const needle = query.trim().toLowerCase();
13
+ if (needle.length === 0)
14
+ return new Set();
15
+ const out = new Set();
16
+ for (const node of nodes) {
17
+ if (captionOf(node).toLowerCase().includes(needle)) {
18
+ out.add(node.id);
19
+ continue;
20
+ }
21
+ if (node.labels?.some((l) => l.toLowerCase().includes(needle))) {
22
+ out.add(node.id);
23
+ continue;
24
+ }
25
+ for (const value of Object.values(node.properties ?? {})) {
26
+ if (value == null)
27
+ continue;
28
+ if (String(value).toLowerCase().includes(needle)) {
29
+ out.add(node.id);
30
+ break;
31
+ }
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+ /**
37
+ * All the interaction state a graph view carries: selection, hover, search,
38
+ * legend filtering and pinning. Kept out of the view component so the view
39
+ * stays a renderer and this stays unit-testable without a canvas.
40
+ */
41
+ export function useGraphViewState(payload, captionOf) {
42
+ const [selection, setSelection] = useState(EMPTY_SELECTION);
43
+ const [hoveredId, setHoveredId] = useState(null);
44
+ const [query, setQuery] = useState("");
45
+ const [hiddenLabels, setHiddenLabels] = useState(() => new Set());
46
+ const [pinnedIds, setPinnedIds] = useState(() => new Set());
47
+ const matchedIds = useMemo(() => matchNodes(payload.nodes, query, captionOf), [payload.nodes, query, captionOf]);
48
+ /**
49
+ * A selection outlives its node otherwise.
50
+ *
51
+ * `graph_show` replacing an open graph reuses the renderer deliberately —
52
+ * that is what makes a refined query read as a change — but the drawer would
53
+ * go on describing a node the canvas no longer contains, with an Expand
54
+ * button that queries something nobody can see.
55
+ *
56
+ * Only DROPS; it never swaps in the equivalent node from the new payload.
57
+ * Hosts pass `data` as a fresh object on many renders, and replacing the
58
+ * selection with an equal-but-different object every time is a render loop.
59
+ */
60
+ useEffect(() => {
61
+ setSelection((prev) => {
62
+ if (prev.node && !payload.nodes.some((node) => node.id === prev.node?.id)) {
63
+ return EMPTY_SELECTION;
64
+ }
65
+ if (prev.edge) {
66
+ const fresh = payload.edges.find((edge) => edge.id === prev.edge?.id);
67
+ if (!fresh)
68
+ return EMPTY_SELECTION;
69
+ // Edges, unlike nodes, are REBUILT on every payload —
70
+ // `reconcileGraphModel` reuses node objects to keep their positions
71
+ // but has no such reason for links. So a merge that rewrote this
72
+ // relationship's type or properties left the drawer describing the
73
+ // version before the write, with no way to notice short of
74
+ // reselecting it. Swapped only when the content actually differs, so
75
+ // a host handing us a fresh `data` object on every render cannot turn
76
+ // this into a loop.
77
+ if (!deepEqual(fresh, prev.edge)) {
78
+ return { node: null, edge: fresh };
79
+ }
80
+ }
81
+ return prev;
82
+ });
83
+ // Pins are ids, and an id outlives the node it named. `graph_show`
84
+ // replacing the graph left the pin in the set: the toolbar counted a pin nothing shows, Unpin
85
+ // All had something to clear that was not there, and — the part that is
86
+ // not merely cosmetic — a LATER graph containing the same id got that
87
+ // node frozen on arrival, by a pin the user made for a different graph.
88
+ setPinnedIds((prev) => {
89
+ if (prev.size === 0)
90
+ return prev;
91
+ const present = new Set(payload.nodes.map((node) => node.id));
92
+ const next = new Set([...prev].filter((id) => present.has(id)));
93
+ // Same set, same object: this runs on every payload, and a fresh Set
94
+ // each time would re-run every consumer that depends on it.
95
+ return next.size === prev.size ? prev : next;
96
+ });
97
+ }, [payload]);
98
+ const selectNode = useCallback((node) => {
99
+ setSelection({ node, edge: null });
100
+ }, []);
101
+ const selectEdge = useCallback((edge) => {
102
+ setSelection({ node: null, edge });
103
+ }, []);
104
+ const clearSelection = useCallback(() => setSelection(EMPTY_SELECTION), []);
105
+ const toggleLabel = useCallback((label) => {
106
+ setHiddenLabels((prev) => {
107
+ const next = new Set(prev);
108
+ if (next.has(label))
109
+ next.delete(label);
110
+ else
111
+ next.add(label);
112
+ return next;
113
+ });
114
+ }, []);
115
+ const showAllLabels = useCallback(() => setHiddenLabels(new Set()), []);
116
+ const togglePinned = useCallback((id) => {
117
+ setPinnedIds((prev) => {
118
+ const next = new Set(prev);
119
+ if (next.has(id))
120
+ next.delete(id);
121
+ else
122
+ next.add(id);
123
+ return next;
124
+ });
125
+ }, []);
126
+ const unpinAll = useCallback(() => setPinnedIds(new Set()), []);
127
+ return {
128
+ selection,
129
+ selectNode,
130
+ selectEdge,
131
+ clearSelection,
132
+ hoveredId,
133
+ setHoveredId,
134
+ query,
135
+ setQuery,
136
+ matchedIds,
137
+ hiddenLabels,
138
+ toggleLabel,
139
+ showAllLabels,
140
+ pinnedIds,
141
+ togglePinned,
142
+ unpinAll,
143
+ };
144
+ }
145
+ export { matchNodes as __matchNodesForTest };
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@iloveagents/foundry-web-graph",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "description": "Graph visualization for Foundry UI — a Bloom-like force-directed graph view, a source-agnostic graph payload contract, and an optional assistant-ui tool card + panel renderer.",
6
+ "keywords": [
7
+ "react",
8
+ "graph",
9
+ "visualization",
10
+ "force-directed",
11
+ "neo4j",
12
+ "cypher",
13
+ "foundry-ui",
14
+ "assistant-ui"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/iLoveAgents/foundry-ui.git",
19
+ "directory": "packages/web-graph"
20
+ },
21
+ "homepage": "https://github.com/iLoveAgents/foundry-ui/tree/main/packages/web-graph#readme",
22
+ "bugs": "https://github.com/iLoveAgents/foundry-ui/issues",
23
+ "type": "module",
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ },
31
+ "./adapters/neo4j": {
32
+ "types": "./dist/adapters/index.d.ts",
33
+ "import": "./dist/adapters/index.js"
34
+ },
35
+ "./assistant-ui": {
36
+ "types": "./dist/assistant-ui/index.d.ts",
37
+ "import": "./dist/assistant-ui/index.js"
38
+ },
39
+ "./styles.css": "./dist/styles.css"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "peerDependencies": {
50
+ "react": "^19.0.0",
51
+ "react-dom": "^19.0.0",
52
+ "lucide-react": ">=0.400.0",
53
+ "@assistant-ui/react": "^0.15.1",
54
+ "zustand": "^5.0.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "@assistant-ui/react": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "dependencies": {
62
+ "react-force-graph-2d": "^1.29.1",
63
+ "@iloveagents/foundry-agent": "^0.24.0",
64
+ "@iloveagents/foundry-web-ui": "^0.24.0",
65
+ "@iloveagents/foundry-web-primitives": "^0.24.0"
66
+ },
67
+ "devDependencies": {
68
+ "@assistant-ui/react": "^0.15.1",
69
+ "@types/react": "^19.2.2",
70
+ "jsdom": "^28.1.0",
71
+ "lucide-react": ">=0.400.0",
72
+ "react": "^19.0.0",
73
+ "react-dom": "^19.0.0",
74
+ "typescript": "~5.9.3",
75
+ "vitest": "^4.1.4",
76
+ "zustand": "^5.0.0"
77
+ },
78
+ "scripts": {
79
+ "build": "tsc -p tsconfig.build.json && node ../../scripts/fix-dts-extensions.mjs dist && cp src/styles.css dist/styles.css",
80
+ "test:unit": "vitest run",
81
+ "typecheck": "tsc --noEmit"
82
+ }
83
+ }