@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
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design tokens → concrete colours for the canvas.
|
|
3
|
+
*
|
|
4
|
+
* `react-force-graph-2d` paints to a `<canvas>`, and a canvas cannot read CSS
|
|
5
|
+
* custom properties: `ctx.fillStyle = "var(--foreground)"` silently paints
|
|
6
|
+
* black. So every colour the renderer uses has to be resolved from the DOM
|
|
7
|
+
* first, and re-resolved when the theme flips — otherwise a light-mode graph
|
|
8
|
+
* stays light-on-light after the user switches to dark.
|
|
9
|
+
*
|
|
10
|
+
* Tokens come from the host's stylesheet (`@iloveagents/foundry-web-ui/styles.css`
|
|
11
|
+
* plus this package's `styles.css`), so a themed app gets a themed graph for
|
|
12
|
+
* free.
|
|
13
|
+
*/
|
|
14
|
+
/** Tokens this package resolves. Values are whatever the host's CSS says. */
|
|
15
|
+
export interface GraphTheme {
|
|
16
|
+
foreground: string;
|
|
17
|
+
mutedForeground: string;
|
|
18
|
+
background: string;
|
|
19
|
+
border: string;
|
|
20
|
+
ring: string;
|
|
21
|
+
/** Categorical node palette, in assignment order. */
|
|
22
|
+
palette: string[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Last-resort palette, used when this package's `styles.css` was not imported.
|
|
26
|
+
* Plain hex on purpose: it must not itself depend on a token resolving.
|
|
27
|
+
*/
|
|
28
|
+
export declare const FALLBACK_THEME: GraphTheme;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the theme against a mounted element.
|
|
31
|
+
*
|
|
32
|
+
* Takes an element rather than `document.documentElement` so a graph inside a
|
|
33
|
+
* scoped theme (the shell's `ThemeScope`, a preview pane) picks up that scope's
|
|
34
|
+
* tokens instead of the page's.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveGraphTheme(element: Element | null): GraphTheme;
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design tokens → concrete colours for the canvas.
|
|
3
|
+
*
|
|
4
|
+
* `react-force-graph-2d` paints to a `<canvas>`, and a canvas cannot read CSS
|
|
5
|
+
* custom properties: `ctx.fillStyle = "var(--foreground)"` silently paints
|
|
6
|
+
* black. So every colour the renderer uses has to be resolved from the DOM
|
|
7
|
+
* first, and re-resolved when the theme flips — otherwise a light-mode graph
|
|
8
|
+
* stays light-on-light after the user switches to dark.
|
|
9
|
+
*
|
|
10
|
+
* Tokens come from the host's stylesheet (`@iloveagents/foundry-web-ui/styles.css`
|
|
11
|
+
* plus this package's `styles.css`), so a themed app gets a themed graph for
|
|
12
|
+
* free.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Preferred palette: this package's own tokens (`styles.css`), which give
|
|
16
|
+
* eight well-separated hues.
|
|
17
|
+
*/
|
|
18
|
+
const PALETTE_TOKENS = [
|
|
19
|
+
"--graph-1",
|
|
20
|
+
"--graph-2",
|
|
21
|
+
"--graph-3",
|
|
22
|
+
"--graph-4",
|
|
23
|
+
"--graph-5",
|
|
24
|
+
"--graph-6",
|
|
25
|
+
"--graph-7",
|
|
26
|
+
"--graph-8",
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* Second choice: the chart tokens every Foundry UI app already has from
|
|
30
|
+
* `@iloveagents/foundry-web-ui/styles.css`. Five hues instead of eight, but a
|
|
31
|
+
* themed app that forgot to import this package's stylesheet still gets colours
|
|
32
|
+
* that match its brand rather than the generic fallback below.
|
|
33
|
+
*/
|
|
34
|
+
const CHART_PALETTE_TOKENS = [
|
|
35
|
+
"--chart-1",
|
|
36
|
+
"--chart-2",
|
|
37
|
+
"--chart-3",
|
|
38
|
+
"--chart-4",
|
|
39
|
+
"--chart-5",
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* Last-resort palette, used when this package's `styles.css` was not imported.
|
|
43
|
+
* Plain hex on purpose: it must not itself depend on a token resolving.
|
|
44
|
+
*/
|
|
45
|
+
export const FALLBACK_THEME = {
|
|
46
|
+
foreground: "#18181b",
|
|
47
|
+
mutedForeground: "#71717a",
|
|
48
|
+
background: "#ffffff",
|
|
49
|
+
border: "#e4e4e7",
|
|
50
|
+
ring: "#8b5cf6",
|
|
51
|
+
palette: ["#7c3aed", "#0891b2", "#db2777", "#ea580c", "#16a34a", "#2563eb", "#ca8a04", "#9333ea"],
|
|
52
|
+
};
|
|
53
|
+
function readToken(styles, name) {
|
|
54
|
+
const raw = styles.getPropertyValue(name).trim();
|
|
55
|
+
return raw.length > 0 ? raw : null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the theme against a mounted element.
|
|
59
|
+
*
|
|
60
|
+
* Takes an element rather than `document.documentElement` so a graph inside a
|
|
61
|
+
* scoped theme (the shell's `ThemeScope`, a preview pane) picks up that scope's
|
|
62
|
+
* tokens instead of the page's.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveGraphTheme(element) {
|
|
65
|
+
if (!element || typeof window === "undefined" || !window.getComputedStyle) {
|
|
66
|
+
return FALLBACK_THEME;
|
|
67
|
+
}
|
|
68
|
+
const styles = window.getComputedStyle(element);
|
|
69
|
+
const readAll = (tokens) => tokens
|
|
70
|
+
.map((token) => readToken(styles, token))
|
|
71
|
+
.filter((value) => value !== null);
|
|
72
|
+
const graphPalette = readAll(PALETTE_TOKENS);
|
|
73
|
+
const chartPalette = graphPalette.length > 0 ? [] : readAll(CHART_PALETTE_TOKENS);
|
|
74
|
+
const palette = graphPalette.length > 0 ? graphPalette : chartPalette;
|
|
75
|
+
return {
|
|
76
|
+
foreground: readToken(styles, "--foreground") ?? FALLBACK_THEME.foreground,
|
|
77
|
+
mutedForeground: readToken(styles, "--muted-foreground") ?? FALLBACK_THEME.mutedForeground,
|
|
78
|
+
background: readToken(styles, "--background") ?? FALLBACK_THEME.background,
|
|
79
|
+
border: readToken(styles, "--border") ?? FALLBACK_THEME.border,
|
|
80
|
+
ring: readToken(styles, "--ring") ?? FALLBACK_THEME.ring,
|
|
81
|
+
palette: palette.length > 0 ? palette : FALLBACK_THEME.palette,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ElementSize {
|
|
2
|
+
width: number;
|
|
3
|
+
height: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Track an element's box.
|
|
7
|
+
*
|
|
8
|
+
* `react-force-graph-2d` takes `width`/`height` as NUMBERS — it sizes the
|
|
9
|
+
* canvas imperatively and has no CSS-driven mode. Without an observer the
|
|
10
|
+
* canvas keeps its first measurement forever, so it goes blank when the tool
|
|
11
|
+
* panel is dragged wider or toggled fullscreen.
|
|
12
|
+
*/
|
|
13
|
+
export declare function useElementSize(element: Element | null): ElementSize;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Track an element's box.
|
|
4
|
+
*
|
|
5
|
+
* `react-force-graph-2d` takes `width`/`height` as NUMBERS — it sizes the
|
|
6
|
+
* canvas imperatively and has no CSS-driven mode. Without an observer the
|
|
7
|
+
* canvas keeps its first measurement forever, so it goes blank when the tool
|
|
8
|
+
* panel is dragged wider or toggled fullscreen.
|
|
9
|
+
*/
|
|
10
|
+
export function useElementSize(element) {
|
|
11
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!element)
|
|
14
|
+
return;
|
|
15
|
+
const measure = () => {
|
|
16
|
+
const rect = element.getBoundingClientRect();
|
|
17
|
+
setSize((prev) =>
|
|
18
|
+
// Sub-pixel jitter from a flex parent would otherwise re-render (and
|
|
19
|
+
// re-measure) on every frame.
|
|
20
|
+
Math.abs(prev.width - rect.width) < 1 && Math.abs(prev.height - rect.height) < 1
|
|
21
|
+
? prev
|
|
22
|
+
: { width: rect.width, height: rect.height });
|
|
23
|
+
};
|
|
24
|
+
measure();
|
|
25
|
+
if (typeof ResizeObserver === "undefined")
|
|
26
|
+
return;
|
|
27
|
+
const observer = new ResizeObserver(measure);
|
|
28
|
+
observer.observe(element);
|
|
29
|
+
return () => observer.disconnect();
|
|
30
|
+
}, [element]);
|
|
31
|
+
return size;
|
|
32
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { type GraphEdge, type GraphNode, type GraphPayload } from "./model.js";
|
|
2
|
+
/**
|
|
3
|
+
* A node handed to the force engine. force-graph WRITES to these objects —
|
|
4
|
+
* `x`, `y`, `vx`, `vy` are the simulation's state, and `fx`/`fy` pin a node.
|
|
5
|
+
*/
|
|
6
|
+
export interface EngineNode extends GraphNode {
|
|
7
|
+
x?: number;
|
|
8
|
+
y?: number;
|
|
9
|
+
vx?: number;
|
|
10
|
+
vy?: number;
|
|
11
|
+
fx?: number;
|
|
12
|
+
fy?: number;
|
|
13
|
+
}
|
|
14
|
+
/** An edge handed to the engine. force-graph replaces the endpoint ids with
|
|
15
|
+
* the resolved node objects in place, hence the widened type. */
|
|
16
|
+
export interface EngineLink extends Omit<GraphEdge, "source" | "target"> {
|
|
17
|
+
source: string | EngineNode;
|
|
18
|
+
target: string | EngineNode;
|
|
19
|
+
}
|
|
20
|
+
/** Why the rendered graph is smaller than the payload. All zero is the happy path. */
|
|
21
|
+
export interface GraphModelLoss {
|
|
22
|
+
/** Edges whose source or target was absent from the payload entirely. */
|
|
23
|
+
danglingEdges: number;
|
|
24
|
+
/** Nodes dropped by the node cap. */
|
|
25
|
+
cappedNodes: number;
|
|
26
|
+
/** Edges dropped — either stranded by the node cap, or over the edge cap. */
|
|
27
|
+
cappedEdges: number;
|
|
28
|
+
}
|
|
29
|
+
export interface GraphModel {
|
|
30
|
+
nodes: EngineNode[];
|
|
31
|
+
links: EngineLink[];
|
|
32
|
+
loss: GraphModelLoss;
|
|
33
|
+
/** True when anything at all was withheld from the canvas. */
|
|
34
|
+
lossy: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface GraphModelLimits {
|
|
37
|
+
maxNodes: number;
|
|
38
|
+
maxEdges: number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Caps chosen so an accidental `MATCH (n) RETURN n` degrades to a slow-but-alive
|
|
42
|
+
* canvas instead of a frozen tab. Hosts with a known-small domain can raise them.
|
|
43
|
+
*/
|
|
44
|
+
export declare const DEFAULT_LIMITS: GraphModelLimits;
|
|
45
|
+
/**
|
|
46
|
+
* Choose which nodes survive a cap: the connected core around the hubs.
|
|
47
|
+
*
|
|
48
|
+
* Which nodes survive decides whether the user sees a graph or confetti.
|
|
49
|
+
*
|
|
50
|
+
* Taking the first N in payload order keeps whatever the producer happened to
|
|
51
|
+
* emit first — for an unbounded query that is close to random, and the
|
|
52
|
+
* survivors are mostly unrelated, so the edges collapse with them.
|
|
53
|
+
*
|
|
54
|
+
* Taking the N highest-degree nodes is better but not enough. In a graph that
|
|
55
|
+
* is even loosely bipartite — films and people, orders and customers — the hubs
|
|
56
|
+
* all sit on one side and none of them are adjacent, so a small cap still
|
|
57
|
+
* yields a set with no edges at all. (Measured: on an 8-film demo graph, top-5
|
|
58
|
+
* by degree gave five films and zero relationships.)
|
|
59
|
+
*
|
|
60
|
+
* So: seed at the biggest hub and expand outwards through its neighbours,
|
|
61
|
+
* preferring well-connected ones, then start again at the next unvisited hub.
|
|
62
|
+
* Every node after the first seed is adjacent to one already chosen, so the
|
|
63
|
+
* result is a handful of connected neighbourhoods rather than scattered dust.
|
|
64
|
+
*
|
|
65
|
+
* Mirrors `select_connected_core` in the Neo4j starter's `graph_payload.py`;
|
|
66
|
+
* both sides cap, and they have to agree on what the important part of a graph
|
|
67
|
+
* is.
|
|
68
|
+
*/
|
|
69
|
+
export declare function selectConnectedCore(nodes: GraphNode[], edges: GraphEdge[], limit: number): GraphNode[];
|
|
70
|
+
/**
|
|
71
|
+
* Turn a payload into the mutable objects the force engine wants, REUSING the
|
|
72
|
+
* object for any node whose id is already in `cache`.
|
|
73
|
+
*
|
|
74
|
+
* This is the single most important detail of driving force-graph. The
|
|
75
|
+
* simulation stores each node's position ON the node object. Build a fresh
|
|
76
|
+
* array every time — which `payload.nodes.map(...)` does — and every unrelated
|
|
77
|
+
* re-render (a hover, a search keystroke) throws away all positions, so the
|
|
78
|
+
* graph visibly explodes and re-settles. Reusing the object for an unchanged
|
|
79
|
+
* node keeps its identity, and therefore its place.
|
|
80
|
+
*
|
|
81
|
+
* Pure, and it returns the next cache rather than mutating the one passed in:
|
|
82
|
+
* the React binding is a thin wrapper below, and this stays testable — and
|
|
83
|
+
* usable — without a renderer.
|
|
84
|
+
*
|
|
85
|
+
* Nodes absent from the new payload are dropped from the returned cache, so
|
|
86
|
+
* this does not accumulate across successive queries.
|
|
87
|
+
*/
|
|
88
|
+
export declare function reconcileGraphModel(payload: GraphPayload, cache: ReadonlyMap<string, EngineNode>, limits?: GraphModelLimits): {
|
|
89
|
+
model: GraphModel;
|
|
90
|
+
cache: Map<string, EngineNode>;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* A link's endpoint id, whichever form it is in.
|
|
94
|
+
*
|
|
95
|
+
* force-graph REPLACES an endpoint id with the node object once the simulation
|
|
96
|
+
* has run, so anything reading endpoints after a render has to accept both —
|
|
97
|
+
* comparing against `node.id` alone silently matches nothing.
|
|
98
|
+
*/
|
|
99
|
+
export declare function endpointId(end: unknown): string | undefined;
|
|
100
|
+
/** React binding for {@link reconcileGraphModel}: holds the cache across renders. */
|
|
101
|
+
export declare function useGraphModel(payload: GraphPayload, limits?: GraphModelLimits): GraphModel;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { useMemo, useRef } from "react";
|
|
2
|
+
import { pruneDanglingEdges } from "./model.js";
|
|
3
|
+
/**
|
|
4
|
+
* Caps chosen so an accidental `MATCH (n) RETURN n` degrades to a slow-but-alive
|
|
5
|
+
* canvas instead of a frozen tab. Hosts with a known-small domain can raise them.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_LIMITS = { maxNodes: 1500, maxEdges: 3000 };
|
|
8
|
+
/**
|
|
9
|
+
* Choose which nodes survive a cap: the connected core around the hubs.
|
|
10
|
+
*
|
|
11
|
+
* Which nodes survive decides whether the user sees a graph or confetti.
|
|
12
|
+
*
|
|
13
|
+
* Taking the first N in payload order keeps whatever the producer happened to
|
|
14
|
+
* emit first — for an unbounded query that is close to random, and the
|
|
15
|
+
* survivors are mostly unrelated, so the edges collapse with them.
|
|
16
|
+
*
|
|
17
|
+
* Taking the N highest-degree nodes is better but not enough. In a graph that
|
|
18
|
+
* is even loosely bipartite — films and people, orders and customers — the hubs
|
|
19
|
+
* all sit on one side and none of them are adjacent, so a small cap still
|
|
20
|
+
* yields a set with no edges at all. (Measured: on an 8-film demo graph, top-5
|
|
21
|
+
* by degree gave five films and zero relationships.)
|
|
22
|
+
*
|
|
23
|
+
* So: seed at the biggest hub and expand outwards through its neighbours,
|
|
24
|
+
* preferring well-connected ones, then start again at the next unvisited hub.
|
|
25
|
+
* Every node after the first seed is adjacent to one already chosen, so the
|
|
26
|
+
* result is a handful of connected neighbourhoods rather than scattered dust.
|
|
27
|
+
*
|
|
28
|
+
* Mirrors `select_connected_core` in the Neo4j starter's `graph_payload.py`;
|
|
29
|
+
* both sides cap, and they have to agree on what the important part of a graph
|
|
30
|
+
* is.
|
|
31
|
+
*/
|
|
32
|
+
export function selectConnectedCore(nodes, edges, limit) {
|
|
33
|
+
if (nodes.length <= limit)
|
|
34
|
+
return nodes;
|
|
35
|
+
const degree = new Map();
|
|
36
|
+
const adjacency = new Map();
|
|
37
|
+
for (const edge of edges) {
|
|
38
|
+
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
|
|
39
|
+
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
|
|
40
|
+
(adjacency.get(edge.source) ?? adjacency.set(edge.source, []).get(edge.source)).push(edge.target);
|
|
41
|
+
(adjacency.get(edge.target) ?? adjacency.set(edge.target, []).get(edge.target)).push(edge.source);
|
|
42
|
+
}
|
|
43
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
44
|
+
// Payload order breaks ties, so identical data caps identically — an
|
|
45
|
+
// unstable tiebreak makes the retained set flicker between renders.
|
|
46
|
+
const order = new Map(nodes.map((node, index) => [node.id, index]));
|
|
47
|
+
const better = (a, b) => (degree.get(b) ?? 0) - (degree.get(a) ?? 0) || (order.get(a) ?? 0) - (order.get(b) ?? 0);
|
|
48
|
+
const ranked = [...order.keys()].sort(better);
|
|
49
|
+
const chosen = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
for (const seed of ranked) {
|
|
52
|
+
if (chosen.length >= limit)
|
|
53
|
+
break;
|
|
54
|
+
if (seen.has(seed))
|
|
55
|
+
continue;
|
|
56
|
+
const queue = [seed];
|
|
57
|
+
seen.add(seed);
|
|
58
|
+
while (queue.length > 0 && chosen.length < limit) {
|
|
59
|
+
const current = queue.shift();
|
|
60
|
+
const node = byId.get(current);
|
|
61
|
+
if (node)
|
|
62
|
+
chosen.push(node);
|
|
63
|
+
const neighbours = (adjacency.get(current) ?? []).filter((id) => !seen.has(id)).sort(better);
|
|
64
|
+
for (const neighbour of neighbours) {
|
|
65
|
+
seen.add(neighbour);
|
|
66
|
+
queue.push(neighbour);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return chosen.slice(0, limit);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Turn a payload into the mutable objects the force engine wants, REUSING the
|
|
74
|
+
* object for any node whose id is already in `cache`.
|
|
75
|
+
*
|
|
76
|
+
* This is the single most important detail of driving force-graph. The
|
|
77
|
+
* simulation stores each node's position ON the node object. Build a fresh
|
|
78
|
+
* array every time — which `payload.nodes.map(...)` does — and every unrelated
|
|
79
|
+
* re-render (a hover, a search keystroke) throws away all positions, so the
|
|
80
|
+
* graph visibly explodes and re-settles. Reusing the object for an unchanged
|
|
81
|
+
* node keeps its identity, and therefore its place.
|
|
82
|
+
*
|
|
83
|
+
* Pure, and it returns the next cache rather than mutating the one passed in:
|
|
84
|
+
* the React binding is a thin wrapper below, and this stays testable — and
|
|
85
|
+
* usable — without a renderer.
|
|
86
|
+
*
|
|
87
|
+
* Nodes absent from the new payload are dropped from the returned cache, so
|
|
88
|
+
* this does not accumulate across successive queries.
|
|
89
|
+
*/
|
|
90
|
+
export function reconcileGraphModel(payload, cache, limits = DEFAULT_LIMITS) {
|
|
91
|
+
{
|
|
92
|
+
const { nodes: allNodes, edges: connectedEdges, dropped: danglingEdges, } = pruneDanglingEdges(payload);
|
|
93
|
+
const keptNodes = selectConnectedCore(allNodes, connectedEdges, limits.maxNodes);
|
|
94
|
+
const cappedNodes = allNodes.length - keptNodes.length;
|
|
95
|
+
// Dropping nodes strands edges that were connected a moment ago, so the
|
|
96
|
+
// edge set is re-filtered against the surviving nodes rather than reused.
|
|
97
|
+
const keptIds = new Set(keptNodes.map((node) => node.id));
|
|
98
|
+
const survivingEdges = connectedEdges.filter((edge) => keptIds.has(edge.source) && keptIds.has(edge.target));
|
|
99
|
+
const keptEdges = survivingEdges.slice(0, limits.maxEdges);
|
|
100
|
+
// Every edge that made it past the dangling prune but not onto the canvas,
|
|
101
|
+
// whichever cap took it.
|
|
102
|
+
const cappedEdges = connectedEdges.length - keptEdges.length;
|
|
103
|
+
const next = new Map();
|
|
104
|
+
const nodes = keptNodes.map((node) => {
|
|
105
|
+
const existing = cache.get(node.id);
|
|
106
|
+
if (existing) {
|
|
107
|
+
// Refresh the data fields in place; leave x/y/vx/vy/fx/fy alone —
|
|
108
|
+
// those belong to the simulation, not to the payload.
|
|
109
|
+
existing.labels = node.labels;
|
|
110
|
+
existing.caption = node.caption;
|
|
111
|
+
existing.properties = node.properties;
|
|
112
|
+
next.set(node.id, existing);
|
|
113
|
+
return existing;
|
|
114
|
+
}
|
|
115
|
+
const created = { ...node };
|
|
116
|
+
next.set(node.id, created);
|
|
117
|
+
return created;
|
|
118
|
+
});
|
|
119
|
+
const loss = { danglingEdges, cappedNodes, cappedEdges };
|
|
120
|
+
return {
|
|
121
|
+
model: {
|
|
122
|
+
nodes,
|
|
123
|
+
// Links carry no simulation state we need to preserve — force-graph
|
|
124
|
+
// resolves the endpoint ids to node objects itself on each data set —
|
|
125
|
+
// so unlike nodes they can be rebuilt freely.
|
|
126
|
+
links: keptEdges.map((edge) => ({ ...edge })),
|
|
127
|
+
loss,
|
|
128
|
+
lossy: danglingEdges > 0 || cappedNodes > 0 || cappedEdges > 0,
|
|
129
|
+
},
|
|
130
|
+
cache: next,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* A link's endpoint id, whichever form it is in.
|
|
136
|
+
*
|
|
137
|
+
* force-graph REPLACES an endpoint id with the node object once the simulation
|
|
138
|
+
* has run, so anything reading endpoints after a render has to accept both —
|
|
139
|
+
* comparing against `node.id` alone silently matches nothing.
|
|
140
|
+
*/
|
|
141
|
+
export function endpointId(end) {
|
|
142
|
+
if (typeof end === "string")
|
|
143
|
+
return end;
|
|
144
|
+
if (typeof end === "number")
|
|
145
|
+
return String(end);
|
|
146
|
+
if (typeof end === "object" && end !== null && "id" in end) {
|
|
147
|
+
const id = end.id;
|
|
148
|
+
if (typeof id === "string" || typeof id === "number")
|
|
149
|
+
return String(id);
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
/** React binding for {@link reconcileGraphModel}: holds the cache across renders. */
|
|
154
|
+
export function useGraphModel(payload, limits = DEFAULT_LIMITS) {
|
|
155
|
+
const cacheRef = useRef(new Map());
|
|
156
|
+
return useMemo(() => {
|
|
157
|
+
const { model, cache } = reconcileGraphModel(payload, cacheRef.current, limits);
|
|
158
|
+
cacheRef.current = cache;
|
|
159
|
+
return model;
|
|
160
|
+
// `limits` is an object literal at most call sites, so depend on its
|
|
161
|
+
// fields rather than its identity — otherwise every render reconciles.
|
|
162
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
163
|
+
}, [payload, limits.maxNodes, limits.maxEdges]);
|
|
164
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type GraphEdge, type GraphNode, type GraphPayload } from "./model.js";
|
|
2
|
+
import type { GraphTheme } from "./theme.js";
|
|
3
|
+
/** Host-supplied styling overrides, all optional. */
|
|
4
|
+
export interface GraphStyling {
|
|
5
|
+
/** Explicit colour per label. Labels not listed fall back to the palette. */
|
|
6
|
+
colors?: Record<string, string>;
|
|
7
|
+
/** Property key to read the caption from, per label. */
|
|
8
|
+
captionKeys?: Record<string, string>;
|
|
9
|
+
/** Relative node size per label (1 = default). */
|
|
10
|
+
sizes?: Record<string, number>;
|
|
11
|
+
}
|
|
12
|
+
/** Everything the canvas needs to draw one node, precomputed once per payload. */
|
|
13
|
+
export interface NodeStyle {
|
|
14
|
+
color: string;
|
|
15
|
+
caption: string;
|
|
16
|
+
radius: number;
|
|
17
|
+
}
|
|
18
|
+
export interface GraphStyleMap {
|
|
19
|
+
/** Distinct labels in first-seen order — also the legend's order. */
|
|
20
|
+
labels: string[];
|
|
21
|
+
colorForLabel: (label: string) => string;
|
|
22
|
+
styleForNode: (node: GraphNode) => NodeStyle;
|
|
23
|
+
/** Distinct relationship types, for the legend. */
|
|
24
|
+
edgeTypes: string[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Assign a palette slot per label — by hash, then linear probing.
|
|
28
|
+
*
|
|
29
|
+
* Colour has to mean the same thing across queries. Assigning slots in
|
|
30
|
+
* first-seen order is collision-free but makes `Person` blue in one result and
|
|
31
|
+
* red in the next, so the legend has to be re-read every time and no mental
|
|
32
|
+
* model of the graph ever forms. Hashing pins a label to a colour it keeps.
|
|
33
|
+
*
|
|
34
|
+
* Probing covers the hash's weakness: two labels that hash to the same slot
|
|
35
|
+
* would otherwise be indistinguishable in exactly the small graphs where the
|
|
36
|
+
* legend matters most. With eight colours and a handful of labels a collision
|
|
37
|
+
* is likelier than not, so dropping it is not an option.
|
|
38
|
+
*
|
|
39
|
+
* The cost, stated plainly because it is a real one: a label's colour depends
|
|
40
|
+
* on which OTHER labels are present. Add a label that hashes to the same slot
|
|
41
|
+
* and sorts earlier, and the first label moves one slot along — so a label can
|
|
42
|
+
* change colour between two queries, and can change colour mid-session as a
|
|
43
|
+
* merge grows the graph. There is no way to have both properties with a fixed
|
|
44
|
+
* eight-colour palette; distinctness within one graph is the one worth having,
|
|
45
|
+
* because the swatch is only ever read next to the label's own name.
|
|
46
|
+
*
|
|
47
|
+
* Sorted order at least makes the outcome depend on WHICH labels are present
|
|
48
|
+
* and never on the order the producer happened to return them in.
|
|
49
|
+
*/
|
|
50
|
+
declare function buildLabelColors(labels: string[], palette: string[], overrides: Record<string, string> | undefined): Map<string, string>;
|
|
51
|
+
export declare function useGraphStyling(payload: GraphPayload, theme: GraphTheme, styling?: GraphStyling): GraphStyleMap;
|
|
52
|
+
/** Human-readable one-liner for a hover tooltip. */
|
|
53
|
+
export declare function describeNode(node: GraphNode, caption: string): string;
|
|
54
|
+
export declare function describeEdge(edge: GraphEdge): string;
|
|
55
|
+
export { buildLabelColors as __buildLabelColorsForTest };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { hasOwn, primaryLabel, resolveCaption, } from "./model.js";
|
|
3
|
+
/** Small, stable string hash (FNV-1a). Deterministic across runs and machines. */
|
|
4
|
+
function hashLabel(label) {
|
|
5
|
+
let hash = 0x811c9dc5;
|
|
6
|
+
for (let i = 0; i < label.length; i += 1) {
|
|
7
|
+
hash ^= label.charCodeAt(i);
|
|
8
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
9
|
+
}
|
|
10
|
+
return hash;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Assign a palette slot per label — by hash, then linear probing.
|
|
14
|
+
*
|
|
15
|
+
* Colour has to mean the same thing across queries. Assigning slots in
|
|
16
|
+
* first-seen order is collision-free but makes `Person` blue in one result and
|
|
17
|
+
* red in the next, so the legend has to be re-read every time and no mental
|
|
18
|
+
* model of the graph ever forms. Hashing pins a label to a colour it keeps.
|
|
19
|
+
*
|
|
20
|
+
* Probing covers the hash's weakness: two labels that hash to the same slot
|
|
21
|
+
* would otherwise be indistinguishable in exactly the small graphs where the
|
|
22
|
+
* legend matters most. With eight colours and a handful of labels a collision
|
|
23
|
+
* is likelier than not, so dropping it is not an option.
|
|
24
|
+
*
|
|
25
|
+
* The cost, stated plainly because it is a real one: a label's colour depends
|
|
26
|
+
* on which OTHER labels are present. Add a label that hashes to the same slot
|
|
27
|
+
* and sorts earlier, and the first label moves one slot along — so a label can
|
|
28
|
+
* change colour between two queries, and can change colour mid-session as a
|
|
29
|
+
* merge grows the graph. There is no way to have both properties with a fixed
|
|
30
|
+
* eight-colour palette; distinctness within one graph is the one worth having,
|
|
31
|
+
* because the swatch is only ever read next to the label's own name.
|
|
32
|
+
*
|
|
33
|
+
* Sorted order at least makes the outcome depend on WHICH labels are present
|
|
34
|
+
* and never on the order the producer happened to return them in.
|
|
35
|
+
*/
|
|
36
|
+
function buildLabelColors(labels, palette, overrides) {
|
|
37
|
+
const out = new Map();
|
|
38
|
+
const taken = new Set();
|
|
39
|
+
const assignable = [...labels].sort();
|
|
40
|
+
for (const label of assignable) {
|
|
41
|
+
// An own-key check because a label is untrusted: `overrides?.["toString"]`
|
|
42
|
+
// finds a function on the prototype and paints a node with it.
|
|
43
|
+
const override = overrides && hasOwn(overrides, label) ? overrides[label] : undefined;
|
|
44
|
+
if (override) {
|
|
45
|
+
out.set(label, override);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
let slot = hashLabel(label) % palette.length;
|
|
49
|
+
// At most one full pass: with more labels than colours some reuse is
|
|
50
|
+
// unavoidable, and stopping keeps this O(palette) rather than looping.
|
|
51
|
+
for (let probe = 0; probe < palette.length && taken.has(slot); probe += 1) {
|
|
52
|
+
slot = (slot + 1) % palette.length;
|
|
53
|
+
}
|
|
54
|
+
taken.add(slot);
|
|
55
|
+
out.set(label, palette[slot]);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/** Radius of a leaf node, in graph units. */
|
|
60
|
+
const BASE_RADIUS = 6;
|
|
61
|
+
/** A hub may grow to this multiple of the base — enough to read at a glance,
|
|
62
|
+
* not so much that one supernode swallows the canvas. */
|
|
63
|
+
const MAX_DEGREE_SCALE = 2.4;
|
|
64
|
+
export function useGraphStyling(payload, theme, styling) {
|
|
65
|
+
return useMemo(() => {
|
|
66
|
+
const labels = [];
|
|
67
|
+
const seenLabels = new Set();
|
|
68
|
+
for (const node of payload.nodes) {
|
|
69
|
+
const label = primaryLabel(node);
|
|
70
|
+
if (!seenLabels.has(label)) {
|
|
71
|
+
seenLabels.add(label);
|
|
72
|
+
labels.push(label);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const edgeTypes = [];
|
|
76
|
+
const seenTypes = new Set();
|
|
77
|
+
for (const edge of payload.edges) {
|
|
78
|
+
const type = edge.type ?? "RELATED";
|
|
79
|
+
if (!seenTypes.has(type)) {
|
|
80
|
+
seenTypes.add(type);
|
|
81
|
+
edgeTypes.push(type);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Producer-supplied legend colours are an override of last resort — the
|
|
85
|
+
// host's explicit `styling.colors` still wins.
|
|
86
|
+
const producerColors = Object.create(null);
|
|
87
|
+
for (const [label, hint] of Object.entries(payload.legend ?? {})) {
|
|
88
|
+
if (hint.color)
|
|
89
|
+
producerColors[label] = hint.color;
|
|
90
|
+
}
|
|
91
|
+
const colors = buildLabelColors(labels, theme.palette, {
|
|
92
|
+
...producerColors,
|
|
93
|
+
...(styling?.colors ?? {}),
|
|
94
|
+
});
|
|
95
|
+
// A `Map`, because the keys are LABELS and labels come from the payload.
|
|
96
|
+
// A graph with a label called `constructor` or `toString` reads an
|
|
97
|
+
// inherited function out of an object literal, and it would be handed to
|
|
98
|
+
// `resolveCaption` as the caption key.
|
|
99
|
+
const captionKeys = new Map();
|
|
100
|
+
for (const [label, hint] of Object.entries(payload.legend ?? {})) {
|
|
101
|
+
if (hint.captionKey)
|
|
102
|
+
captionKeys.set(label, hint.captionKey);
|
|
103
|
+
}
|
|
104
|
+
for (const [label, key] of Object.entries(styling?.captionKeys ?? {})) {
|
|
105
|
+
captionKeys.set(label, key);
|
|
106
|
+
}
|
|
107
|
+
const colorForLabel = (label) => colors.get(label) ?? theme.palette[0] ?? theme.foreground;
|
|
108
|
+
// Size by connectedness. In a force layout every node is a same-sized
|
|
109
|
+
// dot, so the structure — which nodes hold the graph together — is
|
|
110
|
+
// invisible until you trace the edges by eye. Scaling by degree makes the
|
|
111
|
+
// hubs read immediately, and it is the single change that most makes a
|
|
112
|
+
// force graph look like a graph explorer rather than a hairball.
|
|
113
|
+
// sqrt, not linear: degree is long-tailed, and a linear scale turns one
|
|
114
|
+
// supernode into a disc that covers everything else.
|
|
115
|
+
const degree = new Map();
|
|
116
|
+
for (const edge of payload.edges) {
|
|
117
|
+
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
|
|
118
|
+
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
|
|
119
|
+
}
|
|
120
|
+
let maxDegree = 0;
|
|
121
|
+
for (const value of degree.values())
|
|
122
|
+
maxDegree = Math.max(maxDegree, value);
|
|
123
|
+
const degreeScale = (id) => {
|
|
124
|
+
if (maxDegree <= 1)
|
|
125
|
+
return 1;
|
|
126
|
+
const ratio = Math.sqrt((degree.get(id) ?? 0) / maxDegree);
|
|
127
|
+
return 1 + ratio * (MAX_DEGREE_SCALE - 1);
|
|
128
|
+
};
|
|
129
|
+
const styleCache = new Map();
|
|
130
|
+
const styleForNode = (node) => {
|
|
131
|
+
const cached = styleCache.get(node.id);
|
|
132
|
+
if (cached)
|
|
133
|
+
return cached;
|
|
134
|
+
const label = primaryLabel(node);
|
|
135
|
+
const style = {
|
|
136
|
+
color: colorForLabel(label),
|
|
137
|
+
caption: resolveCaption(node, captionKeys.get(label)),
|
|
138
|
+
radius: BASE_RADIUS *
|
|
139
|
+
degreeScale(node.id) *
|
|
140
|
+
(styling?.sizes && hasOwn(styling.sizes, label) ? (styling.sizes[label] ?? 1) : 1),
|
|
141
|
+
};
|
|
142
|
+
styleCache.set(node.id, style);
|
|
143
|
+
return style;
|
|
144
|
+
};
|
|
145
|
+
return { labels, colorForLabel, styleForNode, edgeTypes };
|
|
146
|
+
}, [payload, theme, styling]);
|
|
147
|
+
}
|
|
148
|
+
/** Human-readable one-liner for a hover tooltip. */
|
|
149
|
+
export function describeNode(node, caption) {
|
|
150
|
+
const labels = node.labels?.length ? node.labels.join(":") : "Node";
|
|
151
|
+
return `${labels} — ${caption}`;
|
|
152
|
+
}
|
|
153
|
+
export function describeEdge(edge) {
|
|
154
|
+
return edge.type ?? "RELATED";
|
|
155
|
+
}
|
|
156
|
+
export { buildLabelColors as __buildLabelColorsForTest };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type GraphTheme } from "./theme.js";
|
|
2
|
+
/**
|
|
3
|
+
* Keep {@link GraphTheme} in sync with the DOM.
|
|
4
|
+
*
|
|
5
|
+
* Re-resolves when the `class`, `data-theme` or `style` attribute changes
|
|
6
|
+
* anywhere up the tree — between them that is how every theme switcher in this
|
|
7
|
+
* stack signals a flip (Tailwind's `.dark` class, an explicit `data-theme`, or
|
|
8
|
+
* a runtime layer writing variables inline). Watching the element itself is not
|
|
9
|
+
* enough: the class usually lands on `<html>`, far above.
|
|
10
|
+
*/
|
|
11
|
+
export declare function useGraphTheme(element: Element | null): GraphTheme;
|