@schlessera/brain-ui-react 0.6.2 → 0.7.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/dist/components/graph/graph-canvas.d.ts +20 -0
- package/dist/components/graph/graph-canvas.d.ts.map +1 -0
- package/dist/components/graph/graph-canvas.js +310 -0
- package/dist/components/graph/graph-canvas.js.map +1 -0
- package/dist/components/graph/graph-controls.d.ts +8 -0
- package/dist/components/graph/graph-controls.d.ts.map +1 -0
- package/dist/components/graph/graph-controls.js +132 -0
- package/dist/components/graph/graph-controls.js.map +1 -0
- package/dist/components/graph/graph-empty-state.d.ts +15 -0
- package/dist/components/graph/graph-empty-state.d.ts.map +1 -0
- package/dist/components/graph/graph-empty-state.js +21 -0
- package/dist/components/graph/graph-empty-state.js.map +1 -0
- package/dist/components/graph/graph-page.d.ts +7 -0
- package/dist/components/graph/graph-page.d.ts.map +1 -0
- package/dist/components/graph/graph-page.js +400 -0
- package/dist/components/graph/graph-page.js.map +1 -0
- package/dist/components/graph/lib/graph-helpers.d.ts +90 -0
- package/dist/components/graph/lib/graph-helpers.d.ts.map +1 -0
- package/dist/components/graph/lib/graph-helpers.js +217 -0
- package/dist/components/graph/lib/graph-helpers.js.map +1 -0
- package/dist/components/graph/node-popover.d.ts +11 -0
- package/dist/components/graph/node-popover.d.ts.map +1 -0
- package/dist/components/graph/node-popover.js +38 -0
- package/dist/components/graph/node-popover.js.map +1 -0
- package/dist/components/graph/use-graph-theme.d.ts +16 -0
- package/dist/components/graph/use-graph-theme.d.ts.map +1 -0
- package/dist/components/graph/use-graph-theme.js +36 -0
- package/dist/components/graph/use-graph-theme.js.map +1 -0
- package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -1
- package/dist/components/layout/mobile-tab-bar.js +16 -5
- package/dist/components/layout/mobile-tab-bar.js.map +1 -1
- package/dist/components/layout/side-rail.d.ts.map +1 -1
- package/dist/components/layout/side-rail.js +14 -4
- package/dist/components/layout/side-rail.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/stores/graph-store.d.ts +85 -0
- package/dist/stores/graph-store.d.ts.map +1 -0
- package/dist/stores/graph-store.js +236 -0
- package/dist/stores/graph-store.js.map +1 -0
- package/dist/stores/ui-store.d.ts +5 -0
- package/dist/stores/ui-store.d.ts.map +1 -1
- package/dist/stores/ui-store.js +2 -0
- package/dist/stores/ui-store.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/voice/asr-deepgram.d.ts.map +1 -1
- package/dist/voice/asr-deepgram.js +7 -2
- package/dist/voice/asr-deepgram.js.map +1 -1
- package/package.json +5 -2
- package/src/components/graph/graph-canvas.tsx +366 -0
- package/src/components/graph/graph-controls.tsx +436 -0
- package/src/components/graph/graph-empty-state.tsx +45 -0
- package/src/components/graph/graph-page.tsx +1045 -0
- package/src/components/graph/lib/graph-helpers.ts +288 -0
- package/src/components/graph/node-popover.tsx +124 -0
- package/src/components/graph/use-graph-theme.ts +46 -0
- package/src/components/layout/mobile-tab-bar.tsx +26 -3
- package/src/components/layout/side-rail.tsx +28 -4
- package/src/index.ts +3 -1
- package/src/stores/graph-store.ts +345 -0
- package/src/stores/ui-store.ts +8 -0
- package/src/voice/asr-deepgram.ts +7 -2
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rendering seam: this file is the ONLY module in the package that imports
|
|
3
|
+
* sigma or graphology. Everything it receives is renderer-agnostic (plain
|
|
4
|
+
* nodes/edges/positions + callbacks), so swapping the renderer later touches
|
|
5
|
+
* this file alone.
|
|
6
|
+
*
|
|
7
|
+
* Loaded via React.lazy from graph-page, so sigma lands in its own chunk and
|
|
8
|
+
* the main bundle stays untouched.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useRef } from "react";
|
|
11
|
+
import Graph from "graphology";
|
|
12
|
+
import Sigma from "sigma";
|
|
13
|
+
import forceAtlas2 from "graphology-layout-forceatlas2";
|
|
14
|
+
import type {
|
|
15
|
+
GraphEdgePayload,
|
|
16
|
+
GraphNodePayload,
|
|
17
|
+
GraphSubgraphResponse,
|
|
18
|
+
} from "@schlessera/brain-ui-sdk/protocol";
|
|
19
|
+
import {
|
|
20
|
+
labelSet,
|
|
21
|
+
nodeSize,
|
|
22
|
+
radialLayout,
|
|
23
|
+
type SizeBy,
|
|
24
|
+
} from "./lib/graph-helpers.js";
|
|
25
|
+
import { useGraphTheme } from "./use-graph-theme.js";
|
|
26
|
+
|
|
27
|
+
export interface GraphCanvasProps {
|
|
28
|
+
data: GraphSubgraphResponse;
|
|
29
|
+
layout: "fixed" | "radial" | "force";
|
|
30
|
+
selectedId: number | null;
|
|
31
|
+
hoveredId: number | null;
|
|
32
|
+
matchIds: ReadonlySet<number>;
|
|
33
|
+
sizeBy?: SizeBy;
|
|
34
|
+
/** Radial mode: draw this many distance rings (with hop labels) under the graph. */
|
|
35
|
+
rings?: number;
|
|
36
|
+
/** Mode-specific node color (community, distance, …). */
|
|
37
|
+
nodeColor: (node: GraphNodePayload) => string;
|
|
38
|
+
/** Optional per-edge color override (e.g. backlinks into the local center). */
|
|
39
|
+
edgeColor?: (edge: GraphEdgePayload) => string | undefined;
|
|
40
|
+
onSelect: (id: number | null) => void;
|
|
41
|
+
onHover: (id: number | null) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Force-layout budget: enough to settle an ego graph, cheap enough for a phone. */
|
|
45
|
+
function forceIterations(nodeCount: number): number {
|
|
46
|
+
if (nodeCount <= 150) return 300;
|
|
47
|
+
if (nodeCount <= 500) return 150;
|
|
48
|
+
return 60;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default function GraphCanvas({
|
|
52
|
+
data,
|
|
53
|
+
layout,
|
|
54
|
+
selectedId,
|
|
55
|
+
hoveredId,
|
|
56
|
+
matchIds,
|
|
57
|
+
sizeBy = "degree",
|
|
58
|
+
rings = 0,
|
|
59
|
+
nodeColor,
|
|
60
|
+
edgeColor,
|
|
61
|
+
onSelect,
|
|
62
|
+
onHover,
|
|
63
|
+
}: GraphCanvasProps) {
|
|
64
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
const ringCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
66
|
+
const sigmaRef = useRef<Sigma | null>(null);
|
|
67
|
+
const theme = useGraphTheme();
|
|
68
|
+
const ringCount = layout === "radial" ? rings : 0;
|
|
69
|
+
|
|
70
|
+
// State the reducers read without re-instantiating sigma.
|
|
71
|
+
const interactionRef = useRef({
|
|
72
|
+
selectedId,
|
|
73
|
+
hoveredId,
|
|
74
|
+
matchIds,
|
|
75
|
+
forced: new Set<number>(),
|
|
76
|
+
neighborsOfActive: new Set<string>(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Latest callbacks for the event handlers registered once at mount.
|
|
80
|
+
const callbacksRef = useRef({ onSelect, onHover });
|
|
81
|
+
callbacksRef.current = { onSelect, onHover };
|
|
82
|
+
|
|
83
|
+
// Mount sigma once.
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const container = containerRef.current;
|
|
86
|
+
if (!container) return;
|
|
87
|
+
const graph = new Graph({ type: "directed", multi: false });
|
|
88
|
+
const renderer = new Sigma(graph, container, {
|
|
89
|
+
allowInvalidContainer: true,
|
|
90
|
+
renderLabels: true,
|
|
91
|
+
labelFont: "Plus Jakarta Sans, system-ui, sans-serif",
|
|
92
|
+
labelSize: 11,
|
|
93
|
+
labelColor: { color: theme.label },
|
|
94
|
+
labelRenderedSizeThreshold: 7,
|
|
95
|
+
defaultEdgeType: "arrow",
|
|
96
|
+
minCameraRatio: 0.05,
|
|
97
|
+
maxCameraRatio: 6,
|
|
98
|
+
stagePadding: 40,
|
|
99
|
+
nodeReducer: (node, attrs) => {
|
|
100
|
+
const id = Number(node);
|
|
101
|
+
const s = interactionRef.current;
|
|
102
|
+
const active = s.hoveredId ?? s.selectedId;
|
|
103
|
+
const out = { ...attrs } as typeof attrs & {
|
|
104
|
+
forceLabel?: boolean;
|
|
105
|
+
zIndex?: number;
|
|
106
|
+
};
|
|
107
|
+
out.forceLabel = s.forced.has(id);
|
|
108
|
+
if (s.matchIds.size > 0 && s.matchIds.has(id)) {
|
|
109
|
+
out.color = theme.nodeSelected;
|
|
110
|
+
out.zIndex = 2;
|
|
111
|
+
}
|
|
112
|
+
if (id === s.selectedId || id === s.hoveredId) {
|
|
113
|
+
out.color = theme.nodeSelected;
|
|
114
|
+
out.highlighted = true;
|
|
115
|
+
out.zIndex = 3;
|
|
116
|
+
} else if (active !== null && !s.neighborsOfActive.has(node)) {
|
|
117
|
+
// Fade everything not adjacent to the active node.
|
|
118
|
+
out.color = theme.edge;
|
|
119
|
+
out.label = undefined;
|
|
120
|
+
out.forceLabel = false;
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
},
|
|
124
|
+
edgeReducer: (edge, attrs) => {
|
|
125
|
+
const s = interactionRef.current;
|
|
126
|
+
const active = s.hoveredId ?? s.selectedId;
|
|
127
|
+
const out = { ...attrs } as typeof attrs & { hidden?: boolean };
|
|
128
|
+
if (active !== null) {
|
|
129
|
+
const g = sigmaRef.current?.getGraph();
|
|
130
|
+
if (g) {
|
|
131
|
+
const activeKey = String(active);
|
|
132
|
+
const touchesActive =
|
|
133
|
+
g.source(edge) === activeKey || g.target(edge) === activeKey;
|
|
134
|
+
if (touchesActive) {
|
|
135
|
+
out.color = theme.edgeHighlight;
|
|
136
|
+
out.size = Math.max((attrs.size as number | undefined) ?? 1, 1.5);
|
|
137
|
+
} else {
|
|
138
|
+
out.hidden = true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
renderer.on("clickNode", ({ node }) => callbacksRef.current.onSelect(Number(node)));
|
|
147
|
+
renderer.on("clickStage", () => callbacksRef.current.onSelect(null));
|
|
148
|
+
renderer.on("enterNode", ({ node }) => callbacksRef.current.onHover(Number(node)));
|
|
149
|
+
renderer.on("leaveNode", () => callbacksRef.current.onHover(null));
|
|
150
|
+
|
|
151
|
+
sigmaRef.current = renderer;
|
|
152
|
+
return () => {
|
|
153
|
+
renderer.kill();
|
|
154
|
+
sigmaRef.current = null;
|
|
155
|
+
};
|
|
156
|
+
// Theme is read once per mount (dark-only app).
|
|
157
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
158
|
+
}, []);
|
|
159
|
+
|
|
160
|
+
// (Re)build the graph when the scene changes.
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const renderer = sigmaRef.current;
|
|
163
|
+
if (!renderer) return;
|
|
164
|
+
const graph = renderer.getGraph();
|
|
165
|
+
|
|
166
|
+
// Keep positions of survivors so expansion merges don't reshuffle the scene.
|
|
167
|
+
const previous = new Map<string, { x: number; y: number }>();
|
|
168
|
+
graph.forEachNode((node, attrs) =>
|
|
169
|
+
previous.set(node, { x: attrs.x as number, y: attrs.y as number })
|
|
170
|
+
);
|
|
171
|
+
graph.clear();
|
|
172
|
+
|
|
173
|
+
const radial = layout === "radial" ? radialLayout(data.nodes) : null;
|
|
174
|
+
|
|
175
|
+
data.nodes.forEach((node, i) => {
|
|
176
|
+
let x: number;
|
|
177
|
+
let y: number;
|
|
178
|
+
if (layout === "fixed" && node.x !== undefined && node.y !== undefined) {
|
|
179
|
+
x = node.x;
|
|
180
|
+
y = node.y;
|
|
181
|
+
} else if (radial) {
|
|
182
|
+
const p = radial.get(node.id)!;
|
|
183
|
+
x = p.x;
|
|
184
|
+
y = p.y;
|
|
185
|
+
} else {
|
|
186
|
+
const kept = previous.get(String(node.id));
|
|
187
|
+
if (kept) {
|
|
188
|
+
({ x, y } = kept);
|
|
189
|
+
} else {
|
|
190
|
+
// Deterministic circle seed; FA2 takes it from here.
|
|
191
|
+
const angle = (2 * Math.PI * i) / data.nodes.length;
|
|
192
|
+
x = Math.cos(angle);
|
|
193
|
+
y = Math.sin(angle);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
graph.addNode(String(node.id), {
|
|
197
|
+
x,
|
|
198
|
+
y,
|
|
199
|
+
// Size and color are (re)applied by the restyle effect below; seeding
|
|
200
|
+
// them here just avoids a one-frame flash of defaults.
|
|
201
|
+
size: nodeSize(node, sizeBy),
|
|
202
|
+
color: nodeColor(node),
|
|
203
|
+
label: node.title || node.path,
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
for (const edge of data.edges) {
|
|
208
|
+
const source = String(edge.source);
|
|
209
|
+
const target = String(edge.target);
|
|
210
|
+
if (!graph.hasNode(source) || !graph.hasNode(target)) continue;
|
|
211
|
+
if (graph.hasEdge(source, target)) continue;
|
|
212
|
+
graph.addEdge(source, target, {
|
|
213
|
+
color: edgeColor?.(edge) ?? theme.edge,
|
|
214
|
+
size: 1,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (layout === "force" && graph.order > 2) {
|
|
219
|
+
const settings = forceAtlas2.inferSettings(graph);
|
|
220
|
+
forceAtlas2.assign(graph, {
|
|
221
|
+
iterations: forceIterations(graph.order),
|
|
222
|
+
settings: { ...settings, barnesHutOptimize: graph.order > 300 },
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
renderer.getCamera().animatedReset({ duration: 250 });
|
|
227
|
+
renderer.refresh();
|
|
228
|
+
// Styling props (nodeColor/edgeColor/sizeBy) are deliberately NOT deps:
|
|
229
|
+
// the restyle effect below reapplies them in place, so a display-only
|
|
230
|
+
// change never re-runs layout or resets the camera.
|
|
231
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
232
|
+
}, [data, layout]);
|
|
233
|
+
|
|
234
|
+
// Restyle in place when a display-only prop changes (color-by, size-by).
|
|
235
|
+
// Runs after every rebuild too, which is idempotent and keeps one source of
|
|
236
|
+
// truth for what the current colorFn/sizeBy say a node should look like.
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
const renderer = sigmaRef.current;
|
|
239
|
+
if (!renderer) return;
|
|
240
|
+
const graph = renderer.getGraph();
|
|
241
|
+
for (const node of data.nodes) {
|
|
242
|
+
const key = String(node.id);
|
|
243
|
+
if (!graph.hasNode(key)) continue;
|
|
244
|
+
graph.setNodeAttribute(key, "color", nodeColor(node));
|
|
245
|
+
graph.setNodeAttribute(key, "size", nodeSize(node, sizeBy));
|
|
246
|
+
}
|
|
247
|
+
for (const edge of data.edges) {
|
|
248
|
+
const source = String(edge.source);
|
|
249
|
+
const target = String(edge.target);
|
|
250
|
+
if (!graph.hasEdge(source, target)) continue;
|
|
251
|
+
graph.setEdgeAttribute(source, target, "color", edgeColor?.(edge) ?? theme.edge);
|
|
252
|
+
}
|
|
253
|
+
renderer.refresh({ skipIndexation: true });
|
|
254
|
+
}, [data, nodeColor, edgeColor, sizeBy, theme]);
|
|
255
|
+
|
|
256
|
+
// Interaction + label policy updates (no graph rebuild).
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
const renderer = sigmaRef.current;
|
|
259
|
+
if (!renderer) return;
|
|
260
|
+
const state = interactionRef.current;
|
|
261
|
+
state.selectedId = selectedId;
|
|
262
|
+
state.hoveredId = hoveredId;
|
|
263
|
+
state.matchIds = matchIds;
|
|
264
|
+
|
|
265
|
+
const active = hoveredId ?? selectedId;
|
|
266
|
+
state.neighborsOfActive = new Set<string>();
|
|
267
|
+
if (active !== null) {
|
|
268
|
+
const graph = renderer.getGraph();
|
|
269
|
+
const key = String(active);
|
|
270
|
+
if (graph.hasNode(key)) {
|
|
271
|
+
state.neighborsOfActive.add(key);
|
|
272
|
+
graph.forEachNeighbor(key, (n) => state.neighborsOfActive.add(n));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const recomputeLabels = () => {
|
|
277
|
+
state.forced = labelSet({
|
|
278
|
+
nodes: data.nodes,
|
|
279
|
+
selectedId,
|
|
280
|
+
hoveredId,
|
|
281
|
+
matchIds,
|
|
282
|
+
cameraRatio: renderer.getCamera().ratio,
|
|
283
|
+
});
|
|
284
|
+
renderer.refresh({ skipIndexation: true });
|
|
285
|
+
};
|
|
286
|
+
recomputeLabels();
|
|
287
|
+
|
|
288
|
+
// More labels as the user zooms in; throttled to one recompute per frame batch.
|
|
289
|
+
let pending = 0;
|
|
290
|
+
const onCameraUpdate = () => {
|
|
291
|
+
if (pending) return;
|
|
292
|
+
pending = window.setTimeout(() => {
|
|
293
|
+
pending = 0;
|
|
294
|
+
recomputeLabels();
|
|
295
|
+
}, 150);
|
|
296
|
+
};
|
|
297
|
+
renderer.getCamera().on("updated", onCameraUpdate);
|
|
298
|
+
return () => {
|
|
299
|
+
renderer.getCamera().off("updated", onCameraUpdate);
|
|
300
|
+
if (pending) window.clearTimeout(pending);
|
|
301
|
+
};
|
|
302
|
+
}, [data, selectedId, hoveredId, matchIds]);
|
|
303
|
+
|
|
304
|
+
// Distance rings under the radial layout — an underlay canvas redrawn in
|
|
305
|
+
// sync with sigma's camera (the sigma canvases are transparent, so it shows
|
|
306
|
+
// through).
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
const renderer = sigmaRef.current;
|
|
309
|
+
const canvas = ringCanvasRef.current;
|
|
310
|
+
if (!renderer || !canvas || ringCount === 0) return;
|
|
311
|
+
|
|
312
|
+
const draw = () => {
|
|
313
|
+
const dpr = window.devicePixelRatio || 1;
|
|
314
|
+
const { clientWidth, clientHeight } = canvas;
|
|
315
|
+
if (canvas.width !== clientWidth * dpr) canvas.width = clientWidth * dpr;
|
|
316
|
+
if (canvas.height !== clientHeight * dpr) canvas.height = clientHeight * dpr;
|
|
317
|
+
const ctx = canvas.getContext("2d");
|
|
318
|
+
if (!ctx) return;
|
|
319
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
320
|
+
ctx.clearRect(0, 0, clientWidth, clientHeight);
|
|
321
|
+
|
|
322
|
+
const center = renderer.graphToViewport({ x: 0, y: 0 });
|
|
323
|
+
const edge = renderer.graphToViewport({ x: 1, y: 0 });
|
|
324
|
+
const unit = Math.hypot(edge.x - center.x, edge.y - center.y);
|
|
325
|
+
if (!Number.isFinite(unit) || unit <= 0) return;
|
|
326
|
+
|
|
327
|
+
ctx.strokeStyle = theme.edge;
|
|
328
|
+
ctx.fillStyle = theme.labelMuted;
|
|
329
|
+
ctx.font = "10px Plus Jakarta Sans, system-ui, sans-serif";
|
|
330
|
+
ctx.textAlign = "center";
|
|
331
|
+
for (let d = 1; d <= ringCount; d++) {
|
|
332
|
+
const radius = unit * d;
|
|
333
|
+
ctx.globalAlpha = 0.5;
|
|
334
|
+
ctx.beginPath();
|
|
335
|
+
ctx.arc(center.x, center.y, radius, 0, 2 * Math.PI);
|
|
336
|
+
ctx.stroke();
|
|
337
|
+
// Hop label at the top of the ring, skipped once rings get dense.
|
|
338
|
+
if (unit > 28) {
|
|
339
|
+
ctx.globalAlpha = 0.9;
|
|
340
|
+
ctx.fillText(String(d), center.x, center.y - radius - 4);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
ctx.globalAlpha = 1;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
draw();
|
|
347
|
+
renderer.on("afterRender", draw);
|
|
348
|
+
return () => {
|
|
349
|
+
renderer.off("afterRender", draw);
|
|
350
|
+
const ctx = canvas.getContext("2d");
|
|
351
|
+
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
|
352
|
+
};
|
|
353
|
+
}, [ringCount, theme, data]);
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<div aria-hidden="true" className="absolute inset-0">
|
|
357
|
+
{ringCount > 0 && (
|
|
358
|
+
<canvas
|
|
359
|
+
ref={ringCanvasRef}
|
|
360
|
+
className="pointer-events-none absolute inset-0 h-full w-full"
|
|
361
|
+
/>
|
|
362
|
+
)}
|
|
363
|
+
<div ref={containerRef} className="absolute inset-0 touch-none" />
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|