@iloveagents/foundry-web-graph 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.js +1 -0
- package/dist/adapters/neo4j.d.ts +52 -0
- package/dist/adapters/neo4j.js +149 -0
- package/dist/assistant-ui/graph-client-tools.d.ts +11 -0
- package/dist/assistant-ui/graph-client-tools.js +261 -0
- package/dist/assistant-ui/graph-context.d.ts +38 -0
- package/dist/assistant-ui/graph-context.js +113 -0
- package/dist/assistant-ui/graph-panel-content.d.ts +45 -0
- package/dist/assistant-ui/graph-panel-content.js +28 -0
- package/dist/assistant-ui/graph-panel.d.ts +11 -0
- package/dist/assistant-ui/graph-panel.js +121 -0
- package/dist/assistant-ui/graph-provenance.d.ts +17 -0
- package/dist/assistant-ui/graph-provenance.js +31 -0
- package/dist/assistant-ui/graph-result-seeder.d.ts +73 -0
- package/dist/assistant-ui/graph-result-seeder.js +157 -0
- package/dist/assistant-ui/graph-tool-registry.d.ts +25 -0
- package/dist/assistant-ui/graph-tool-registry.js +15 -0
- package/dist/assistant-ui/graph-tool-ui.d.ts +54 -0
- package/dist/assistant-ui/graph-tool-ui.js +77 -0
- package/dist/assistant-ui/index.d.ts +17 -0
- package/dist/assistant-ui/index.js +17 -0
- package/dist/assistant-ui/merge-into-panel.d.ts +21 -0
- package/dist/assistant-ui/merge-into-panel.js +72 -0
- package/dist/assistant-ui/register-graph-panel.d.ts +8 -0
- package/dist/assistant-ui/register-graph-panel.js +18 -0
- package/dist/caption-placement.d.ts +119 -0
- package/dist/caption-placement.js +146 -0
- package/dist/graph-canvas-paint.d.ts +90 -0
- package/dist/graph-canvas-paint.js +186 -0
- package/dist/graph-canvas.d.ts +49 -0
- package/dist/graph-canvas.js +533 -0
- package/dist/graph-inspector.d.ts +42 -0
- package/dist/graph-inspector.js +106 -0
- package/dist/graph-legend.d.ts +19 -0
- package/dist/graph-legend.js +20 -0
- package/dist/graph-notice.d.ts +19 -0
- package/dist/graph-notice.js +30 -0
- package/dist/graph-table.d.ts +28 -0
- package/dist/graph-table.js +57 -0
- package/dist/graph-toolbar.d.ts +22 -0
- package/dist/graph-toolbar.js +8 -0
- package/dist/graph-tooltip.d.ts +4 -0
- package/dist/graph-tooltip.js +55 -0
- package/dist/graph-view.d.ts +94 -0
- package/dist/graph-view.js +338 -0
- package/dist/graph-workspace.d.ts +55 -0
- package/dist/graph-workspace.js +100 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +21 -0
- package/dist/model.d.ts +206 -0
- package/dist/model.js +369 -0
- package/dist/styles.css +97 -0
- package/dist/theme.d.ts +36 -0
- package/dist/theme.js +83 -0
- package/dist/use-element-size.d.ts +13 -0
- package/dist/use-element-size.js +32 -0
- package/dist/use-graph-model.d.ts +101 -0
- package/dist/use-graph-model.js +164 -0
- package/dist/use-graph-styling.d.ts +55 -0
- package/dist/use-graph-styling.js +156 -0
- package/dist/use-graph-theme.d.ts +11 -0
- package/dist/use-graph-theme.js +54 -0
- package/dist/use-graph-view-state.d.ts +42 -0
- package/dist/use-graph-view-state.js +145 -0
- package/package.json +83 -0
|
@@ -0,0 +1,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
|
+
}
|