feat: overhaul graph explorer visuals and loading flow

This commit is contained in:
Zohaib Hassnain
2026-04-11 03:19:28 +05:00
parent 6e6b190da1
commit dfd7785cc1
58 changed files with 6635 additions and 1808 deletions
+112
View File
@@ -9,10 +9,15 @@
"version": "0.0.0",
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@sigma/edge-curve": "^3.1.0",
"@sigma/node-border": "^3.0.0",
"@tanstack/react-query": "^5.95.2",
"@xyflow/react": "^12.10.2",
"graphology": "^0.26.0",
"graphology-communities-louvain": "^2.0.2",
"graphology-layout-forceatlas2": "^0.10.1",
"graphology-metrics": "^2.4.0",
"graphology-shortest-path": "^2.1.0",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-arborist": "^3.4.3",
@@ -962,6 +967,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@sigma/edge-curve": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@sigma/edge-curve/-/edge-curve-3.1.0.tgz",
"integrity": "sha512-OFWkfAXEsm+X8l1K4K49cC0psB0gQ+gqxKA08HG5piNPdzrDZ5gG9Gza6htZ5AirOVwd/4/uq/gPpD5En+H+3Q==",
"license": "MIT",
"peerDependencies": {
"sigma": ">=3.0.0-beta.10"
}
},
"node_modules/@sigma/node-border": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@sigma/node-border/-/node-border-3.0.0.tgz",
"integrity": "sha512-mE3zUfjvJVuAMhSjiP/zdlkqe0OVTETxd04XHUwof01YqdzTk0OB4ACJIhWrwgsBXl7tTd9lPuKoroafLh8MtQ==",
"license": "MIT",
"peerDependencies": {
"sigma": ">=3.0.0-beta.17"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.95.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz",
@@ -1505,6 +1528,12 @@
"d3-zoom": "^3.0.0"
}
},
"node_modules/@yomguithereal/helpers": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@yomguithereal/helpers/-/helpers-1.1.1.tgz",
"integrity": "sha512-UYvAq/XCA7xoh1juWDYsq3W0WywOB+pz8cgVnE1b45ZfdMhBvHDrgmSFG3jXeZSr2tMTYLGHFHON+ekG05Jebg==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -2343,6 +2372,34 @@
"graphology-types": ">=0.24.0"
}
},
"node_modules/graphology-communities-louvain": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/graphology-communities-louvain/-/graphology-communities-louvain-2.0.2.tgz",
"integrity": "sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==",
"license": "MIT",
"dependencies": {
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.4.4",
"mnemonist": "^0.39.0",
"pandemonium": "^2.4.1"
},
"peerDependencies": {
"graphology-types": ">=0.19.0"
}
},
"node_modules/graphology-indices": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz",
"integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==",
"license": "MIT",
"dependencies": {
"graphology-utils": "^2.4.2",
"mnemonist": "^0.39.0"
},
"peerDependencies": {
"graphology-types": ">=0.20.0"
}
},
"node_modules/graphology-layout-forceatlas2": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/graphology-layout-forceatlas2/-/graphology-layout-forceatlas2-0.10.1.tgz",
@@ -2355,6 +2412,37 @@
"graphology-types": ">=0.19.0"
}
},
"node_modules/graphology-metrics": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/graphology-metrics/-/graphology-metrics-2.4.0.tgz",
"integrity": "sha512-7WOfOP+mFLCaTJx55Qg4eY+211vr1/b3D/R3biz3SXGhAaCVcWYkfabnmO4O4WBNWANEHtVnFrGgJ0kj6MM6xw==",
"license": "MIT",
"dependencies": {
"graphology-indices": "^0.17.0",
"graphology-shortest-path": "^2.0.0",
"graphology-utils": "^2.4.4",
"mnemonist": "^0.39.0",
"pandemonium": "2.4.1"
},
"peerDependencies": {
"graphology-types": ">=0.20.0"
}
},
"node_modules/graphology-shortest-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/graphology-shortest-path/-/graphology-shortest-path-2.1.0.tgz",
"integrity": "sha512-KbT9CTkP/u72vGEJzyRr24xFC7usI9Es3LMmCPHGwQ1KTsoZjxwA9lMKxfU0syvT/w+7fZUdB/Hu2wWYcJBm6Q==",
"license": "MIT",
"dependencies": {
"@yomguithereal/helpers": "^1.1.1",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.4.3",
"mnemonist": "^0.39.0"
},
"peerDependencies": {
"graphology-types": ">=0.20.0"
}
},
"node_modules/graphology-types": {
"version": "0.24.8",
"resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz",
@@ -2918,6 +3006,15 @@
"node": "*"
}
},
"node_modules/mnemonist": {
"version": "0.39.8",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz",
"integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.1"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -2988,6 +3085,12 @@
"node": ">=0.10.0"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3038,6 +3141,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pandemonium": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz",
"integrity": "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==",
"license": "MIT",
"dependencies": {
"mnemonist": "^0.39.2"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+7 -1
View File
@@ -7,14 +7,20 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@sigma/edge-curve": "^3.1.0",
"@sigma/node-border": "^3.0.0",
"@tanstack/react-query": "^5.95.2",
"@xyflow/react": "^12.10.2",
"graphology": "^0.26.0",
"graphology-communities-louvain": "^2.0.2",
"graphology-layout-forceatlas2": "^0.10.1",
"graphology-metrics": "^2.4.0",
"graphology-shortest-path": "^2.1.0",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-arborist": "^3.4.3",
+2
View File
@@ -0,0 +1,2 @@
export function pairRegistryKey(source: string, target: string): string;
export function curveGroupForPair(source: string, target: string): string;
@@ -0,0 +1,7 @@
export function pairRegistryKey(source, target) {
return JSON.stringify([source, target]);
}
export function curveGroupForPair(source, target) {
return JSON.stringify([source, target]);
}
+72 -5
View File
@@ -6,11 +6,12 @@ import type {
GraphLabelVisibilityPolicy,
GraphNodeShapeVariant,
} from "../workspaces/GraphWorkspace/graphTheme";
import { curveGroupForPair, pairRegistryKey } from "./edgePairKeys.js";
export const graph = new Graph({
type: "directed",
multi: false,
multi: true,
allowSelfLoops: false
});
@@ -50,6 +51,10 @@ export interface NodeAttributes {
}
export interface EdgeAttributes {
edgeId?: string;
familyId?: string;
sourceId?: string;
targetId?: string;
size?: number;
baseSize?: number;
@@ -57,6 +62,7 @@ export interface EdgeAttributes {
baseColor?: string;
mutedColor?: string;
type?: string;
curvature?: number;
visualPriority?: number;
edgeFamily?: "line" | "parallel" | "bidirectional" | "path";
isBidirectional?: boolean;
@@ -67,6 +73,7 @@ export interface EdgeAttributes {
isParallelPair?: boolean;
parallelIndex?: number;
parallelCount?: number;
familySize?: number;
edgeType: string;
@@ -74,6 +81,51 @@ export interface EdgeAttributes {
properties: Record<string, any>;
}
function normalizeParallelMetadataForPair(source: string, target: string): void {
const edgeIds: string[] = [];
graph.forEachDirectedEdge(source, target, (edgeId) => {
edgeIds.push(String(edgeId));
});
const pairCount = edgeIds.length;
const familyCounts = new Map<string, number>();
edgeIds.forEach((edgeId) => {
const attrs = graph.getEdgeAttributes(edgeId) as EdgeAttributes;
const familyId = String(attrs.familyId || edgeId);
familyCounts.set(familyId, (familyCounts.get(familyId) ?? 0) + 1);
});
edgeIds
.sort((left, right) => {
const leftAttrs = graph.getEdgeAttributes(left) as EdgeAttributes;
const rightAttrs = graph.getEdgeAttributes(right) as EdgeAttributes;
const priorityDelta = Number(rightAttrs.visualPriority ?? 0) - Number(leftAttrs.visualPriority ?? 0);
if (priorityDelta !== 0) {
return priorityDelta;
}
const weightDelta = Number(rightAttrs.weight ?? 0) - Number(leftAttrs.weight ?? 0);
if (weightDelta !== 0) {
return weightDelta;
}
return left.localeCompare(right);
})
.forEach((edgeId, index) => {
const attrs = graph.getEdgeAttributes(edgeId) as EdgeAttributes;
const familyId = String(attrs.familyId || edgeId);
graph.mergeEdgeAttributes(edgeId, {
edgeId,
familyId,
sourceId: source,
targetId: target,
isParallelPair: pairCount > 1,
parallelIndex: index,
parallelCount: pairCount,
familySize: familyCounts.get(familyId) ?? 1,
curveGroup: curveGroupForPair(source, target),
});
});
}
export function batchMergeNodes(
nodes: { id: string; attributes: NodeAttributes }[]
@@ -85,14 +137,29 @@ export function batchMergeNodes(
export function batchMergeEdges(
edges: { source: string; target: string; attributes: EdgeAttributes }[]
edges: { id: string; familyId?: string; source: string; target: string; attributes: EdgeAttributes }[]
): void {
for (const { source, target, attributes } of edges) {
const touchedPairs = new Map<string, { source: string; target: string }>();
for (const { id, familyId, source, target, attributes } of edges) {
const edgeId = String(attributes.edgeId || id);
const resolvedFamilyId = String(attributes.familyId || familyId || edgeId);
if (graph.hasNode(source) && graph.hasNode(target)) {
graph.mergeDirectedEdge(source, target, attributes);
graph.mergeDirectedEdgeWithKey(edgeId, source, target, {
...attributes,
edgeId,
familyId: resolvedFamilyId,
sourceId: source,
targetId: target,
});
touchedPairs.set(pairRegistryKey(source, target), { source, target });
}
}
touchedPairs.forEach(({ source, target }) => {
normalizeParallelMetadataForPair(source, target);
});
}
export function clearGraph(): void {
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,7 @@ export type LinkPrediction = {
export type PathResponse = {
path: string[];
edge_ids?: string[];
total_weight: number;
};
@@ -0,0 +1,315 @@
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { GRAPH_THEME, withAlpha } from "./graphTheme";
import { GRAPH_LOAD_STAGE_SEQUENCE, createGraphLoadProgress, getGraphLoadStageLabel } from "./graphLoading";
import type { GraphLoadProgress } from "./types";
const LOADING_OVERLAY_CSS = `
.graph-stage-loader {
position: absolute;
inset: 0;
z-index: 9;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
opacity: 1;
transition: opacity 220ms ease, transform 220ms ease;
}
.graph-stage-loader[data-exiting="true"] {
opacity: 0;
transform: scale(0.985);
}
.graph-stage-loader-card {
width: min(540px, calc(100% - 48px));
border-radius: 24px;
padding: 20px 20px 18px;
border: 1px solid rgba(127, 208, 255, 0.18);
background:
radial-gradient(circle at top right, rgba(242, 182, 109, 0.12), transparent 28%),
radial-gradient(circle at top left, rgba(127, 208, 255, 0.14), transparent 30%),
linear-gradient(145deg, rgba(7, 17, 31, 0.94), rgba(12, 25, 43, 0.82));
box-shadow: 0 26px 90px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255,255,255,0.05);
backdrop-filter: blur(18px) saturate(1.08);
-webkit-backdrop-filter: blur(18px) saturate(1.08);
}
.graph-stage-loader-card[data-live="true"] {
width: min(500px, calc(100% - 56px));
background:
radial-gradient(circle at top right, rgba(242, 182, 109, 0.08), transparent 26%),
radial-gradient(circle at top left, rgba(127, 208, 255, 0.12), transparent 28%),
linear-gradient(145deg, rgba(7, 17, 31, 0.84), rgba(11, 24, 40, 0.72));
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255,255,255,0.04);
}
.graph-stage-loader-beacon {
position: relative;
width: 12px;
height: 12px;
border-radius: 999px;
background: linear-gradient(135deg, rgba(127, 208, 255, 0.98), rgba(242, 182, 109, 0.94));
box-shadow: 0 0 18px rgba(127, 208, 255, 0.4);
}
.graph-stage-loader-beacon::after {
content: "";
position: absolute;
inset: -7px;
border-radius: inherit;
border: 1px solid rgba(127, 208, 255, 0.18);
animation: graph-loader-beacon 1.9s ease-out infinite;
}
.graph-stage-loader-track {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
.graph-stage-loader-step {
border-radius: 999px;
padding: 7px 0;
text-align: center;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid rgba(127, 208, 255, 0.08);
color: rgba(143, 168, 198, 0.72);
background: rgba(255, 255, 255, 0.02);
}
.graph-stage-loader-step[data-state="done"] {
color: rgba(214, 232, 250, 0.92);
border-color: rgba(127, 208, 255, 0.18);
background: rgba(89, 155, 220, 0.14);
}
.graph-stage-loader-step[data-state="active"] {
color: #eff7ff;
border-color: rgba(242, 182, 109, 0.24);
background: linear-gradient(135deg, rgba(49, 108, 172, 0.28), rgba(242, 182, 109, 0.16));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.graph-stage-loader-bar {
position: relative;
width: 100%;
height: 12px;
overflow: hidden;
border-radius: 999px;
border: 1px solid rgba(127, 208, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
}
.graph-stage-loader-bar-fill {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, rgba(74, 163, 255, 0.9), rgba(127, 208, 255, 0.96), rgba(242, 182, 109, 0.92));
box-shadow: 0 0 30px rgba(74, 163, 255, 0.28);
transition: width 220ms ease;
}
.graph-stage-loader-bar-indeterminate::before {
content: "";
position: absolute;
top: 1px;
bottom: 1px;
width: 34%;
border-radius: 999px;
background: linear-gradient(90deg, rgba(74, 163, 255, 0), rgba(127, 208, 255, 0.94), rgba(242, 182, 109, 0.82), rgba(74, 163, 255, 0));
box-shadow: 0 0 26px rgba(127, 208, 255, 0.18);
animation: graph-loader-sweep 1.5s cubic-bezier(0.22, 1, 0.36, 1) infinite;
}
@keyframes graph-loader-beacon {
0% { transform: scale(0.72); opacity: 0.6; }
100% { transform: scale(1.44); opacity: 0; }
}
@keyframes graph-loader-sweep {
0% { transform: translateX(-120%); }
100% { transform: translateX(360%); }
}
`;
function formatLayoutSource(source: GraphLoadProgress["layoutSource"]) {
switch (source) {
case "provided":
return "Persisted layout";
case "carried":
return "Preserved layout";
case "runtime":
return "Runtime layout";
default:
return null;
}
}
function formatLayoutState(state: GraphLoadProgress["layoutState"]) {
switch (state) {
case "bootstrapping":
return "Bootstrapping";
case "running":
return "Settling";
case "interactive":
return "Interactive";
case "stabilized":
return "Stable";
case "failed":
return "Fallback";
default:
return null;
}
}
const loadingMetricStyle = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "7px 10px",
borderRadius: 999,
border: "1px solid rgba(127, 208, 255, 0.12)",
background: "rgba(255, 255, 255, 0.03)",
color: "#b8cade",
fontSize: 11,
fontWeight: 600,
} satisfies CSSProperties;
export function GraphLoadingOverlay({
progress,
visible,
showGraphBehind,
}: {
progress: GraphLoadProgress | null;
visible: boolean;
showGraphBehind: boolean;
}) {
const [renderVisible, setRenderVisible] = useState(visible);
const [exiting, setExiting] = useState(false);
const [displayProgress, setDisplayProgress] = useState<GraphLoadProgress>(
progress ?? createGraphLoadProgress({
phase: "bootstrapping",
message: "Preparing graph session",
progressKind: "indeterminate",
}),
);
const exitTimerRef = useRef<number | null>(null);
useEffect(() => {
if (progress) {
setDisplayProgress(progress);
}
}, [progress]);
useEffect(() => {
if (visible) {
if (exitTimerRef.current !== null) {
window.clearTimeout(exitTimerRef.current);
exitTimerRef.current = null;
}
setRenderVisible(true);
setExiting(false);
return;
}
if (!renderVisible) {
return;
}
setExiting(true);
exitTimerRef.current = window.setTimeout(() => {
setRenderVisible(false);
setExiting(false);
exitTimerRef.current = null;
}, 220);
return () => {
if (exitTimerRef.current !== null) {
window.clearTimeout(exitTimerRef.current);
exitTimerRef.current = null;
}
};
}, [renderVisible, visible]);
if (!renderVisible) {
return null;
}
const activeProgress = progress ?? displayProgress;
const isLiveStage = activeProgress.phase === "stabilizing_layout" || activeProgress.showGraphBehind || showGraphBehind;
const overlayBackground = isLiveStage
? "linear-gradient(180deg, rgba(1,4,9,0.04), rgba(1,4,9,0.18))"
: "linear-gradient(180deg, rgba(1,4,9,0.22), rgba(1,4,9,0.5))";
const determinateRatio = activeProgress.progressKind === "determinate" && activeProgress.total
? Math.max(0.05, Math.min(activeProgress.loaded ?? 0, activeProgress.total) / Math.max(activeProgress.total, 1))
: null;
const layoutSource = formatLayoutSource(activeProgress.layoutSource);
const layoutState = formatLayoutState(activeProgress.layoutState);
return (
<div
className="graph-stage-loader"
data-exiting={exiting}
style={{ background: overlayBackground }}
>
<style>{LOADING_OVERLAY_CSS}</style>
<div className="graph-stage-loader-card" data-live={isLiveStage}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 14, marginBottom: 14 }}>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#ffffff", fontSize: 20, fontWeight: 700, letterSpacing: "-0.03em", marginBottom: 6 }}>
{activeProgress.title}
</div>
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.5 }}>
{activeProgress.message}
</div>
</div>
<div style={{ display: "inline-flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
<div className="graph-stage-loader-beacon" aria-hidden="true" />
<div style={{ color: "#d7e9fb", fontSize: 11, fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase" }}>
Stage {activeProgress.stageIndex ?? 1}/{activeProgress.stageCount ?? GRAPH_LOAD_STAGE_SEQUENCE.length}
</div>
</div>
</div>
<div className="graph-stage-loader-track" style={{ marginBottom: 14 }}>
{GRAPH_LOAD_STAGE_SEQUENCE.map((phase, index) => {
const current = activeProgress.stageIndex ?? 1;
const state = index + 1 < current ? "done" : index + 1 === current ? "active" : "upcoming";
return (
<div key={phase} className="graph-stage-loader-step" data-state={state}>
{getGraphLoadStageLabel(phase)}
</div>
);
})}
</div>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "baseline", marginBottom: 8 }}>
<div style={{ color: "#dce9f6", fontSize: 12, fontWeight: 600 }}>
{activeProgress.progressKind === "determinate" && activeProgress.total
? `${(activeProgress.loaded ?? 0).toLocaleString()} / ${activeProgress.total.toLocaleString()} in current stage`
: "Working through this stage"}
</div>
<div style={{ color: "#90a8c5", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase" }}>
{activeProgress.progressKind === "determinate" && determinateRatio !== null
? `${Math.round(determinateRatio * 100)}%`
: "Live"}
</div>
</div>
<div className={`graph-stage-loader-bar ${activeProgress.progressKind === "indeterminate" ? "graph-stage-loader-bar-indeterminate" : ""}`}>
{activeProgress.progressKind === "determinate" && determinateRatio !== null ? (
<span className="graph-stage-loader-bar-fill" style={{ width: `${Math.round(determinateRatio * 100)}%` }} />
) : null}
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 14 }}>
<span style={loadingMetricStyle}>
{activeProgress.nodesLoaded.toLocaleString()}
{activeProgress.nodesTotal ? ` / ${activeProgress.nodesTotal.toLocaleString()}` : ""} nodes
</span>
<span style={loadingMetricStyle}>
{activeProgress.edgesLoaded.toLocaleString()}
{activeProgress.edgesTotal ? ` / ${activeProgress.edgesTotal.toLocaleString()}` : ""} relationships
</span>
{layoutSource ? (
<span style={{ ...loadingMetricStyle, color: "#a9ddff", borderColor: withAlpha(GRAPH_THEME.palette.accent.hovered, 0.22) }}>
{layoutSource}
{layoutState ? ` · ${layoutState}` : ""}
</span>
) : null}
</div>
</div>
</div>
);
}
@@ -1,8 +1,8 @@
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { batchMergeEdges, batchMergeNodes, clearGraph, graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
import { GraphCanvas } from "./GraphCanvas";
import type { GraphCanvasHandle } from "./GraphCanvas";
import { SigmaSceneAdapter } from "./SigmaSceneAdapter";
import { createGraphLoadProgress } from "./graphLoading";
import {
chooseColorAccessor,
colorForNodeKey,
@@ -13,6 +13,7 @@ import {
deterministicPosition,
} from "./graphAnalytics";
import { GRAPH_THEME } from "./graphConfig";
import type { GraphSceneHandle } from "./scene";
import type {
GraphDataSnapshot,
GraphEffectsState,
@@ -29,6 +30,12 @@ const STAGE_EFFECTS_STATE: GraphEffectsState = {
pathPulseEnabled: false,
pathFlowEnabled: false,
lensEnabled: false,
temporalEmphasisEnabled: false,
semanticRegionsEnabled: false,
contoursEnabled: false,
pathfindingEnabled: false,
communitiesEnabled: false,
centralityEnabled: false,
legendEnabled: false,
diagnosticsEnabled: false,
lensMode: "neighborhood",
@@ -102,7 +109,7 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
},
ref,
) {
const canvasRef = useRef<GraphCanvasHandle>(null);
const sceneRef = useRef<GraphSceneHandle>(null);
const prevActiveIdsRef = useRef<Set<string>>(new Set());
const [graphVersion, setGraphVersion] = useState(0);
const [runtimeLayoutSource, setRuntimeLayoutSource] = useState<GraphLayoutSource>(snapshot?.summary.layoutSource ?? "runtime");
@@ -110,8 +117,8 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
const stageSignature = useMemo(() => (snapshot ? `${snapshot.fetchedAt}:${snapshot.summary.nodeCount}:${snapshot.summary.edgeCount}` : null), [snapshot]);
useImperativeHandle(ref, () => ({
fitView: () => canvasRef.current?.fitView(),
focusNode: (nodeId: string) => canvasRef.current?.focusNode(nodeId),
fitView: () => sceneRef.current?.fitView(),
focusNode: (nodeId: string) => sceneRef.current?.focusNode(nodeId),
}), []);
useEffect(() => {
@@ -122,15 +129,16 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
return;
}
onProgressChange({
phase: "styling",
onProgressChange(createGraphLoadProgress({
phase: "computing_styling",
progressKind: "indeterminate",
nodesLoaded: snapshot.summary.nodeCount,
nodesTotal: snapshot.summary.nodeCount,
edgesLoaded: snapshot.summary.edgeCount,
edgesTotal: snapshot.summary.edgeCount,
message: "Computing graph styling",
progress: 0.88,
});
message: "Computing runtime graph styling",
showGraphBehind: false,
}));
const degreeByNode = computeDegreeMap(snapshot.nodes, snapshot.edges);
const pageRankByNode = computePageRank(snapshot.nodes, snapshot.edges);
@@ -191,15 +199,16 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
return;
}
onProgressChange({
phase: "rendering",
onProgressChange(createGraphLoadProgress({
phase: "hydrating_scene",
progressKind: "indeterminate",
nodesLoaded: snapshot.summary.nodeCount,
nodesTotal: snapshot.summary.nodeCount,
edgesLoaded: snapshot.summary.edgeCount,
edgesTotal: snapshot.summary.edgeCount,
message: "Rendering graph",
progress: 0.96,
});
message: "Hydrating graph scene and renderer",
showGraphBehind: false,
}));
const nodesToMerge = draftAttributes.map(({ id, attributes }) => {
const colorKey = colorAccessor(id, attributes);
@@ -223,9 +232,15 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
});
const edgesToMerge = snapshot.edges.map((edge) => ({
id: edge.id,
familyId: edge.familyId,
source: edge.source,
target: edge.target,
attributes: {
edgeId: edge.id,
familyId: edge.familyId,
sourceId: edge.source,
targetId: edge.target,
weight: edge.weight,
edgeType: edge.type,
properties: edge.properties,
@@ -258,23 +273,26 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
onLayoutRunningChange(layoutSource === "runtime");
if (selectedNodeId) {
canvasRef.current?.focusNode(selectedNodeId);
sceneRef.current?.focusNode(selectedNodeId);
} else {
canvasRef.current?.getSigma()?.refresh();
sceneRef.current?.getRuntime()?.requestRender();
}
setGraphVersion((current) => current + 1);
if (layoutSource !== "runtime") {
onProgressChange(null);
} else {
onProgressChange({
phase: "rendering",
onProgressChange(createGraphLoadProgress({
phase: "stabilizing_layout",
progressKind: "indeterminate",
nodesLoaded: snapshot.summary.nodeCount,
nodesTotal: snapshot.summary.nodeCount,
edgesLoaded: snapshot.summary.edgeCount,
edgesTotal: snapshot.summary.edgeCount,
message: "Stabilizing full-graph layout",
progress: 0.98,
});
message: "Settling runtime layout",
showGraphBehind: true,
layoutSource,
layoutState: "bootstrapping",
}));
}
onRuntimeReady();
}
@@ -333,7 +351,7 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
prevActiveIdsRef.current = nextActiveIds;
onActiveNodeCountChange(data.active_node_count);
canvasRef.current?.getSigma()?.refresh();
sceneRef.current?.getRuntime()?.requestRender();
});
} catch (error) {
if (!cancelled) {
@@ -392,9 +410,15 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
if (eventType === "ADD_EDGE" && payload?.source_id && payload?.target_id) {
batchMergeEdges([
{
id: String(payload.id),
familyId: payload.familyId ? String(payload.familyId) : String(payload.id),
source: payload.source_id,
target: payload.target_id,
attributes: {
edgeId: String(payload.id),
familyId: payload.familyId ? String(payload.familyId) : String(payload.id),
sourceId: payload.source_id,
targetId: payload.target_id,
weight: Number(payload.weight ?? 1),
edgeType: payload.type,
properties: payload.properties || {},
@@ -407,7 +431,7 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
]);
}
canvasRef.current?.getSigma()?.refresh();
sceneRef.current?.getRuntime()?.requestRender();
setGraphVersion((current) => current + 1);
} catch (error) {
console.error("[GraphRuntimeStage] websocket update failed", error);
@@ -420,9 +444,10 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
}, []);
return (
<GraphCanvas
ref={canvasRef}
onNodeClick={onNodeSelect}
<SigmaSceneAdapter
ref={sceneRef}
onNodeSelect={onNodeSelect}
selectedEdgeId=""
selectedNodeId={selectedNodeId}
activePath={activePath}
effectsState={STAGE_EFFECTS_STATE}
@@ -2,12 +2,13 @@
import { batchMergeEdges, batchMergeNodes, graph } from "../../store/graphStore";
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
import { curveGroupForPair } from "../../store/edgePairKeys.js";
import { InspectorPanel, MetricChip, SurfaceCard } from "../../ui/primitives";
import { lazy, Suspense } from "react";
import { GraphCanvas } from "./GraphCanvas";
import type { GraphCanvasHandle, GraphViewMode } from "./GraphCanvas";
import { SigmaSceneAdapter } from "./SigmaSceneAdapter";
import { useLoadGraph, useReloadGraph } from "./useLoadGraph";
import type { GraphLoadProgress } from "./useLoadGraph";
import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading";
import { GRAPH_THEME, withAlpha } from "./graphTheme";
import {
type GraphPlugin,
@@ -15,18 +16,22 @@ import {
type GraphPluginContext,
type GraphPluginOverlayDescriptor,
type GraphPluginPanelDescriptor,
type GraphPluginRuntime,
type GraphPluginToolbarItem,
type GraphTemporalState,
} from "./plugins";
import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
import type {
GraphAnalyticsSnapshot,
GraphDiagnosticsSnapshot,
GraphEffectToggle,
GraphEffectsState,
GraphInteractionState,
GraphLoadProgress,
GraphLoadSummary,
GraphSelectedEdgeState,
GraphSelectedNodeState,
GraphTemporalState,
GraphViewMode,
} from "./types";
type SearchResult = {
@@ -82,6 +87,12 @@ const DEFAULT_EFFECTS_STATE: GraphEffectsState = {
pathPulseEnabled: false,
pathFlowEnabled: false,
lensEnabled: false,
temporalEmphasisEnabled: false,
semanticRegionsEnabled: true,
contoursEnabled: false,
pathfindingEnabled: false,
communitiesEnabled: false,
centralityEnabled: false,
legendEnabled: false,
diagnosticsEnabled: false,
lensMode: "neighborhood",
@@ -90,7 +101,7 @@ const DEFAULT_EFFECTS_STATE: GraphEffectsState = {
const LazyTimelinePanel = lazy(() => import("./TimelinePanel").then((module) => ({ default: module.TimelinePanel })));
const LazyGraphInspectorPanel = lazy(() => import("./GraphInspectorPanel").then((module) => ({ default: module.GraphInspectorPanel })));
const loadExplorationEffectsPlugin = () => import("./plugins/explorationEffectsPlugin").then((module) => module.explorationEffectsPlugin);
const loadExplorationEffectsPlugin = () => import("./plugins/explorationEffectsPluginPhaseC").then((module) => module.explorationEffectsPluginPhaseC);
const loadNeighborhoodPanelPlugin = () => import("./plugins/neighborhoodPanelPlugin").then((module) => module.neighborhoodPanelPlugin);
const loadTemporalOverlayPlugin = () => import("./plugins/temporalOverlayPlugin").then((module) => module.temporalOverlayPlugin);
@@ -391,83 +402,6 @@ const HUD_CSS = `
}
`;
function phaseLabel(phase: GraphLoadProgress["phase"]) {
switch (phase) {
case "nodes":
return "Loading nodes";
case "edges":
return "Loading edges";
case "styling":
return "Computing layout and styling";
case "rendering":
return "Rendering graph";
default:
return "Loading graph";
}
}
function LoadingOverlay({
progress,
showGraphBehind,
}: {
progress: GraphLoadProgress | null;
showGraphBehind: boolean;
}) {
const safeProgress = progress ?? {
phase: "nodes" as const,
nodesLoaded: 0,
nodesTotal: null,
edgesLoaded: 0,
edgesTotal: null,
message: "Preparing graph load",
progress: 0.06,
};
return (
<div
className="graph-loading-overlay"
style={{
background: showGraphBehind
? "linear-gradient(180deg, rgba(1,4,9,0.08), rgba(1,4,9,0.28))"
: "linear-gradient(180deg, rgba(1,4,9,0.32), rgba(1,4,9,0.58))",
}}
>
<div className="graph-loading-card">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, marginBottom: 14 }}>
<div>
<div style={{ color: "#ffffff", fontSize: 18, fontWeight: 700, marginBottom: 4 }}>Loading Graph</div>
<div style={{ color: "#8fa8c6", fontSize: 13 }}>{phaseLabel(safeProgress.phase)}</div>
</div>
<div className="graph-loading-dots" aria-hidden="true">
<span className="graph-loading-dot" />
<span className="graph-loading-dot" />
<span className="graph-loading-dot" />
</div>
</div>
<div style={{ color: "#c6d4e3", fontSize: 13, marginBottom: 12 }}>
{safeProgress.message}
</div>
<div className="graph-loading-bar" style={{ marginBottom: 14 }}>
<span style={{ width: `${Math.round(Math.max(6, safeProgress.progress * 100))}%` }} />
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
<span style={loadingMetricStyle}>
{safeProgress.nodesLoaded.toLocaleString()}
{safeProgress.nodesTotal ? ` / ${safeProgress.nodesTotal.toLocaleString()}` : ""} nodes
</span>
<span style={loadingMetricStyle}>
{safeProgress.edgesLoaded.toLocaleString()}
{safeProgress.edgesTotal ? ` / ${safeProgress.edgesTotal.toLocaleString()}` : ""} edges
</span>
</div>
</div>
</div>
);
}
function getProvenanceCount(properties: Record<string, unknown>) {
return PROVENANCE_KEYS.reduce(
(count, key) => (properties[key] !== undefined && properties[key] !== null ? count + 1 : count),
@@ -518,6 +452,8 @@ function buildRealtimeNodeAttributes(payload: {
}
function buildRealtimeEdgeAttributes(payload: {
id: string;
familyId?: string;
source_id: string;
target_id: string;
type?: string;
@@ -530,6 +466,10 @@ function buildRealtimeEdgeAttributes(payload: {
const baseColor = isInferred ? GRAPH_THEME.palette.accent.path : GRAPH_THEME.palette.muted.edgeStructure;
return {
edgeId: payload.id,
familyId: payload.familyId || payload.id,
sourceId: payload.source_id,
targetId: payload.target_id,
weight: Number(payload.weight ?? 1),
edgeType: payload.type || "related_to",
properties,
@@ -541,7 +481,7 @@ function buildRealtimeEdgeAttributes(payload: {
visualPriority: isInferred ? 0.95 : 0.5,
isBidirectional,
edgeFamily: isInferred ? "path" : isBidirectional ? "bidirectional" : "line",
curveGroup: isBidirectional ? [payload.source_id, payload.target_id].sort().join("::") : null,
curveGroup: isBidirectional ? curveGroupForPair(payload.source_id, payload.target_id) : null,
type: "line",
edgeVariant: isInferred ? "pathSignal" : isBidirectional ? "bidirectionalCurve" : "directional",
arrowVisibilityPolicy: isInferred ? "always" : "contextual",
@@ -549,6 +489,7 @@ function buildRealtimeEdgeAttributes(payload: {
isParallelPair: false,
parallelIndex: 0,
parallelCount: 1,
familySize: 1,
};
}
@@ -580,6 +521,51 @@ function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
};
}
function buildSelectedEdgeState(edgeId: string): GraphSelectedEdgeState | null {
if (!edgeId || !graph.hasEdge(edgeId)) {
return null;
}
const [sourceId, targetId] = graph.extremities(edgeId);
const attributes = graph.getEdgeAttributes(edgeId) as {
edgeType?: string;
weight?: number;
properties?: Record<string, unknown>;
familyId?: string;
};
const sourceAttributes = graph.getNodeAttributes(sourceId) as { label?: string; content?: string };
const targetAttributes = graph.getNodeAttributes(targetId) as { label?: string; content?: string };
const properties = attributes.properties ?? {};
const familyId = String(attributes.familyId || edgeId);
let familySize = 0;
let siblingCount = 0;
graph.forEachEdge((candidateEdgeId, candidateAttrs) => {
const edgeAttrs = candidateAttrs as { familyId?: string };
const [candidateSource, candidateTarget] = graph.extremities(candidateEdgeId);
if (String(edgeAttrs.familyId || candidateEdgeId) === familyId) {
familySize += 1;
}
if (candidateSource === sourceId && candidateTarget === targetId) {
siblingCount += 1;
}
});
return {
id: edgeId,
familyId,
sourceId,
sourceLabel: String(sourceAttributes.label ?? sourceAttributes.content ?? sourceId),
targetId,
targetLabel: String(targetAttributes.label ?? targetAttributes.content ?? targetId),
edgeType: String(attributes.edgeType ?? "related_to"),
weight: Number(attributes.weight ?? 1),
properties,
provenanceCount: getProvenanceCount(properties),
familySize,
siblingCount,
};
}
function collectPluginPanels(
plugins: GraphPlugin[],
context: GraphPluginContext,
@@ -637,6 +623,7 @@ function collectPluginOverlays(
export function GraphWorkspace() {
const [selectedNodeId, setSelectedNodeId] = useState("");
const [selectedEdgeId, setSelectedEdgeId] = useState("");
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
const [viewMode, setViewMode] = useState<GraphViewMode>("focused");
const [searchQuery, setSearchQuery] = useState("");
@@ -659,17 +646,21 @@ export function GraphWorkspace() {
const [pluginRuntimeVersion, setPluginRuntimeVersion] = useState(0);
const [effectsState, setEffectsState] = useState<GraphEffectsState>(DEFAULT_EFFECTS_STATE);
const [graphDiagnosticsState, setGraphDiagnosticsState] = useState<GraphDiagnosticsSnapshot["effectAvailability"] | null>(null);
const [graphAnalyticsState, setGraphAnalyticsState] = useState<GraphAnalyticsSnapshot | null>(null);
const [loadedPlugins, setLoadedPlugins] = useState<Record<string, GraphPlugin>>({});
const debouncedTime = useDebounce(scrubberTime, 150);
const prevActiveIdsRef = useRef<Set<string>>(new Set());
const canvasRef = useRef<GraphCanvasHandle>(null);
const pluginRuntimeRef = useRef<GraphPluginRuntime | null>(null);
const sceneRef = useRef<GraphSceneHandle>(null);
const pluginRuntimeRef = useRef<GraphSceneRuntime | null>(null);
const settlingOverlayTimeoutRef = useRef<number | null>(null);
const pluginInteractionStateRef = useRef<GraphInteractionState>({
hoveredNodeId: null,
selectedNodeId: "",
selectedEdgeId: "",
focusedNodeId: "",
activePath: [],
activePathEdgeIds: [],
viewMode: "focused",
zoomTier: "overview",
isLayoutRunning: false,
@@ -678,13 +669,46 @@ export function GraphWorkspace() {
const { data: summary, isLoading, isFetching } = useLoadGraph({
enabled: true,
onGraphReady: () => {
setIsLayoutRunning(true);
setLoadingProgress(null);
onGraphReady: (graphSummary) => {
setIsLayoutRunning(!graphSummary.layoutReady);
if (settlingOverlayTimeoutRef.current !== null) {
window.clearTimeout(settlingOverlayTimeoutRef.current);
settlingOverlayTimeoutRef.current = null;
}
if (graphSummary.layoutReady) {
setLoadingProgress(null);
return;
}
setLoadingProgress(createGraphLoadProgress({
phase: "stabilizing_layout",
progressKind: "indeterminate",
nodesLoaded: graphSummary.nodeCount,
nodesTotal: graphSummary.nodeCount,
edgesLoaded: graphSummary.edgeCount,
edgesTotal: graphSummary.edgeCount,
message: "Settling runtime layout",
showGraphBehind: true,
layoutSource: graphSummary.layoutSource,
layoutState: "bootstrapping",
}));
settlingOverlayTimeoutRef.current = window.setTimeout(() => {
setLoadingProgress((current) => (current?.phase === "stabilizing_layout" ? null : current));
settlingOverlayTimeoutRef.current = null;
}, 900);
},
onProgress: setLoadingProgress,
});
useEffect(() => {
return () => {
if (settlingOverlayTimeoutRef.current !== null) {
window.clearTimeout(settlingOverlayTimeoutRef.current);
}
};
}, []);
useEffect(() => {
let cancelled = false;
const loadBounds = async () => {
@@ -736,7 +760,7 @@ export function GraphWorkspace() {
});
prevActiveIdsRef.current = nextActiveIds;
setActiveNodeCount(data.active_node_count);
canvasRef.current?.getSigma()?.refresh();
sceneRef.current?.getRuntime()?.requestRender();
});
} catch (fetchError) {
if (!cancelled) {
@@ -753,6 +777,7 @@ export function GraphWorkspace() {
const focusNode = useCallback((nodeId: string) => {
setSelectedNodeId(nodeId);
setSelectedEdgeId("");
setPathResult(null);
setSearchResults([]);
setSearchError("");
@@ -761,6 +786,10 @@ export function GraphWorkspace() {
}
}, []);
const handleEdgeSelect = useCallback((edgeId: string) => {
setSelectedEdgeId(edgeId);
}, []);
const handleSearch = useCallback(async () => {
if (!searchQuery.trim()) {
setSearchResults([]);
@@ -821,7 +850,7 @@ export function GraphWorkspace() {
if (data.path?.length) {
const lastStep = data.path[data.path.length - 1];
if (graph.hasNode(lastStep)) {
canvasRef.current?.focusNode(lastStep);
sceneRef.current?.focusNode(lastStep);
}
}
} catch (pathError) {
@@ -870,17 +899,19 @@ export function GraphWorkspace() {
attributes: buildRealtimeNodeAttributes(payload),
},
]);
canvasRef.current?.getSigma()?.refresh();
sceneRef.current?.getRuntime()?.requestRender();
}
if (eventType === "ADD_EDGE") {
batchMergeEdges([
{
id: String(payload.id),
familyId: payload.familyId ? String(payload.familyId) : String(payload.id),
source: payload.source_id,
target: payload.target_id,
attributes: buildRealtimeEdgeAttributes(payload),
},
]);
canvasRef.current?.getSigma()?.refresh();
sceneRef.current?.getRuntime()?.requestRender();
}
} catch (socketError) {
console.error("[GraphWorkspace] websocket update failed", socketError);
@@ -906,14 +937,19 @@ export function GraphWorkspace() {
return `${localNeighborCount} direct neighbors highlighted`;
}, [selectedNodeId, viewMode]);
const showLoadingOverlay = isLoading || isFetching;
const showLoadingOverlay = isLoading || isFetching || loadingProgress?.phase === "stabilizing_layout";
const hasGraphContent = Boolean(summary?.nodeCount);
const activePath = pathResult?.path ?? [];
const activePathEdgeIds = pathResult?.edge_ids ?? [];
const graphSummary = summary as GraphLoadSummary | null;
const selectedNodeState = useMemo(
() => buildSelectedNodeState(selectedNodeId),
[selectedNodeId, summary?.nodeCount, summary?.edgeCount],
);
const selectedEdgeState = useMemo(
() => buildSelectedEdgeState(selectedEdgeId),
[selectedEdgeId, summary?.nodeCount, summary?.edgeCount],
);
const temporalState = useMemo(
() => ({
currentTime: scrubberTime,
@@ -1009,10 +1045,10 @@ export function GraphWorkspace() {
const handlePluginAction = useCallback((action: GraphPluginActionRequest) => {
switch (action.type) {
case "fitView":
canvasRef.current?.fitView();
sceneRef.current?.fitView();
return;
case "focusNode":
canvasRef.current?.focusNode(action.nodeId);
sceneRef.current?.focusNode(action.nodeId);
return;
case "selectNode":
focusNode(action.nodeId);
@@ -1075,8 +1111,8 @@ export function GraphWorkspace() {
}, [activePlugins, effectsState, graphDiagnosticsState, pluginPanelState]);
const pluginContext = useMemo<GraphPluginContext>(() => ({
get sigma() {
return pluginRuntimeRef.current?.sigma ?? null;
get scene() {
return pluginRuntimeRef.current;
},
get graph() {
return graph;
@@ -1095,9 +1131,11 @@ export function GraphWorkspace() {
getTemporalState: () => temporalState,
getEffectsState: () => effectsState,
getDiagnosticsSnapshot: () => diagnosticsSnapshot,
getAnalyticsSnapshot: () => graphAnalyticsState,
isPanelOpen: (panelId: string) => Boolean(pluginPanelState[panelId]),
dispatchAction: handlePluginAction,
}), [
graphAnalyticsState,
diagnosticsSnapshot,
effectsState,
graphSummary,
@@ -1108,8 +1146,11 @@ export function GraphWorkspace() {
temporalState,
]);
const handlePluginRuntimeChange = useCallback((runtime: GraphPluginRuntime | null) => {
const handleSceneRuntimeChange = useCallback((runtime: GraphSceneRuntime | null) => {
pluginRuntimeRef.current = runtime;
if (!runtime) {
setGraphAnalyticsState(null);
}
setPluginRuntimeVersion((version) => version + 1);
}, []);
@@ -1131,6 +1172,10 @@ export function GraphWorkspace() {
setGraphDiagnosticsState(effectAvailability);
}, []);
const handleAnalyticsChange = useCallback((analytics: GraphAnalyticsSnapshot | null) => {
setGraphAnalyticsState(analytics);
}, []);
useEffect(() => {
if (!pluginRuntimeRef.current) {
return;
@@ -1252,7 +1297,7 @@ export function GraphWorkspace() {
id: "fit-view",
label: "Fit View",
title: "Reset the camera to the current view",
onClick: () => canvasRef.current?.fitView(),
onClick: () => sceneRef.current?.fitView(),
},
{
id: "layout-toggle",
@@ -1288,6 +1333,25 @@ export function GraphWorkspace() {
return groups;
}, [isLayoutRunning, pluginToolbarItems, reload, searchQuery, selectedNodeId, showLoadingOverlay, viewMode]);
const sceneAdapterProps = {
onNodeSelect: focusNode,
onEdgeSelect: handleEdgeSelect,
selectedNodeId,
selectedEdgeId,
activePath,
activePathEdgeIds,
effectsState,
temporalState,
isLayoutRunning,
viewMode,
showFitViewButton: false,
pluginOverlays: pluginOverlays.map((overlay) => overlay.element),
onRuntimeChange: handleSceneRuntimeChange,
onInteractionStateChange: handleInteractionStateChange,
onDiagnosticsChange: handleDiagnosticsChange,
onAnalyticsChange: handleAnalyticsChange,
} as const;
return (
<div className="palantir-bg" style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden" }}>
<style>{HUD_CSS}</style>
@@ -1301,7 +1365,7 @@ export function GraphWorkspace() {
<div className="explore-toolbar">
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
{showLoadingOverlay && loadingProgress ? (
<MetricChip>{phaseLabel(loadingProgress.phase)}</MetricChip>
<MetricChip>{getGraphLoadTitle(loadingProgress.phase)}</MetricChip>
) : null}
{summary ? (
<MetricChip>{summary.nodeCount.toLocaleString()} nodes · {summary.edgeCount.toLocaleString()} edges</MetricChip>
@@ -1377,6 +1441,61 @@ export function GraphWorkspace() {
))}
</div>
) : null}
{selectedEdgeState ? (
<div style={selectedEdgeCardStyle}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "flex-start" }}>
<div style={{ minWidth: 0 }}>
<div style={{ color: "rgba(127, 208, 255, 0.76)", fontSize: 11, fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase" }}>
Relationship
</div>
<div style={{ color: "#f4f8ff", fontSize: 15, fontWeight: 700, marginTop: 6 }}>
{selectedEdgeState.edgeType}
</div>
</div>
<button
onClick={() => setSelectedEdgeId("")}
style={{ ...secondaryActionButtonStyle, minHeight: 30, padding: "6px 10px" }}
>
Close
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<button style={selectedEdgeNodeChipStyle} onClick={() => focusNode(selectedEdgeState.sourceId)}>
{selectedEdgeState.sourceLabel}
</button>
<span style={{ color: "#7fa7ce", fontSize: 12 }}></span>
<button style={selectedEdgeNodeChipStyle} onClick={() => focusNode(selectedEdgeState.targetId)}>
{selectedEdgeState.targetLabel}
</button>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<MetricChip tone="warm">weight {selectedEdgeState.weight.toFixed(2)}</MetricChip>
<MetricChip>{selectedEdgeState.siblingCount} parallel lane{selectedEdgeState.siblingCount === 1 ? "" : "s"}</MetricChip>
<MetricChip>{selectedEdgeState.familySize} family member{selectedEdgeState.familySize === 1 ? "" : "s"}</MetricChip>
{selectedEdgeState.provenanceCount > 0 ? (
<MetricChip>{selectedEdgeState.provenanceCount} provenance fields</MetricChip>
) : null}
</div>
{Object.keys(selectedEdgeState.properties).length ? (
<div style={selectedEdgePropertyGridStyle}>
{Object.entries(selectedEdgeState.properties).slice(0, 4).map(([key, value]) => (
<div key={key} style={selectedEdgePropertyCardStyle}>
<div style={{ color: "rgba(127, 208, 255, 0.68)", fontSize: 10, letterSpacing: "0.06em", textTransform: "uppercase" }}>
{key}
</div>
<div style={{ color: "#dce7f4", fontSize: 12, marginTop: 4, wordBreak: "break-word" }}>
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</div>
</div>
))}
</div>
) : null}
</div>
) : null}
</div>
</SurfaceCard>
</section>
@@ -1393,23 +1512,15 @@ export function GraphWorkspace() {
<div className="palantir-grid" />
<div className="palantir-vignette" />
<div className="explore-scene-stage">
<GraphCanvas
ref={canvasRef}
onNodeClick={focusNode}
selectedNodeId={selectedNodeId}
activePath={activePath}
effectsState={effectsState}
isLayoutRunning={isLayoutRunning}
viewMode={viewMode}
showFitViewButton={false}
pluginOverlays={pluginOverlays.map((overlay) => overlay.element)}
onPluginRuntimeChange={handlePluginRuntimeChange}
onInteractionStateChange={handleInteractionStateChange}
onDiagnosticsChange={handleDiagnosticsChange}
<SigmaSceneAdapter
ref={sceneRef}
{...sceneAdapterProps}
/>
<GraphLoadingOverlay
progress={loadingProgress}
visible={showLoadingOverlay}
showGraphBehind={hasGraphContent || Boolean(loadingProgress?.showGraphBehind)}
/>
{showLoadingOverlay ? (
<LoadingOverlay progress={loadingProgress} showGraphBehind={hasGraphContent} />
) : null}
</div>
</div>
@@ -1530,6 +1641,37 @@ const predictionCardStyle: React.CSSProperties = {
cursor: "pointer",
};
const selectedEdgeCardStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 12,
padding: 14,
background: "linear-gradient(135deg, rgba(88, 166, 255, 0.1), rgba(88, 166, 255, 0.04))",
border: "1px solid rgba(127, 208, 255, 0.16)",
borderRadius: 14,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
};
const selectedEdgeNodeChipStyle: React.CSSProperties = {
...secondaryActionButtonStyle,
minHeight: 32,
padding: "7px 12px",
whiteSpace: "nowrap",
};
const selectedEdgePropertyGridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
gap: 8,
};
const selectedEdgePropertyCardStyle: React.CSSProperties = {
borderRadius: 10,
padding: 10,
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.06)",
};
const pluginDockContentStyle: React.CSSProperties = {
borderRadius: 16,
border: "1px solid rgba(255, 255, 255, 0.06)",
@@ -1567,12 +1709,3 @@ const timelineFallbackStyle: React.CSSProperties = {
borderTop: "1px solid rgba(88, 166, 255, 0.2)",
background: "rgba(1, 4, 9, 0.88)",
};
const loadingMetricStyle: React.CSSProperties = {
background: "rgba(255, 255, 255, 0.04)",
color: "#cfe3ff",
padding: "6px 10px",
borderRadius: 999,
fontSize: 12,
border: "1px solid rgba(127, 208, 255, 0.12)",
};
@@ -1,5 +1,7 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
import { getGraphLoadTitle } from "./graphLoading";
import { useGraphData, useReloadGraphData } from "./useGraphData";
import type {
ApiNode,
@@ -117,21 +119,6 @@ function useDebounce<T>(value: T, delay: number): T {
return debouncedValue;
}
function phaseLabel(phase: GraphLoadProgress["phase"]) {
switch (phase) {
case "nodes":
return "Loading nodes";
case "edges":
return "Loading edges";
case "styling":
return "Computing layout and styling";
case "rendering":
return "Rendering graph";
default:
return "Loading graph";
}
}
function sourceAttribution(properties: Record<string, unknown>) {
const keys = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"];
return keys
@@ -153,86 +140,6 @@ function toSelectedNodeState(node: ApiNode, neighborCount: number, fallbackColor
};
}
function LoadingOverlay({ progress }: { progress: GraphLoadProgress | null }) {
const safeProgress = progress ?? {
phase: "nodes" as const,
nodesLoaded: 0,
nodesTotal: null,
edgesLoaded: 0,
edgesTotal: null,
message: "Preparing graph load",
progress: 0.06,
};
return (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 9,
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
background: "linear-gradient(180deg, rgba(1,4,9,0.12), rgba(1,4,9,0.46))",
}}
>
<div
style={{
width: "min(460px, calc(100% - 48px))",
borderRadius: 28,
padding: "22px 22px 18px",
background: "linear-gradient(135deg, rgba(7, 17, 31, 0.9), rgba(14, 28, 48, 0.78))",
border: "1px solid rgba(127, 208, 255, 0.16)",
boxShadow: "0 24px 80px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255,255,255,0.04)",
backdropFilter: "blur(18px)",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, marginBottom: 14 }}>
<div>
<div style={{ color: "#ffffff", fontSize: 18, fontWeight: 700, marginBottom: 4 }}>Loading Graph</div>
<div style={{ color: "#8fa8c6", fontSize: 13 }}>{phaseLabel(safeProgress.phase)}</div>
</div>
<div style={{ display: "inline-flex", gap: 8, alignItems: "center" }} aria-hidden="true">
<span style={loadingDotStyle} />
<span style={{ ...loadingDotStyle, animationDelay: "0.14s" }} />
<span style={{ ...loadingDotStyle, animationDelay: "0.28s" }} />
</div>
</div>
<div style={{ color: "#c6d4e3", fontSize: 13, marginBottom: 12 }}>
{safeProgress.message}
</div>
<div style={{ width: "100%", height: 10, borderRadius: 999, overflow: "hidden", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(127,208,255,0.1)", marginBottom: 14 }}>
<span
style={{
display: "block",
height: "100%",
width: `${Math.round(Math.max(6, safeProgress.progress * 100))}%`,
borderRadius: 999,
background: "linear-gradient(90deg, rgba(74,163,255,0.9), rgba(127,208,255,0.95), rgba(242,182,109,0.92))",
boxShadow: "0 0 28px rgba(74,163,255,0.3)",
transition: "width 180ms ease",
}}
/>
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
<span style={loadingMetricStyle}>
{safeProgress.nodesLoaded.toLocaleString()}
{safeProgress.nodesTotal ? ` / ${safeProgress.nodesTotal.toLocaleString()}` : ""} nodes
</span>
<span style={loadingMetricStyle}>
{safeProgress.edgesLoaded.toLocaleString()}
{safeProgress.edgesTotal ? ` / ${safeProgress.edgesTotal.toLocaleString()}` : ""} edges
</span>
</div>
</div>
</div>
);
}
function TimelineFallback({ min, max }: TemporalBounds) {
return (
<div
@@ -686,7 +593,11 @@ export function GraphWorkspaceShell() {
onRuntimeReady={handleRuntimeReady}
/>
</Suspense>
{showLoadingOverlay ? <LoadingOverlay progress={loadingProgress} /> : null}
<GraphLoadingOverlay
progress={loadingProgress}
visible={showLoadingOverlay}
showGraphBehind={Boolean(loadingProgress?.showGraphBehind || isGraphStageReady)}
/>
</div>
<Suspense fallback={<TimelineFallback min={temporalBounds?.min ?? null} max={temporalBounds?.max ?? null} />}>
@@ -703,7 +614,7 @@ export function GraphWorkspaceShell() {
<div className="graph-status-label">Graph Studio</div>
<div className="graph-status-title">{visibleSelectedNode ? visibleSelectedNode.label : "Knowledge Explorer"}</div>
<div className="graph-status-metrics">
{showLoadingOverlay && loadingProgress ? <span style={{ ...metricPillStyle, color: "#a9ddff" }}>{phaseLabel(loadingProgress.phase)}</span> : null}
{showLoadingOverlay && loadingProgress ? <span style={{ ...metricPillStyle, color: "#a9ddff" }}>{getGraphLoadTitle(loadingProgress.phase)}</span> : null}
{layoutStatusLabel ? <span style={{ ...metricPillStyle, color: "#a9ddff" }}>{layoutStatusLabel}</span> : null}
{snapshot ? <span style={metricPillStyle}>{snapshot.summary.nodeCount.toLocaleString()} nodes · {snapshot.summary.edgeCount.toLocaleString()} edges</span> : null}
{activeNodeCount !== null ? <span style={{ ...metricPillStyle, color: "#4fd49c", borderColor: "rgba(79, 212, 156, 0.22)" }}>{activeNodeCount.toLocaleString()} active</span> : null}
@@ -891,24 +802,6 @@ const subtleChipStyle: CSSProperties = {
border: "1px solid rgba(255, 255, 255, 0.06)",
};
const loadingMetricStyle: CSSProperties = {
background: "rgba(255, 255, 255, 0.04)",
color: "#cfe3ff",
padding: "6px 10px",
borderRadius: 999,
fontSize: 12,
border: "1px solid rgba(127, 208, 255, 0.12)",
};
const loadingDotStyle: CSSProperties = {
width: 10,
height: 10,
borderRadius: 999,
background: "linear-gradient(135deg, rgba(127, 208, 255, 0.96), rgba(242, 182, 109, 0.96))",
boxShadow: "0 0 18px rgba(127, 208, 255, 0.35)",
animation: "sem-loader-pulse 1.2s ease-in-out infinite",
};
const collapseStyle: CSSProperties = {
border: "1px solid rgba(255, 255, 255, 0.05)",
borderRadius: 14,
@@ -0,0 +1,53 @@
import { forwardRef, useImperativeHandle, useRef } from "react";
import { GraphCanvas, type GraphCanvasHandle } from "./GraphCanvas";
import type { GraphSceneAdapter, GraphSceneHandle, GraphSceneProps, GraphSceneRuntime } from "./scene";
export const SigmaSceneAdapter = forwardRef<GraphSceneHandle, GraphSceneProps>(
function SigmaSceneAdapter(
{
onNodeSelect,
onEdgeSelect,
onInteractionStateChange,
onCameraStateChange,
onDiagnosticsChange,
onAnalyticsChange,
onRuntimeChange,
onLayoutRunningChange,
...sceneProps
},
ref,
) {
const canvasRef = useRef<GraphCanvasHandle>(null);
const runtimeRef = useRef<GraphSceneRuntime | null>(null);
useImperativeHandle(ref, () => ({
fitView: () => canvasRef.current?.fitView(),
focusNode: (nodeId: string) => canvasRef.current?.focusNode(nodeId),
getRuntime: () => runtimeRef.current,
setLayoutRunning: onLayoutRunningChange
? (running: boolean) => {
onLayoutRunningChange(running);
}
: undefined,
}), [onLayoutRunningChange]);
return (
<GraphCanvas
ref={canvasRef}
onNodeClick={onNodeSelect ?? (() => {})}
onEdgeClick={onEdgeSelect}
onInteractionStateChange={onInteractionStateChange}
onCameraStateChange={onCameraStateChange}
onDiagnosticsChange={onDiagnosticsChange}
onAnalyticsChange={onAnalyticsChange}
onSceneRuntimeChange={(runtime) => {
runtimeRef.current = runtime;
onRuntimeChange?.(runtime);
}}
onLayoutRunningChange={onLayoutRunningChange}
{...sceneProps}
/>
);
},
) as GraphSceneAdapter;
@@ -6,10 +6,16 @@ export const clickSelectionBehavior: GraphBehavior = {
detach: () => {},
onNodeClick: (context, nodeId) => {
context.setHoveredNodeId(nodeId);
context.onEdgeSelectionChange("");
context.onNodeSelectionChange(nodeId);
},
onEdgeClick: (context, edgeId) => {
context.setHoveredNodeId(null);
context.onEdgeSelectionChange(edgeId);
},
onStageClick: (context) => {
context.setHoveredNodeId(null);
context.onEdgeSelectionChange("");
context.onNodeSelectionChange("");
},
};
@@ -15,6 +15,7 @@ export interface GraphBehaviorContext {
getInteractionState: () => GraphInteractionState;
setHoveredNodeId: (nodeId: string | null) => void;
onNodeSelectionChange: (nodeId: string) => void;
onEdgeSelectionChange: (edgeId: string) => void;
focusNodeInView: (nodeId: string) => void;
fitCurrentView: () => void;
dispatchAction: (action: GraphBehaviorActionRequest) => void;
@@ -27,6 +28,7 @@ export interface GraphBehavior {
onNodeEnter?: (context: GraphBehaviorContext, nodeId: string) => void;
onNodeLeave?: (context: GraphBehaviorContext, nodeId: string) => void;
onNodeClick?: (context: GraphBehaviorContext, nodeId: string) => void;
onEdgeClick?: (context: GraphBehaviorContext, edgeId: string) => void;
onStageClick?: (context: GraphBehaviorContext) => void;
onCameraChange?: (context: GraphBehaviorContext, cameraState: GraphCameraState) => void;
onStateChange?: (context: GraphBehaviorContext, interactionState: GraphInteractionState) => void;
@@ -1,193 +1,683 @@
import type { ApiEdge, ApiNode } from "./types";
import type { NodeAttributes } from "../../store/graphStore";
import { GRAPH_THEME, clamp, hashString } from "./graphConfig";
import type Graph from "graphology";
import louvain from "graphology-communities-louvain";
import { dijkstra } from "graphology-shortest-path";
import betweennessCentrality from "graphology-metrics/centrality/betweenness";
import {
degreeCentrality,
inDegreeCentrality,
outDegreeCentrality,
} from "graphology-metrics/centrality/degree";
const SEMANTIC_COLOR_FIELDS = [
"community",
"cluster",
"module",
"group",
"category",
"domain",
"layer",
"source",
"nodeType",
] as const;
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
import { GRAPH_THEME, hashString } from "./graphTheme";
import type {
GraphAnalyticsSnapshot,
GraphDataSnapshot,
GraphCentralityNodeSummary,
GraphCommunitySummary,
GraphInteractionState,
GraphSemanticRegionSummary,
} from "./types";
type SemanticField = (typeof SEMANTIC_COLOR_FIELDS)[number];
type GraphRef = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
function normalizedEntropy(counts: number[], total: number): number {
if (counts.length <= 1 || total <= 0) {
return 0;
}
type GraphCentralityRecord = {
degree: number;
inDegree: number;
outDegree: number;
betweenness: number;
score: number;
};
let entropy = 0;
for (const count of counts) {
const probability = count / total;
entropy -= probability * Math.log(probability);
}
type GraphAnalyticsBase = {
communitiesByNode: Map<string, number>;
communityCount: number;
modularity: number | null;
centralityByNode: Map<string, GraphCentralityRecord>;
topNodeIds: string[];
betweennessReady: boolean;
};
return entropy / Math.log(counts.length);
const MAX_BETWEENNESS_NODES = 1400;
const MAX_COMMUNITY_SUMMARIES = 6;
const MAX_REGION_SUMMARIES = 6;
const MAX_CENTRALITY_SUMMARIES = 6;
const CENTRALITY_ITERATIONS = 24;
const MAX_BACKBONE_ANCHORS = 4;
const MAX_BACKBONE_CENTRAL_LINKS = 2;
const MAX_BACKBONE_BRIDGES = 4;
function getNodeLabel(graphRef: GraphRef, nodeId: string): string {
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
return String(attrs.label || attrs.content || nodeId);
}
function getSemanticFieldValue(attributes: Pick<NodeAttributes, "nodeType" | "properties">, field: SemanticField): string | null {
if (field === "nodeType") {
return typeof attributes.nodeType === "string" && attributes.nodeType.trim() ? attributes.nodeType : null;
function getNodeColor(graphRef: GraphRef, nodeId: string): string {
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
return String(attrs.baseColor || attrs.color || "#63E6FF");
}
function getNodeSemanticGroup(graphRef: GraphRef, nodeId: string): string {
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
return String(attrs.semanticGroup || attrs.nodeType || "entity");
}
function toVisibleNodeSet(graphRef: GraphRef, visibleNodeIds?: Iterable<string>): Set<string> {
if (!visibleNodeIds) {
return new Set(graphRef.nodes());
}
const value = attributes.properties?.[field];
return typeof value === "string" && value.trim() ? value : null;
const visible = new Set<string>();
for (const nodeId of visibleNodeIds) {
if (graphRef.hasNode(nodeId)) {
visible.add(nodeId);
}
}
return visible.size ? visible : new Set(graphRef.nodes());
}
function structuralColorKey(nodeId: string, attributes: Pick<NodeAttributes, "nodeType">): string {
const shard = hashString(nodeId) % GRAPH_THEME.nodes.palette.length;
return `${attributes.nodeType || "entity"}:${shard}`;
}
function buildCentralityRecords(
graphRef: GraphRef,
includeBetweenness: boolean,
): Pick<GraphAnalyticsBase, "centralityByNode" | "topNodeIds" | "betweennessReady"> {
const degree = degreeCentrality(graphRef);
const inDegree = inDegreeCentrality(graphRef);
const outDegree = outDegreeCentrality(graphRef);
const betweenness = includeBetweenness
? betweennessCentrality(graphRef, { normalized: true, getEdgeWeight: "weight" })
: {};
export function chooseColorAccessor(
nodes: Array<{ id: string; attributes: Pick<NodeAttributes, "nodeType" | "properties"> }>,
): (nodeId: string, attributes: Pick<NodeAttributes, "nodeType" | "properties">) => string {
let bestField: SemanticField | null = null;
let bestScore = 0;
const centralityByNode = new Map<string, GraphCentralityRecord>();
graphRef.forEachNode((nodeId) => {
const degreeScore = Number(degree[nodeId] ?? 0);
const betweennessScore = Number((betweenness as Record<string, number>)[nodeId] ?? 0);
const inDegreeScore = Number(inDegree[nodeId] ?? 0);
const outDegreeScore = Number(outDegree[nodeId] ?? 0);
centralityByNode.set(nodeId, {
degree: degreeScore,
inDegree: inDegreeScore,
outDegree: outDegreeScore,
betweenness: betweennessScore,
score: degreeScore * 0.7 + betweennessScore * 0.3,
});
});
for (const field of SEMANTIC_COLOR_FIELDS) {
const counts = new Map<string, number>();
let covered = 0;
for (const node of nodes) {
const value = getSemanticFieldValue(node.attributes, field);
if (!value) {
continue;
const topNodeIds = graphRef
.nodes()
.sort((left, right) => {
const leftScore = centralityByNode.get(left)?.score ?? 0;
const rightScore = centralityByNode.get(right)?.score ?? 0;
if (rightScore !== leftScore) {
return rightScore - leftScore;
}
covered += 1;
counts.set(value, (counts.get(value) ?? 0) + 1);
}
return left.localeCompare(right);
});
const uniqueCount = counts.size;
if (covered === 0 || uniqueCount <= 1) {
continue;
}
const countValues = [...counts.values()];
const coverage = covered / nodes.length;
const dominantRatio = Math.max(...countValues) / covered;
const entropy = normalizedEntropy(countValues, covered);
const diversity = Math.min(uniqueCount, GRAPH_THEME.nodes.palette.length) / GRAPH_THEME.nodes.palette.length;
const score = entropy * 0.6 + diversity * 0.25 + coverage * 0.15;
const informativeEnough = coverage >= 0.4 && entropy >= 0.42 && dominantRatio <= 0.8;
if (!informativeEnough) {
continue;
}
if (score > bestScore) {
bestField = field;
bestScore = score;
}
}
if (bestField) {
return (nodeId, attributes) => getSemanticFieldValue(attributes, bestField) ?? structuralColorKey(nodeId, attributes);
}
return (nodeId, attributes) => structuralColorKey(nodeId, attributes);
}
export function computeDegreeMap(nodes: ApiNode[], edges: ApiEdge[]): Map<string, number> {
const degreeByNode = new Map<string, number>();
for (const node of nodes) {
degreeByNode.set(node.id, 0);
}
for (const edge of edges) {
degreeByNode.set(edge.source, (degreeByNode.get(edge.source) ?? 0) + 1);
degreeByNode.set(edge.target, (degreeByNode.get(edge.target) ?? 0) + 1);
}
return degreeByNode;
}
export function computePageRank(nodes: ApiNode[], edges: ApiEdge[], iterations = 18, damping = 0.85): Map<string, number> {
const nodeIds = nodes.map((node) => node.id);
const nodeCount = nodeIds.length;
const score = new Map<string, number>();
const inbound = new Map<string, string[]>();
const outbound = new Map<string, number>();
for (const nodeId of nodeIds) {
score.set(nodeId, 1 / Math.max(nodeCount, 1));
inbound.set(nodeId, []);
outbound.set(nodeId, 0);
}
for (const edge of edges) {
inbound.get(edge.target)?.push(edge.source);
outbound.set(edge.source, (outbound.get(edge.source) ?? 0) + 1);
}
for (let iteration = 0; iteration < iterations; iteration += 1) {
const next = new Map<string, number>();
let danglingMass = 0;
for (const nodeId of nodeIds) {
if ((outbound.get(nodeId) ?? 0) === 0) {
danglingMass += score.get(nodeId) ?? 0;
}
}
for (const nodeId of nodeIds) {
let incoming = 0;
for (const sourceId of inbound.get(nodeId) ?? []) {
const outDegree = outbound.get(sourceId) ?? 1;
incoming += (score.get(sourceId) ?? 0) / Math.max(outDegree, 1);
}
const distributedDangling = danglingMass / Math.max(nodeCount, 1);
const nextScore = (1 - damping) / Math.max(nodeCount, 1) + damping * (incoming + distributedDangling);
next.set(nodeId, nextScore);
}
next.forEach((value, key) => score.set(key, value));
}
const maxScore = Math.max(...score.values(), 1);
score.forEach((value, key) => score.set(key, value / maxScore));
return score;
}
export function computeNodeSize(nodeId: string, degreeByNode: Map<string, number>, pageRank: Map<string, number>): number {
const degree = degreeByNode.get(nodeId) ?? 0;
const maxDegree = Math.max(...degreeByNode.values(), 1);
const normalizedLogDegree = Math.log(degree + 1) / Math.log(maxDegree + 1);
const normalizedPageRank = pageRank.get(nodeId) ?? normalizedLogDegree;
return clamp(2.5, 2.5 + 12 * normalizedLogDegree + 7 * normalizedPageRank, 22);
}
export function computeEdgeSize(weight: number | undefined): number {
const safeWeight = Number.isFinite(weight) ? Math.max(Number(weight), 0) : 0;
return clamp(0.5, 0.6 + Math.sqrt(safeWeight || 0.04), 2.4);
}
export function deterministicPosition(nodeId: string, index: number, total: number): { x: number; y: number } {
const seed = hashString(`${nodeId}:${index}:${total}`) ^ 0x9e3779b9;
let state = seed || 0x12345678;
const next = () => {
state += 0x6d2b79f5;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
const angle = next() * Math.PI * 2;
const radius = Math.sqrt(next()) * Math.min(90 + Math.sqrt(total) * 0.55, 170);
const spread = 0.82 + Math.min(index / Math.max(total, 1), 0.18);
return {
x: Math.cos(angle) * radius * spread,
y: Math.sin(angle) * radius * spread,
centralityByNode,
topNodeIds,
betweennessReady: includeBetweenness,
};
}
export function computeGraphAnalyticsBase(
graphRef: GraphRef,
options?: {
computeCommunities?: boolean;
computeCentrality?: boolean;
},
): GraphAnalyticsBase {
const shouldComputeCommunities = options?.computeCommunities ?? true;
const shouldComputeCentrality = options?.computeCentrality ?? true;
const communitiesByNode = new Map<string, number>();
let communityCount = 0;
let modularity: number | null = null;
if (shouldComputeCommunities && graphRef.order > 1 && graphRef.size > 0) {
try {
const result = louvain.detailed(graphRef, { getEdgeWeight: "weight" });
modularity = Number(result.modularity ?? 0);
communityCount = Number(result.count ?? 0);
Object.entries(result.communities).forEach(([nodeId, communityId]) => {
communitiesByNode.set(nodeId, Number(communityId));
});
} catch (error) {
console.error("[GraphAnalytics] community detection failed", error);
}
}
if (!shouldComputeCentrality || graphRef.order === 0) {
return {
communitiesByNode,
communityCount,
modularity,
centralityByNode: new Map<string, GraphCentralityRecord>(),
topNodeIds: [],
betweennessReady: false,
};
}
const includeBetweenness = graphRef.order <= MAX_BETWEENNESS_NODES;
const centrality = buildCentralityRecords(graphRef, includeBetweenness);
return {
communitiesByNode,
communityCount,
modularity,
...centrality,
};
}
function buildCommunitySummaries(
graphRef: GraphRef,
visibleNodeIds: Set<string>,
base: GraphAnalyticsBase,
): GraphCommunitySummary[] {
const grouped = new Map<number, {
nodeCount: number;
visibleNodeCount: number;
semanticCounts: Map<string, number>;
anchorNodeId: string | null;
prominence: number;
color: string;
}>();
graphRef.forEachNode((nodeId) => {
const communityId = base.communitiesByNode.get(nodeId);
if (communityId === undefined) {
return;
}
const entry = grouped.get(communityId) ?? {
nodeCount: 0,
visibleNodeCount: 0,
semanticCounts: new Map<string, number>(),
anchorNodeId: null,
prominence: 0,
color: getNodeColor(graphRef, nodeId),
};
entry.nodeCount += 1;
if (visibleNodeIds.has(nodeId)) {
entry.visibleNodeCount += 1;
}
const semanticGroup = getNodeSemanticGroup(graphRef, nodeId);
entry.semanticCounts.set(semanticGroup, (entry.semanticCounts.get(semanticGroup) ?? 0) + 1);
const nodeScore = base.centralityByNode.get(nodeId)?.score ?? 0;
if (!entry.anchorNodeId || nodeScore > (base.centralityByNode.get(entry.anchorNodeId)?.score ?? -1)) {
entry.anchorNodeId = nodeId;
entry.color = getNodeColor(graphRef, nodeId);
}
entry.prominence += visibleNodeIds.has(nodeId) ? 1 + nodeScore : nodeScore * 0.15;
grouped.set(communityId, entry);
});
return [...grouped.entries()]
.map(([communityId, data]) => {
const dominantSemanticGroup = [...data.semanticCounts.entries()]
.sort((left, right) => right[1] - left[1])[0]?.[0] ?? "entity";
return {
communityId: String(communityId),
nodeCount: data.nodeCount,
visibleNodeCount: data.visibleNodeCount,
dominantSemanticGroup,
color: data.color,
anchorNodeId: data.anchorNodeId,
anchorLabel: data.anchorNodeId ? getNodeLabel(graphRef, data.anchorNodeId) : "Community anchor",
prominence: data.prominence,
};
})
.filter((summary) => summary.visibleNodeCount > 0)
.sort((left, right) => right.prominence - left.prominence)
.slice(0, MAX_COMMUNITY_SUMMARIES);
}
function buildSemanticRegionSummaries(
graphRef: GraphRef,
visibleNodeIds: Set<string>,
base: GraphAnalyticsBase,
): GraphSemanticRegionSummary[] {
const grouped = new Map<string, {
nodeCount: number;
visibleNodeCount: number;
communityCounts: Map<string, number>;
anchorNodeId: string | null;
prominence: number;
color: string;
}>();
graphRef.forEachNode((nodeId) => {
const semanticGroup = getNodeSemanticGroup(graphRef, nodeId);
const entry = grouped.get(semanticGroup) ?? {
nodeCount: 0,
visibleNodeCount: 0,
communityCounts: new Map<string, number>(),
anchorNodeId: null,
prominence: 0,
color: getNodeColor(graphRef, nodeId),
};
entry.nodeCount += 1;
if (visibleNodeIds.has(nodeId)) {
entry.visibleNodeCount += 1;
}
const communityId = base.communitiesByNode.get(nodeId);
if (communityId !== undefined) {
const communityKey = String(communityId);
entry.communityCounts.set(communityKey, (entry.communityCounts.get(communityKey) ?? 0) + 1);
}
const nodeScore = base.centralityByNode.get(nodeId)?.score ?? 0;
if (!entry.anchorNodeId || nodeScore > (base.centralityByNode.get(entry.anchorNodeId)?.score ?? -1)) {
entry.anchorNodeId = nodeId;
entry.color = getNodeColor(graphRef, nodeId);
}
entry.prominence += visibleNodeIds.has(nodeId) ? 1 + nodeScore : nodeScore * 0.1;
grouped.set(semanticGroup, entry);
});
return [...grouped.entries()]
.map(([semanticGroup, data]) => ({
semanticGroup,
nodeCount: data.nodeCount,
visibleNodeCount: data.visibleNodeCount,
color: data.color,
anchorNodeId: data.anchorNodeId,
anchorLabel: data.anchorNodeId ? getNodeLabel(graphRef, data.anchorNodeId) : semanticGroup,
dominantCommunityId: [...data.communityCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null,
prominence: data.prominence,
}))
.filter((summary) => summary.visibleNodeCount > 0)
.sort((left, right) => right.prominence - left.prominence)
.slice(0, MAX_REGION_SUMMARIES);
}
function buildCentralitySummaries(
graphRef: GraphRef,
visibleNodeIds: Set<string>,
base: GraphAnalyticsBase,
): GraphCentralityNodeSummary[] {
const candidateIds = base.topNodeIds.filter((nodeId) => visibleNodeIds.has(nodeId));
const rankedIds = (candidateIds.length ? candidateIds : base.topNodeIds).slice(0, MAX_CENTRALITY_SUMMARIES);
return rankedIds.map((nodeId) => {
const record = base.centralityByNode.get(nodeId) ?? {
degree: 0,
betweenness: 0,
score: 0,
};
return {
id: nodeId,
label: getNodeLabel(graphRef, nodeId),
semanticGroup: getNodeSemanticGroup(graphRef, nodeId),
color: getNodeColor(graphRef, nodeId),
degree: Number(record.degree ?? 0),
betweenness: Number(record.betweenness ?? 0),
score: Number(record.score ?? 0),
};
});
}
function collectNodeIncidentEdges(
graphRef: GraphRef,
nodeId: string,
visibleNodeIds: Set<string>,
): Array<{ edgeId: string; source: string; target: string; attrs: EdgeAttributes }> {
const edges: Array<{ edgeId: string; source: string; target: string; attrs: EdgeAttributes }> = [];
graphRef.forEachOutEdge(nodeId, (edgeId, attrs, source, target) => {
if (!visibleNodeIds.has(target)) {
return;
}
edges.push({ edgeId: String(edgeId), source, target, attrs: attrs as EdgeAttributes });
});
graphRef.forEachInEdge(nodeId, (edgeId, attrs, source, target) => {
if (!visibleNodeIds.has(source)) {
return;
}
edges.push({ edgeId: String(edgeId), source, target, attrs: attrs as EdgeAttributes });
});
return edges;
}
function scoreBackboneEdge(
attrs: EdgeAttributes,
sourceId: string,
targetId: string,
base: GraphAnalyticsBase,
) {
const sourceScore = base.centralityByNode.get(sourceId)?.score ?? 0;
const targetScore = base.centralityByNode.get(targetId)?.score ?? 0;
const weight = Number(attrs.weight ?? 0);
const priority = Number(attrs.visualPriority ?? 0);
const parallelBoost = Number(attrs.parallelCount ?? 1) > 1 ? 0.14 : 0;
const bidirectionalBoost = attrs.isBidirectional ? 0.18 : 0;
return weight * 1.4 + (sourceScore + targetScore) * 2.4 + priority * 0.6 + parallelBoost + bidirectionalBoost;
}
function buildOverviewBackboneSnapshot(
graphRef: GraphRef,
visibleNodeIds: Set<string>,
base: GraphAnalyticsBase,
semanticRegionSummaries: GraphSemanticRegionSummary[],
centralitySummaries: GraphCentralityNodeSummary[],
): GraphAnalyticsSnapshot["overviewBackbone"] {
if (visibleNodeIds.size === 0) {
return {
ready: false,
reason: "No visible nodes are available for overview backbone selection.",
edgeIds: [],
};
}
const selectedEdgeIds = new Set<string>();
const regionByNode = new Map<string, string>();
visibleNodeIds.forEach((nodeId) => {
regionByNode.set(nodeId, getNodeSemanticGroup(graphRef, nodeId));
});
const topRegionIds = new Set(semanticRegionSummaries.slice(0, 3).map((summary) => summary.semanticGroup));
const backboneCoreNodeIds = new Set(
centralitySummaries
.slice(0, MAX_BACKBONE_ANCHORS * 2)
.map((summary) => summary.id)
.filter((nodeId) => visibleNodeIds.has(nodeId)),
);
const anchorIds = centralitySummaries
.slice(0, MAX_BACKBONE_ANCHORS)
.map((summary) => summary.id)
.filter((nodeId) => visibleNodeIds.has(nodeId));
const coreLinkCandidates = new Map<string, { edgeId: string; score: number }>();
anchorIds.forEach((anchorId) => {
collectNodeIncidentEdges(graphRef, anchorId, visibleNodeIds)
.filter((entry) => {
const otherNodeId = entry.source === anchorId ? entry.target : entry.source;
if (!backboneCoreNodeIds.has(otherNodeId)) {
return false;
}
const sourceRegion = regionByNode.get(entry.source);
const targetRegion = regionByNode.get(entry.target);
return Boolean(sourceRegion && targetRegion && (topRegionIds.has(sourceRegion) || topRegionIds.has(targetRegion)));
})
.forEach((entry) => {
const pairKey = [entry.source, entry.target].sort().join("::");
const sourceRegion = regionByNode.get(entry.source);
const targetRegion = regionByNode.get(entry.target);
const bridgeBoost = sourceRegion && targetRegion && sourceRegion !== targetRegion ? 0.28 : 0;
const score = scoreBackboneEdge(entry.attrs, entry.source, entry.target, base) + bridgeBoost;
const current = coreLinkCandidates.get(pairKey);
if (!current || score > current.score || (score === current.score && entry.edgeId.localeCompare(current.edgeId) < 0)) {
coreLinkCandidates.set(pairKey, { edgeId: entry.edgeId, score });
}
});
});
const bridgeByPair = new Map<string, { edgeId: string; score: number }>();
graphRef.forEachEdge((edgeId, attrs, source, target) => {
if (!visibleNodeIds.has(source) || !visibleNodeIds.has(target)) {
return;
}
const sourceRegion = regionByNode.get(source);
const targetRegion = regionByNode.get(target);
if (!sourceRegion || !targetRegion || sourceRegion === targetRegion) {
return;
}
if (!topRegionIds.has(sourceRegion) && !topRegionIds.has(targetRegion)) {
return;
}
const pairKey = [sourceRegion, targetRegion].sort().join("::");
const score = scoreBackboneEdge(attrs as EdgeAttributes, source, target, base) + 0.36;
const current = bridgeByPair.get(pairKey);
if (!current || score > current.score || (score === current.score && String(edgeId).localeCompare(current.edgeId) < 0)) {
bridgeByPair.set(pairKey, { edgeId: String(edgeId), score });
}
});
[...bridgeByPair.values()]
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.edgeId.localeCompare(right.edgeId);
})
.slice(0, MAX_BACKBONE_BRIDGES)
.forEach((entry) => selectedEdgeIds.add(entry.edgeId));
[...coreLinkCandidates.values()]
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.edgeId.localeCompare(right.edgeId);
})
.slice(0, MAX_BACKBONE_CENTRAL_LINKS)
.forEach((entry) => selectedEdgeIds.add(entry.edgeId));
const edgeIds = [...selectedEdgeIds]
.filter((edgeId) => graphRef.hasEdge(edgeId))
.sort((left, right) => left.localeCompare(right));
return {
ready: edgeIds.length > 0,
reason: edgeIds.length > 0
? "Ready"
: "No overview backbone edges met the current visibility thresholds.",
edgeIds,
};
}
function buildDirectedPathSnapshot(
graphRef: GraphRef,
interactionState: GraphInteractionState,
): GraphAnalyticsSnapshot["directedPath"] {
if (interactionState.activePath.length < 2) {
return {
ready: false,
reason: "Trace a path to compare it against local strict directed shortest pathfinding.",
sourceId: interactionState.selectedNodeId || null,
targetId: null,
path: [],
length: null,
verifiedAgainstActivePath: false,
};
}
const sourceId = interactionState.activePath[0] ?? null;
const targetId = interactionState.activePath[interactionState.activePath.length - 1] ?? null;
if (!sourceId || !targetId || !graphRef.hasNode(sourceId) || !graphRef.hasNode(targetId)) {
return {
ready: false,
reason: "Active path endpoints are not available in the current graph view.",
sourceId,
targetId,
path: [],
length: null,
verifiedAgainstActivePath: false,
};
}
try {
const path = dijkstra.bidirectional(graphRef, sourceId, targetId, "weight") ?? [];
return {
ready: path.length > 1,
reason: path.length > 1
? "Ready"
: "No strict directed shortest path found in the current graph view.",
sourceId,
targetId,
path,
length: path.length > 1 ? path.length - 1 : null,
verifiedAgainstActivePath: path.join("::") === interactionState.activePath.join("::"),
};
} catch (error) {
console.error("[GraphAnalytics] directed pathfinding failed", error);
return {
ready: false,
reason: "Directed pathfinding failed for the current graph snapshot.",
sourceId,
targetId,
path: [],
length: null,
verifiedAgainstActivePath: false,
};
}
}
export function buildGraphAnalyticsSnapshot(params: {
graphRef: GraphRef;
interactionState: GraphInteractionState;
base: GraphAnalyticsBase;
visibleNodeIds?: Iterable<string>;
}): GraphAnalyticsSnapshot {
const { graphRef, interactionState, base } = params;
const visibleNodeIds = toVisibleNodeSet(graphRef, params.visibleNodeIds);
const directedPath = buildDirectedPathSnapshot(graphRef, interactionState);
const communitySummaries = base.communitiesByNode.size
? buildCommunitySummaries(graphRef, visibleNodeIds, base)
: [];
const semanticRegionSummaries = buildSemanticRegionSummaries(graphRef, visibleNodeIds, base);
const centralitySummaries = buildCentralitySummaries(graphRef, visibleNodeIds, base);
const overviewBackbone = buildOverviewBackboneSnapshot(
graphRef,
visibleNodeIds,
base,
semanticRegionSummaries,
centralitySummaries,
);
return {
generatedAt: Date.now(),
directedPath,
communities: {
ready: communitySummaries.length > 0,
reason: communitySummaries.length > 0
? "Ready"
: base.communitiesByNode.size > 0
? "No visible communities in the current graph context."
: "Community detection has not produced summaries yet.",
count: base.communityCount,
modularity: base.modularity,
summaries: communitySummaries,
},
centrality: {
ready: centralitySummaries.length > 0,
reason: centralitySummaries.length > 0
? base.betweennessReady
? "Ready"
: "Ready (degree-biased while betweenness is bounded for large graphs)."
: "Centrality ranking is waiting for graph data.",
topNodes: centralitySummaries,
},
semanticRegions: {
ready: semanticRegionSummaries.length > 0,
reason: semanticRegionSummaries.length > 0
? "Ready"
: "No semantic regions are visible in the current graph context.",
summaries: semanticRegionSummaries,
},
overviewBackbone,
};
}
export function colorForNodeKey(key: string): string {
return GRAPH_THEME.nodes.palette[hashString(key) % GRAPH_THEME.nodes.palette.length];
const palette = GRAPH_THEME.palette.semantic;
return palette[hashString(key) % palette.length];
}
export function chooseColorAccessor(
nodes: Array<{ id: string; attributes: Pick<NodeAttributes, "nodeType" | "content"> }>,
) {
return (id: string, attributes: Pick<NodeAttributes, "nodeType" | "content">) => {
const semanticKey = String(attributes.nodeType || attributes.content || id);
const node = nodes.find((entry) => entry.id === id);
const fallbackKey = String(node?.attributes.nodeType || node?.attributes.content || semanticKey);
return `${fallbackKey}:${id}`;
};
}
export function deterministicPosition(nodeId: string, index: number, totalNodes: number) {
const angle = (hashString(nodeId) % 360) * (Math.PI / 180);
const ring = Math.floor(index / Math.max(12, Math.ceil(Math.sqrt(Math.max(totalNodes, 1)))));
const radius = 120 + ring * 90 + (hashString(`${nodeId}:radius`) % 46);
return {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
};
}
export function computeDegreeMap(
nodes: GraphDataSnapshot["nodes"],
edges: GraphDataSnapshot["edges"],
) {
const degreeByNode = new Map<string, number>();
nodes.forEach((node) => degreeByNode.set(node.id, 0));
edges.forEach((edge) => {
degreeByNode.set(edge.source, (degreeByNode.get(edge.source) ?? 0) + 1);
degreeByNode.set(edge.target, (degreeByNode.get(edge.target) ?? 0) + 1);
});
return degreeByNode;
}
export function computePageRank(
nodes: GraphDataSnapshot["nodes"],
edges: GraphDataSnapshot["edges"],
) {
const pageRank = new Map<string, number>();
const outbound = new Map<string, string[]>();
const inbound = new Map<string, string[]>();
const nodeCount = Math.max(nodes.length, 1);
nodes.forEach((node) => {
pageRank.set(node.id, 1 / nodeCount);
outbound.set(node.id, []);
inbound.set(node.id, []);
});
edges.forEach((edge) => {
outbound.set(edge.source, [...(outbound.get(edge.source) ?? []), edge.target]);
inbound.set(edge.target, [...(inbound.get(edge.target) ?? []), edge.source]);
});
for (let iteration = 0; iteration < CENTRALITY_ITERATIONS; iteration += 1) {
const next = new Map<string, number>();
nodes.forEach((node) => {
const incoming = inbound.get(node.id) ?? [];
let sum = 0;
incoming.forEach((sourceId) => {
const outDegree = (outbound.get(sourceId) ?? []).length || nodeCount;
sum += (pageRank.get(sourceId) ?? 0) / outDegree;
});
next.set(node.id, 0.15 / nodeCount + 0.85 * sum);
});
next.forEach((value, nodeId) => pageRank.set(nodeId, value));
}
return pageRank;
}
export function computeNodeSize(
nodeId: string,
degreeByNode: Map<string, number>,
pageRankByNode: Map<string, number>,
) {
const degree = degreeByNode.get(nodeId) ?? 0;
const pageRank = pageRankByNode.get(nodeId) ?? 0;
return 5.5 + Math.min(16, degree * 0.18 + pageRank * 160);
}
export function computeEdgeSize(weight: number) {
return Math.max(0.8, Math.min(3.2, 0.9 + Math.log2(Math.max(weight, 1) + 1) * 0.36));
}
@@ -0,0 +1,84 @@
import type { GraphLoadPhase, GraphLoadProgress, GraphLoadProgressKind, GraphLayoutSource, GraphLayoutState } from "./types";
export const GRAPH_LOAD_STAGE_SEQUENCE: Exclude<GraphLoadPhase, "ready">[] = [
"bootstrapping",
"fetching_nodes",
"fetching_edges",
"computing_styling",
"hydrating_scene",
"stabilizing_layout",
];
export function getGraphLoadTitle(phase: GraphLoadPhase): string {
switch (phase) {
case "bootstrapping":
return "Preparing graph session";
case "fetching_nodes":
return "Loading nodes";
case "fetching_edges":
return "Loading relationships";
case "computing_styling":
return "Computing node styling";
case "hydrating_scene":
return "Hydrating graph scene";
case "stabilizing_layout":
return "Stabilizing layout";
case "ready":
default:
return "Graph ready";
}
}
export function getGraphLoadStageLabel(phase: Exclude<GraphLoadPhase, "ready">): string {
switch (phase) {
case "bootstrapping":
return "Prepare";
case "fetching_nodes":
return "Nodes";
case "fetching_edges":
return "Relations";
case "computing_styling":
return "Styling";
case "hydrating_scene":
return "Scene";
case "stabilizing_layout":
return "Layout";
default:
return "Stage";
}
}
export function createGraphLoadProgress(input: {
phase: GraphLoadPhase;
message: string;
progressKind: GraphLoadProgressKind;
loaded?: number | null;
total?: number | null;
nodesLoaded?: number;
nodesTotal?: number | null;
edgesLoaded?: number;
edgesTotal?: number | null;
showGraphBehind?: boolean;
layoutSource?: GraphLayoutSource;
layoutState?: GraphLayoutState;
}): GraphLoadProgress {
const phaseIndex = GRAPH_LOAD_STAGE_SEQUENCE.indexOf(input.phase as Exclude<GraphLoadPhase, "ready">);
return {
phase: input.phase,
title: getGraphLoadTitle(input.phase),
message: input.message,
progressKind: input.progressKind,
loaded: input.loaded ?? null,
total: input.total ?? null,
nodesLoaded: input.nodesLoaded ?? 0,
nodesTotal: input.nodesTotal ?? null,
edgesLoaded: input.edgesLoaded ?? 0,
edgesTotal: input.edgesTotal ?? null,
showGraphBehind: input.showGraphBehind ?? false,
stageIndex: phaseIndex >= 0 ? phaseIndex + 1 : GRAPH_LOAD_STAGE_SEQUENCE.length,
stageCount: GRAPH_LOAD_STAGE_SEQUENCE.length,
layoutSource: input.layoutSource,
layoutState: input.layoutState,
};
}
@@ -0,0 +1,775 @@
import type Graph from "graphology";
import Sigma from "sigma";
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
import { blendHex, GRAPH_THEME, withAlpha, zoomTierAtLeast } from "./graphTheme";
import type {
GraphAnalyticsSnapshot,
GraphEffectsState,
GraphInteractionState,
GraphTemporalState,
} from "./types";
type GraphRef = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
export type ViewportPoint = { x: number; y: number };
export type PathSegmentOverlay = {
sourceId: string;
targetId: string;
source: ViewportPoint;
target: ViewportPoint;
color: string;
size: number;
};
export type VisibleNodeSample = {
nodeId: string;
point: ViewportPoint;
size: number;
attrs: NodeAttributes;
};
function isPointNearViewport(point: ViewportPoint, width: number, height: number, padding = 96) {
return point.x >= -padding
&& point.y >= -padding
&& point.x <= width + padding
&& point.y <= height + padding;
}
function drawGlowHalo(
context: CanvasRenderingContext2D,
x: number,
y: number,
radius: number,
color: string,
) {
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, color);
gradient.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = gradient;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
}
function createScratchCanvas(width: number, height: number) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
return canvas;
}
function parseCssColor(color: string) {
if (color.startsWith("#")) {
const hex = color.slice(1);
const normalized = hex.length === 3
? hex.split("").map((char) => `${char}${char}`).join("")
: hex;
if (normalized.length === 6) {
return {
r: Number.parseInt(normalized.slice(0, 2), 16),
g: Number.parseInt(normalized.slice(2, 4), 16),
b: Number.parseInt(normalized.slice(4, 6), 16),
};
}
}
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (match) {
return {
r: Number.parseInt(match[1], 10),
g: Number.parseInt(match[2], 10),
b: Number.parseInt(match[3], 10),
};
}
return { r: 130, g: 145, b: 165 };
}
function createThresholdMask(
alphaValues: Uint8ClampedArray,
width: number,
height: number,
threshold: number,
) {
const mask = new Uint8Array(width * height);
let count = 0;
let sumX = 0;
let sumY = 0;
let sumWeight = 0;
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = y * width + x;
const alpha = alphaValues[index];
if (alpha < threshold) {
continue;
}
mask[index] = 1;
count += 1;
sumX += x * alpha;
sumY += y * alpha;
sumWeight += alpha;
}
}
const centroid = sumWeight > 0
? { x: sumX / sumWeight, y: sumY / sumWeight }
: { x: width / 2, y: height / 2 };
return { mask, count, centroid };
}
function blurAlphaValues(
alphaValues: Uint8ClampedArray,
width: number,
height: number,
passes: number,
) {
if (passes <= 0) {
return alphaValues;
}
let source = alphaValues;
for (let pass = 0; pass < passes; pass += 1) {
const next = new Uint8ClampedArray(width * height);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let sum = 0;
let samples = 0;
for (let oy = -1; oy <= 1; oy += 1) {
const sampleY = y + oy;
if (sampleY < 0 || sampleY >= height) {
continue;
}
for (let ox = -1; ox <= 1; ox += 1) {
const sampleX = x + ox;
if (sampleX < 0 || sampleX >= width) {
continue;
}
sum += source[sampleY * width + sampleX];
samples += 1;
}
}
next[y * width + x] = Math.round(sum / Math.max(samples, 1));
}
}
source = next;
}
return source;
}
function isolateDominantMask(
mask: Uint8Array,
width: number,
height: number,
alphaValues: Uint8ClampedArray,
) {
const visited = new Uint8Array(mask.length);
const queue = new Int32Array(mask.length);
let bestPixels: number[] = [];
let totalCount = 0;
for (let index = 0; index < mask.length; index += 1) {
if (!mask[index] || visited[index]) {
continue;
}
let head = 0;
let tail = 0;
const component: number[] = [];
visited[index] = 1;
queue[tail++] = index;
while (head < tail) {
const current = queue[head++];
component.push(current);
const x = current % width;
const y = Math.floor(current / width);
const neighbors = [
current - 1,
current + 1,
current - width,
current + width,
];
for (let n = 0; n < neighbors.length; n += 1) {
const neighbor = neighbors[n];
if (neighbor < 0 || neighbor >= mask.length || visited[neighbor] || !mask[neighbor]) {
continue;
}
if ((n === 0 && x === 0) || (n === 1 && x === width - 1) || (n === 2 && y === 0) || (n === 3 && y === height - 1)) {
continue;
}
visited[neighbor] = 1;
queue[tail++] = neighbor;
}
}
totalCount += component.length;
if (component.length > bestPixels.length) {
bestPixels = component;
}
}
const dominantMask = new Uint8Array(mask.length);
let sumX = 0;
let sumY = 0;
let sumWeight = 0;
bestPixels.forEach((index) => {
dominantMask[index] = 1;
const alpha = alphaValues[index];
const x = index % width;
const y = Math.floor(index / width);
sumX += x * alpha;
sumY += y * alpha;
sumWeight += alpha;
});
return {
mask: dominantMask,
count: bestPixels.length,
centroid: sumWeight > 0
? { x: sumX / sumWeight, y: sumY / sumWeight }
: { x: width / 2, y: height / 2 },
occupancyRatio: bestPixels.length / Math.max(width * height, 1),
dominantMassRatio: bestPixels.length / Math.max(totalCount, 1),
};
}
function renderDensityField(
samples: VisibleNodeSample[],
width: number,
height: number,
) {
const scale = GRAPH_THEME.effects.semanticRegions.fogResolutionScale;
const gridWidth = Math.max(72, Math.round(width * scale));
const gridHeight = Math.max(72, Math.round(height * scale));
const canvas = createScratchCanvas(gridWidth, gridHeight);
const context = canvas.getContext("2d");
if (!context) {
return null;
}
context.clearRect(0, 0, gridWidth, gridHeight);
context.globalCompositeOperation = "source-over";
samples.forEach((sample) => {
const x = sample.point.x * scale;
const y = sample.point.y * scale;
const radius = Math.max(
2.5,
(GRAPH_THEME.effects.semanticRegions.splatRadius + sample.size * 0.9) * scale,
);
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, "rgba(255,255,255,0.22)");
gradient.addColorStop(0.58, "rgba(255,255,255,0.08)");
gradient.addColorStop(1, "rgba(255,255,255,0)");
context.fillStyle = gradient;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
});
const imageData = context.getImageData(0, 0, gridWidth, gridHeight);
const alphaValues = new Uint8ClampedArray(gridWidth * gridHeight);
let maxAlpha = 0;
for (let index = 0; index < alphaValues.length; index += 1) {
const alpha = imageData.data[index * 4 + 3];
alphaValues[index] = alpha;
if (alpha > maxAlpha) {
maxAlpha = alpha;
}
}
if (maxAlpha <= 0) {
return null;
}
const blurredAlphaValues = blurAlphaValues(
alphaValues,
gridWidth,
gridHeight,
GRAPH_THEME.effects.semanticRegions.blurPasses,
);
const blurredMaxAlpha = blurredAlphaValues.reduce((value, alpha) => Math.max(value, alpha), 0);
if (blurredMaxAlpha <= 0) {
return null;
}
return { canvas, gridWidth, gridHeight, alphaValues: blurredAlphaValues, maxAlpha: blurredMaxAlpha, scale };
}
function drawDensityFog(
context: CanvasRenderingContext2D,
densityField: ReturnType<typeof renderDensityField>,
color: string,
alpha: number,
) {
if (!densityField) {
return;
}
const { gridWidth, gridHeight, alphaValues } = densityField;
const fogCanvas = createScratchCanvas(gridWidth, gridHeight);
const fogContext = fogCanvas.getContext("2d");
if (!fogContext) {
return;
}
const imageData = fogContext.createImageData(gridWidth, gridHeight);
const rgb = parseCssColor(color);
for (let index = 0; index < alphaValues.length; index += 1) {
const sourceAlpha = alphaValues[index] / 255;
if (sourceAlpha <= 0) {
continue;
}
const pixelIndex = index * 4;
imageData.data[pixelIndex] = rgb.r;
imageData.data[pixelIndex + 1] = rgb.g;
imageData.data[pixelIndex + 2] = rgb.b;
imageData.data[pixelIndex + 3] = Math.round(255 * Math.pow(sourceAlpha, 1.7) * alpha);
}
fogContext.putImageData(imageData, 0, 0);
context.save();
context.imageSmoothingEnabled = true;
context.globalCompositeOperation = "lighter";
context.drawImage(fogCanvas, 0, 0, context.canvas.width, context.canvas.height);
context.restore();
}
function drawMaskContour(
context: CanvasRenderingContext2D,
mask: Uint8Array,
gridWidth: number,
gridHeight: number,
color: string,
alpha: number,
) {
const contourCanvas = createScratchCanvas(gridWidth, gridHeight);
const contourContext = contourCanvas.getContext("2d");
if (!contourContext) {
return;
}
const imageData = contourContext.createImageData(gridWidth, gridHeight);
const rgb = parseCssColor(color);
for (let y = 1; y < gridHeight - 1; y += 1) {
for (let x = 1; x < gridWidth - 1; x += 1) {
const index = y * gridWidth + x;
if (!mask[index]) {
continue;
}
const isEdge = !mask[index - 1]
|| !mask[index + 1]
|| !mask[index - gridWidth]
|| !mask[index + gridWidth];
if (!isEdge) {
continue;
}
const pixelIndex = index * 4;
imageData.data[pixelIndex] = rgb.r;
imageData.data[pixelIndex + 1] = rgb.g;
imageData.data[pixelIndex + 2] = rgb.b;
imageData.data[pixelIndex + 3] = Math.round(255 * alpha);
}
}
contourContext.putImageData(imageData, 0, 0);
context.save();
context.imageSmoothingEnabled = true;
context.drawImage(contourCanvas, 0, 0, context.canvas.width, context.canvas.height);
context.restore();
}
function buildRegionRenderColors(color: string) {
return {
fogColor: blendHex("#0E1927", color, 0.46),
innerContourColor: blendHex("#1C2C3F", color, 0.78),
outerContourColor: blendHex("#132131", color, 0.6),
labelBorderColor: blendHex("#2B3F57", color, 0.78),
};
}
export function collectVisibleNodeSamples(
sigma: Sigma,
graphRef: GraphRef,
viewportWidth: number,
viewportHeight: number,
): VisibleNodeSample[] {
const samples: VisibleNodeSample[] = [];
graphRef.forEachNode((nodeId, attrs) => {
const displayData = sigma.getNodeDisplayData(nodeId);
if (!displayData) {
return;
}
const point = sigma.graphToViewport({ x: displayData.x, y: displayData.y });
if (!isPointNearViewport(point, viewportWidth, viewportHeight, 84)) {
return;
}
samples.push({
nodeId,
point,
size: displayData.size,
attrs: attrs as NodeAttributes,
});
});
return samples;
}
function drawRegionLabel(
context: CanvasRenderingContext2D,
x: number,
y: number,
text: string,
color: string,
) {
const label = text.length > 22 ? `${text.slice(0, 21)}` : text;
context.save();
context.font = `600 11px "IBM Plex Sans", Inter, system-ui, sans-serif`;
context.textBaseline = "middle";
const width = context.measureText(label).width + 14;
const height = 22;
const left = x - width / 2;
const top = y - height / 2;
context.fillStyle = "rgba(8, 14, 24, 0.84)";
context.strokeStyle = withAlpha(color, 0.24);
context.lineWidth = 1;
context.beginPath();
context.roundRect(left, top, width, height, 999);
context.fill();
context.stroke();
context.fillStyle = "rgba(235, 244, 255, 0.88)";
context.fillText(label, left + 7, y);
context.restore();
}
export function drawSemanticRegionsLayer(
context: CanvasRenderingContext2D,
analytics: GraphAnalyticsSnapshot | null,
visibleNodes: VisibleNodeSample[],
interactionState: GraphInteractionState,
effectsState: GraphEffectsState,
) {
if (!effectsState.semanticRegionsEnabled || !analytics?.semanticRegions.ready) {
return;
}
if (!zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.semanticRegions.minZoomTier)) {
return;
}
const visibleByGroup = new Map<string, VisibleNodeSample[]>();
visibleNodes.forEach((sample) => {
const group = String(sample.attrs.semanticGroup || sample.attrs.nodeType || "entity");
const entry = visibleByGroup.get(group);
if (entry) {
entry.push(sample);
} else {
visibleByGroup.set(group, [sample]);
}
});
const summaries = analytics.semanticRegions.summaries
.slice(0, GRAPH_THEME.effects.semanticRegions.maxRegions);
const maxProminence = summaries.reduce((value, summary) => Math.max(value, summary.prominence), 1);
const semanticConfig = GRAPH_THEME.effects.semanticRegions;
summaries.forEach((summary) => {
const samples = visibleByGroup.get(summary.semanticGroup);
if (!samples || samples.length < semanticConfig.minVisibleSamples) {
return;
}
const densityField = renderDensityField(samples, context.canvas.width, context.canvas.height);
if (!densityField) {
return;
}
const prominenceRatio = 0.52 + (summary.prominence / maxProminence) * 0.48;
const densityThreshold = Math.max(18, densityField.maxAlpha * semanticConfig.densityThreshold);
const contourThreshold = Math.max(28, densityField.maxAlpha * semanticConfig.contourThreshold);
const outerContourThreshold = Math.max(18, contourThreshold * 0.72);
const densityMask = createThresholdMask(
densityField.alphaValues,
densityField.gridWidth,
densityField.gridHeight,
densityThreshold,
);
const dominantDensityMask = isolateDominantMask(
densityMask.mask,
densityField.gridWidth,
densityField.gridHeight,
densityField.alphaValues,
);
if (
dominantDensityMask.count < semanticConfig.minMaskPixels
|| dominantDensityMask.occupancyRatio < semanticConfig.minOccupancyRatio
|| dominantDensityMask.dominantMassRatio < semanticConfig.dominantMassRatio
) {
return;
}
const contourMask = createThresholdMask(
densityField.alphaValues,
densityField.gridWidth,
densityField.gridHeight,
contourThreshold,
);
const outerContourMask = createThresholdMask(
densityField.alphaValues,
densityField.gridWidth,
densityField.gridHeight,
outerContourThreshold,
);
const dominantContourMask = isolateDominantMask(
contourMask.mask,
densityField.gridWidth,
densityField.gridHeight,
densityField.alphaValues,
);
const dominantOuterContourMask = isolateDominantMask(
outerContourMask.mask,
densityField.gridWidth,
densityField.gridHeight,
densityField.alphaValues,
);
const colors = buildRegionRenderColors(summary.color);
drawDensityFog(
context,
densityField,
colors.fogColor,
semanticConfig.fogAlpha * prominenceRatio,
);
if (
dominantOuterContourMask.count >= semanticConfig.outerContourMinMaskPixels
&& dominantOuterContourMask.dominantMassRatio >= semanticConfig.dominantMassRatio
) {
drawMaskContour(
context,
dominantOuterContourMask.mask,
densityField.gridWidth,
densityField.gridHeight,
colors.outerContourColor,
semanticConfig.outerContourAlpha * prominenceRatio,
);
}
drawMaskContour(
context,
dominantContourMask.mask,
densityField.gridWidth,
densityField.gridHeight,
colors.innerContourColor,
semanticConfig.innerContourAlpha * prominenceRatio,
);
if (summary.visibleNodeCount >= 18) {
const labelX = dominantDensityMask.centroid.x / densityField.scale;
const labelY = dominantDensityMask.centroid.y / densityField.scale - 18;
drawRegionLabel(
context,
labelX,
labelY,
summary.semanticGroup,
colors.labelBorderColor,
);
}
});
}
export function drawContourLayer(
context: CanvasRenderingContext2D,
analytics: GraphAnalyticsSnapshot | null,
visibleNodes: VisibleNodeSample[],
interactionState: GraphInteractionState,
effectsState: GraphEffectsState,
) {
if (!effectsState.contoursEnabled || !analytics?.centrality.ready) {
return;
}
if (!zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.contours.minZoomTier)) {
return;
}
const visibleById = new Map(visibleNodes.map((sample) => [sample.nodeId, sample] as const));
analytics.centrality.topNodes.slice(0, GRAPH_THEME.effects.contours.maxContours).forEach((node) => {
const sample = visibleById.get(node.id);
if (!sample) {
return;
}
drawGlowHalo(
context,
sample.point.x,
sample.point.y,
GRAPH_THEME.effects.contours.baseRadius + sample.size * 3.2,
withAlpha(node.color, GRAPH_THEME.effects.contours.glowAlpha),
);
});
}
function isTemporalNodeActive(attrs: NodeAttributes, currentTime: Date | null) {
if (!currentTime || (!attrs.valid_from && !attrs.valid_until)) {
return false;
}
const time = currentTime.getTime();
const from = attrs.valid_from ? new Date(attrs.valid_from).getTime() : Number.NEGATIVE_INFINITY;
const until = attrs.valid_until ? new Date(attrs.valid_until).getTime() : Number.POSITIVE_INFINITY;
return time >= from && time <= until;
}
export function drawTemporalEmphasisLayer(
context: CanvasRenderingContext2D,
visibleNodes: VisibleNodeSample[],
temporalState: GraphTemporalState | null | undefined,
interactionState: GraphInteractionState,
effectsState: GraphEffectsState,
) {
if (!effectsState.temporalEmphasisEnabled || !temporalState?.currentTime) {
return;
}
if (!zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.temporalEmphasis.minZoomTier)) {
return;
}
visibleNodes
.filter((sample) => isTemporalNodeActive(sample.attrs, temporalState.currentTime))
.slice(0, GRAPH_THEME.effects.temporalEmphasis.maxHighlights)
.forEach((sample) => {
drawGlowHalo(
context,
sample.point.x,
sample.point.y,
Math.max(sample.size * GRAPH_THEME.effects.temporalEmphasis.radiusMultiplier, 14),
withAlpha(GRAPH_THEME.palette.accent.temporal, GRAPH_THEME.effects.temporalEmphasis.glowAlpha),
);
});
}
export function drawLensLayer(
context: CanvasRenderingContext2D,
sigma: Sigma,
primaryNodeId: string,
focusIds: Set<string>,
) {
const primaryData = sigma.getNodeDisplayData(primaryNodeId);
if (!primaryData) {
return;
}
const center = sigma.graphToViewport({ x: primaryData.x, y: primaryData.y });
drawGlowHalo(
context,
center.x,
center.y,
GRAPH_THEME.effects.lens.radius,
withAlpha(GRAPH_THEME.palette.accent.hovered, GRAPH_THEME.effects.lens.glowAlpha),
);
focusIds.forEach((neighborId) => {
if (neighborId === primaryNodeId || !graph.hasNode(neighborId)) {
return;
}
if (
!graph.hasDirectedEdge(primaryNodeId, neighborId)
&& !graph.hasDirectedEdge(neighborId, primaryNodeId)
) {
return;
}
const neighborData = sigma.getNodeDisplayData(neighborId);
if (!neighborData) {
return;
}
const neighborPoint = sigma.graphToViewport({ x: neighborData.x, y: neighborData.y });
context.strokeStyle = withAlpha(GRAPH_THEME.palette.accent.hovered, GRAPH_THEME.effects.lens.edgeAlpha * 0.38);
context.lineWidth = GRAPH_THEME.effects.lens.edgeLineWidth + 3.2;
context.lineCap = "round";
context.beginPath();
context.moveTo(center.x, center.y);
context.lineTo(neighborPoint.x, neighborPoint.y);
context.stroke();
context.strokeStyle = withAlpha(GRAPH_THEME.palette.accent.hovered, GRAPH_THEME.effects.lens.edgeAlpha);
context.lineWidth = GRAPH_THEME.effects.lens.edgeLineWidth;
context.beginPath();
context.moveTo(center.x, center.y);
context.lineTo(neighborPoint.x, neighborPoint.y);
context.stroke();
});
}
export function drawPathEffectsLayer(
context: CanvasRenderingContext2D,
segments: PathSegmentOverlay[],
effectsState: GraphEffectsState,
effectAvailability: {
pathPulse: { available: boolean };
pathFlow: { available: boolean };
},
now: number,
) {
if (effectsState.pathFlowEnabled && effectAvailability.pathFlow.available) {
segments.forEach((segment, index) => {
const t = ((now * GRAPH_THEME.effects.pathFlow.speed) + index * GRAPH_THEME.effects.pathFlow.spacing) % 1;
const headX = segment.source.x + (segment.target.x - segment.source.x) * t;
const headY = segment.source.y + (segment.target.y - segment.source.y) * t;
const tailT = Math.max(0, t - 0.08);
const tailX = segment.source.x + (segment.target.x - segment.source.x) * tailT;
const tailY = segment.source.y + (segment.target.y - segment.source.y) * tailT;
context.strokeStyle = withAlpha(segment.color, 0.28);
context.lineWidth = Math.max(segment.size + 3.6, 4.6);
context.lineCap = "round";
context.beginPath();
context.moveTo(tailX, tailY);
context.lineTo(headX, headY);
context.stroke();
context.strokeStyle = withAlpha(GRAPH_THEME.palette.accent.selected, GRAPH_THEME.effects.pathFlow.opacity);
context.lineWidth = Math.max(segment.size + 1.3, 2.4);
context.beginPath();
context.moveTo(tailX, tailY);
context.lineTo(headX, headY);
context.stroke();
});
}
if (effectsState.pathPulseEnabled && effectAvailability.pathPulse.available) {
segments.forEach((segment, index) => {
const t = ((now * GRAPH_THEME.effects.pathPulse.speed) + index * 0.17) % 1;
const x = segment.source.x + (segment.target.x - segment.source.x) * t;
const y = segment.source.y + (segment.target.y - segment.source.y) * t;
const glow = context.createRadialGradient(x, y, 0, x, y, GRAPH_THEME.effects.pathPulse.radius);
glow.addColorStop(0, withAlpha(GRAPH_THEME.palette.accent.path, GRAPH_THEME.effects.pathPulse.glowAlpha));
glow.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = glow;
context.beginPath();
context.arc(x, y, GRAPH_THEME.effects.pathPulse.radius, 0, Math.PI * 2);
context.fill();
});
}
}
@@ -0,0 +1,977 @@
import Graph from "graphology";
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
import {
clamp,
blendHex,
GRAPH_THEME,
type GraphArrowVisibilityPolicy,
type GraphBadgeKind,
type GraphEdgeVariant,
type GraphEdgeVisualState,
type GraphLabelVisibilityPolicy,
type GraphNodeShapeVariant,
type GraphNodeVisualState,
type GraphTheme,
type GraphZoomTier,
withAlpha,
zoomTierAtLeast,
} from "./graphTheme";
import type { GraphInteractionState, GraphViewMode } from "./types";
const MAX_FOCUS_NEIGHBORS = GRAPH_THEME.focus.maxNeighbors;
const FOCUS_RING_CAPACITY = GRAPH_THEME.focus.ringCapacity;
const FOCUS_RING_GAP = GRAPH_THEME.focus.ringGap;
const FOCUS_PRIMARY_LABELS = GRAPH_THEME.focus.primaryLabels;
function getOverviewPresenceBoost(cameraRatio: number) {
return clamp(0, Math.log2(Math.max(cameraRatio, 1)) / 1.85, 1);
}
export type GraphSigmaEdgeType = "line" | "arrow" | "curve" | "curvedArrow";
export type ResolvedNodeStyle = {
color: string;
shellColor: string;
coreScale: number;
size: number;
forceLabel: boolean;
label: string;
zIndex: number;
hidden: boolean;
borderColor: string;
borderSize: number;
nodeVariant: GraphNodeShapeVariant;
badgeKind?: GraphBadgeKind;
badgeCount?: number;
showBadge: boolean;
showRing: boolean;
ringColor?: string;
ringSize: number;
showHalo: boolean;
haloColor: string;
};
export type ResolvedEdgeStyle = {
hidden: boolean;
type?: GraphSigmaEdgeType;
color?: string;
size?: number;
zIndex: number;
edgeVariant: GraphEdgeVariant;
arrowVisibilityPolicy: GraphArrowVisibilityPolicy;
curveStrength: number;
curvature: number;
};
function forEachDirectedEdgeBetween(
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
source: string,
target: string,
callback: (edgeId: string, attrs: EdgeAttributes) => void,
) {
graphRef.forEachDirectedEdge(source, target, (edgeId, attrs) => {
callback(String(edgeId), attrs as EdgeAttributes);
});
}
function collectDirectedEdgeIdsBetween(
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
source: string,
target: string,
): string[] {
const edgeIds: string[] = [];
forEachDirectedEdgeBetween(graphRef, source, target, (edgeId) => {
edgeIds.push(edgeId);
});
return edgeIds;
}
export function buildPathEdgeSet(
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
path: string[],
pathEdgeIds: string[] = [],
): Set<string> {
if (pathEdgeIds.length > 0) {
return new Set<string>(pathEdgeIds.filter((edgeId) => graphRef.hasEdge(edgeId)));
}
const edgeIds = new Set<string>();
for (let index = 0; index < path.length - 1; index += 1) {
collectDirectedEdgeIdsBetween(graphRef, path[index], path[index + 1]).forEach((edgeId) => edgeIds.add(edgeId));
}
return edgeIds;
}
export function buildEdgeEndpointSet(
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
...edgeIds: Array<string | null | undefined>
): Set<string> {
const nodeIds = new Set<string>();
edgeIds.forEach((edgeId) => {
if (!edgeId || !graphRef.hasEdge(edgeId)) {
return;
}
const [source, target] = graphRef.extremities(edgeId);
nodeIds.add(source);
nodeIds.add(target);
});
return nodeIds;
}
function collectFocusEdgeIds(
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
nodeIds: Set<string>,
): Set<string> {
const edgeIds = new Set<string>();
const ids = Array.from(nodeIds);
for (let sourceIndex = 0; sourceIndex < ids.length; sourceIndex += 1) {
const source = ids[sourceIndex];
for (let targetIndex = 0; targetIndex < ids.length; targetIndex += 1) {
const target = ids[targetIndex];
if (source === target) {
continue;
}
collectDirectedEdgeIdsBetween(graphRef, source, target).forEach((edgeId) => edgeIds.add(edgeId));
}
}
return edgeIds;
}
function collectImpactedNodeIds(
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
interactionState: GraphInteractionState | null,
): Set<string> {
if (!interactionState) {
return new Set<string>();
}
const impacted = new Set<string>(interactionState.activePath);
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
buildFocusSet(primaryNodeId).forEach((nodeId) => impacted.add(nodeId));
}
buildEdgeEndpointSet(graphRef, interactionState.selectedEdgeId)
.forEach((nodeId) => impacted.add(nodeId));
return impacted;
}
function collectImpactedEdgeKeys(
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
interactionState: GraphInteractionState | null,
): Set<string> {
if (!interactionState) {
return new Set<string>();
}
const impacted = new Set<string>(buildPathEdgeSet(graphRef, interactionState.activePath, interactionState.activePathEdgeIds));
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
collectFocusEdgeIds(graphRef, buildFocusSet(primaryNodeId)).forEach((edgeId) => impacted.add(edgeId));
}
if (interactionState.selectedEdgeId) {
impacted.add(interactionState.selectedEdgeId);
}
return impacted;
}
function resolveDisplayEdgeIds(
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
stableEdgeIds: Set<string>,
): string[] {
return Array.from(stableEdgeIds).filter((edgeId) => graphRef.hasEdge(edgeId));
}
export function collectInteractionRefreshTargets(
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
previousState: GraphInteractionState | null,
nextState: GraphInteractionState,
): { nodes: string[]; edges: string[] } {
const nodeIds = new Set<string>();
const edgeKeys = new Set<string>();
collectImpactedNodeIds(graphRef, previousState).forEach((nodeId) => nodeIds.add(nodeId));
collectImpactedNodeIds(graphRef, nextState).forEach((nodeId) => nodeIds.add(nodeId));
collectImpactedEdgeKeys(graphRef, previousState).forEach((edgeId) => edgeKeys.add(edgeId));
collectImpactedEdgeKeys(graphRef, nextState).forEach((edgeId) => edgeKeys.add(edgeId));
return {
nodes: Array.from(nodeIds).filter((nodeId) => graphRef.hasNode(nodeId)),
edges: resolveDisplayEdgeIds(graphRef, edgeKeys),
};
}
export function getEdgeWeightBetween(source: string, target: string): number {
let weight = 0;
forEachDirectedEdgeBetween(graph, source, target, (_edgeId, attrs) => {
weight = Math.max(weight, Number(attrs?.weight ?? 0));
});
forEachDirectedEdgeBetween(graph, target, source, (_edgeId, attrs) => {
weight = Math.max(weight, Number(attrs?.weight ?? 0));
});
return weight;
}
export function rankNeighbors(nodeId: string): string[] {
return graph
.neighbors(nodeId)
.map((neighborId) => ({
id: neighborId,
weight: getEdgeWeightBetween(nodeId, neighborId),
degree: graph.degree(neighborId),
}))
.sort((left, right) => {
if (right.weight !== left.weight) {
return right.weight - left.weight;
}
if (right.degree !== left.degree) {
return right.degree - left.degree;
}
return left.id.localeCompare(right.id);
})
.map((item) => item.id);
}
export function buildFocusSet(nodeId: string): Set<string> {
const ranked = rankNeighbors(nodeId).slice(0, MAX_FOCUS_NEIGHBORS);
return new Set<string>([nodeId, ...ranked]);
}
export function isEdgeInteractable(
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
interactionState: GraphInteractionState,
edgeId: string,
source: string,
target: string,
attrs: EdgeAttributes,
): boolean {
const pathEdgeIds = buildPathEdgeSet(graphRef, interactionState.activePath, interactionState.activePathEdgeIds);
if (pathEdgeIds.has(edgeId) || interactionState.selectedEdgeId === edgeId) {
return true;
}
const primaryNodeId = interactionState.selectedNodeId;
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
if (source === primaryNodeId || target === primaryNodeId) {
return true;
}
const focusIds = buildFocusSet(primaryNodeId);
return focusIds.has(source) && focusIds.has(target);
}
if (interactionState.zoomTier !== "inspection") {
return false;
}
return Number(attrs.visualPriority ?? 0) >= GRAPH_THEME.zoomTiers[interactionState.zoomTier].edgePriorityThreshold;
}
function resolveNodeColor(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphNodeVisualState,
attrs: NodeAttributes,
cameraRatio: number,
fallbackColor?: string,
) {
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
const overviewTint = state === "neighbor"
? theme.palette.overview.nodeTintMix + 0.09
: theme.palette.overview.nodeTintMix;
const overviewCore = blendHex(
theme.palette.overview.nodeCore,
semanticColor,
Math.min(0.74, theme.palette.overview.nodeCoreMix + overviewTint),
);
switch (theme.nodes.states[state].color) {
case "selected":
return theme.palette.accent.selected;
case "hovered":
return theme.palette.accent.hovered;
case "path":
return theme.palette.accent.path;
case "muted":
return zoomTier === "overview"
? withAlpha(theme.palette.overview.nodeMuted, 0.42)
: String(attrs.mutedColor || withAlpha(semanticColor, theme.nodes.mutedAlpha));
case "base":
default:
if (zoomTier === "overview") {
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
const boostedCore = blendHex(overviewCore, semanticColor, 0.3 + 0.26 * presenceBoost);
return withAlpha(boostedCore, Math.min(0.98, theme.palette.overview.nodeCoreAlpha + presenceBoost * 0.18));
}
return semanticColor;
}
}
function resolveNodeShellColor(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphNodeVisualState,
attrs: NodeAttributes,
cameraRatio: number,
fallbackColor?: string,
) {
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
const overviewShell = blendHex(
theme.palette.overview.nodeBase,
semanticColor,
(state === "neighbor" ? 0.02 : 0.012) + presenceBoost * 0.024,
);
if (zoomTier !== "overview") {
return withAlpha(blendHex(theme.palette.overview.nodeBase, semanticColor, 0.26), 0.95);
}
if (state === "selected") {
return withAlpha(blendHex(theme.palette.overview.nodeBase, theme.palette.accent.selected, 0.05), 0.98);
}
if (state === "hovered") {
return withAlpha(blendHex(theme.palette.overview.nodeBase, theme.palette.accent.hovered, 0.06), 0.98);
}
if (state === "path") {
return withAlpha(blendHex(theme.palette.overview.nodeBase, theme.palette.accent.path, 0.06), 0.97);
}
if (state === "muted" || state === "inactive") {
return withAlpha(theme.palette.overview.nodeMuted, 0.22);
}
return withAlpha(overviewShell, theme.palette.overview.nodeShellAlpha);
}
function resolveNodeCoreScale(
zoomTier: GraphZoomTier,
state: GraphNodeVisualState,
cameraRatio: number,
) {
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
if (zoomTier === "overview") {
switch (state) {
case "selected":
return 0.34 + presenceBoost * 0.08;
case "hovered":
return 0.32 + presenceBoost * 0.08;
case "path":
return 0.28 + presenceBoost * 0.07;
case "neighbor":
return 0.2 + presenceBoost * 0.06;
case "muted":
case "inactive":
return 0.08 + presenceBoost * 0.03;
case "default":
default:
return 0.16 + presenceBoost * 0.08;
}
}
switch (state) {
case "selected":
return 0.52;
case "hovered":
return 0.48;
case "path":
return 0.44;
case "neighbor":
return 0.3;
case "muted":
case "inactive":
return 0.1;
case "default":
default:
return zoomTier === "structure" ? 0.22 : 0.28;
}
}
function resolveEdgeColor(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphEdgeVisualState,
attrs: EdgeAttributes,
fallbackColor?: string,
) {
const defaultInspectionColor = zoomTier === "overview"
? theme.palette.overview.edgeInspection
: theme.palette.muted.edgeInspection;
const baseColor = String(attrs.baseColor || fallbackColor || defaultInspectionColor);
switch (theme.edges.states[state].color) {
case "hover":
return theme.palette.accent.hovered;
case "path":
return theme.palette.accent.path;
case "backbone":
return zoomTier === "overview"
? theme.palette.overview.edgeBackbone
: theme.palette.muted.edgeFocus;
case "focus":
return theme.palette.muted.edgeFocus;
case "overview":
return theme.palette.muted.edgeOverview;
case "structure":
return zoomTier === "overview"
? theme.palette.overview.edgeStructure
: theme.palette.muted.edgeStructure;
case "inspection":
return zoomTier === "overview"
? theme.palette.overview.edgeInspection
: theme.palette.muted.edgeInspection;
case "muted":
return zoomTier === "overview"
? theme.palette.overview.edgeStructure
: String(attrs.mutedColor || theme.palette.muted.edgeOverview);
default:
return baseColor;
}
}
function resolveNodeRingColor(
theme: GraphTheme,
state: GraphNodeVisualState,
attrs: NodeAttributes,
) {
if (state === "selected") {
return attrs.ringColor || theme.nodes.selectedRing.color;
}
if (state === "hovered") {
return theme.palette.accent.hovered;
}
if (state === "path") {
return theme.palette.accent.path;
}
return undefined;
}
function resolveNodeRingSize(
theme: GraphTheme,
state: GraphNodeVisualState,
zoomTier: GraphZoomTier,
) {
if (state === "selected" && zoomTierAtLeast(zoomTier, theme.nodes.selectedRing.visibleFrom)) {
return theme.nodes.selectedRing.nativeSize;
}
if (state === "hovered") {
return Math.max(1.45, theme.nodes.selectedRing.nativeSize - 0.35);
}
if (state === "path") {
return Math.max(1.2, theme.nodes.selectedRing.nativeSize - 0.55);
}
return 0;
}
export function resolveNodeVisualState(
nodeId: string,
zoomTier: GraphZoomTier,
hoveredNodeId: string | null,
selectedNodeId: string,
selectedEdgeId: string,
focusIds: Set<string>,
edgeEndpointIds: Set<string>,
pathNodeIds: Set<string>,
): GraphNodeVisualState {
if (hoveredNodeId && nodeId === hoveredNodeId) {
return "hovered";
}
if (selectedNodeId && nodeId === selectedNodeId) {
return "selected";
}
if (pathNodeIds.has(nodeId)) {
return "path";
}
if (focusIds.has(nodeId)) {
return "neighbor";
}
if (edgeEndpointIds.has(nodeId)) {
return "neighbor";
}
if (hoveredNodeId || selectedNodeId || selectedEdgeId || pathNodeIds.size > 0) {
if (zoomTier === "overview") {
return "default";
}
return "muted";
}
return "default";
}
export function resolveEdgeVisualState(
edgeId: string,
source: string,
target: string,
zoomTier: GraphZoomTier,
hoveredNodeId: string | null,
selectedNodeId: string,
selectedEdgeId: string,
focusIds: Set<string>,
pathEdgeIds: Set<string>,
overviewBackboneEdgeIds: Set<string>,
): GraphEdgeVisualState {
const primaryNodeId = hoveredNodeId || selectedNodeId;
if (pathEdgeIds.has(edgeId)) {
return "path";
}
if (selectedEdgeId && edgeId === selectedEdgeId) {
return "selected";
}
if (primaryNodeId && (source === primaryNodeId || target === primaryNodeId)) {
return hoveredNodeId ? "hovered" : "selected";
}
if (zoomTier !== "overview" && focusIds.has(source) && focusIds.has(target)) {
return "neighbor";
}
if (zoomTier === "overview" && overviewBackboneEdgeIds.has(edgeId)) {
return "backbone";
}
if (hoveredNodeId || selectedNodeId || selectedEdgeId || pathEdgeIds.size > 0) {
return "muted";
}
if (zoomTier === "overview") {
return "inactive";
}
return "default";
}
export function resolveNodeVariant(state: GraphNodeVisualState, attrs: NodeAttributes): GraphNodeShapeVariant {
if (state === "selected") {
return "selected";
}
return attrs.nodeShapeVariant || attrs.nodeVariant || "default";
}
export function resolveEdgeVariant(state: GraphEdgeVisualState, attrs: EdgeAttributes): GraphEdgeVariant {
if (state === "path") {
return "pathSignal";
}
if ((attrs.parallelCount ?? 1) > 1) {
return "parallelCurve";
}
if (attrs.edgeVariant) {
return attrs.edgeVariant;
}
if (attrs.isBidirectional) {
return "bidirectionalCurve";
}
if (attrs.arrowVisibilityPolicy === "contextual") {
return "directional";
}
return "line";
}
export function shouldForceNodeLabel(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphNodeVisualState,
attrs: NodeAttributes,
labelPriority: number,
): boolean {
const tierConfig = theme.zoomTiers[zoomTier];
const forceVisibleState = theme.labels.forceVisibleStates.includes(state);
const policy = attrs.labelVisibilityPolicy || "priority";
if (forceVisibleState || theme.nodes.states[state].forceLabel) {
return true;
}
switch (policy as GraphLabelVisibilityPolicy) {
case "always":
return true;
case "local":
return zoomTier !== "overview" && state !== "default" && state !== "muted" && state !== "inactive";
case "priority":
return labelPriority >= tierConfig.labelThreshold;
case "none":
default:
return false;
}
}
function resolveNodeBorderColor(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphNodeVisualState,
variant: GraphNodeShapeVariant,
attrs: NodeAttributes,
baseColor: string,
) {
if (state === "selected" || variant === "selected") {
return attrs.ringColor || theme.nodes.selectedRing.color;
}
if (state === "hovered") {
return theme.palette.accent.hovered;
}
if (state === "path") {
return theme.palette.accent.path;
}
if (state === "muted" || state === "inactive") {
return withAlpha(
attrs.strokeColor || attrs.borderColor || theme.palette.overview.nodeBorder || theme.palette.background.nodeBorder,
zoomTier === "overview" ? 0.26 : 0.7,
);
}
if (variant === "temporal") {
return theme.palette.accent.temporal;
}
if (variant === "provenance") {
return theme.palette.accent.provenance;
}
if (variant === "inferred") {
return theme.palette.accent.inferred;
}
if (zoomTier === "overview") {
return withAlpha(
blendHex(theme.palette.overview.nodeBorder, baseColor, state === "neighbor" ? 0.08 : 0.03),
state === "neighbor" ? 0.24 : 0.06,
);
}
return attrs.strokeColor || attrs.borderColor || theme.palette.background.nodeBorder || baseColor;
}
export function resolveNodeElementStyle(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphNodeVisualState,
attrs: NodeAttributes,
label: string,
cameraRatio = 1,
): ResolvedNodeStyle {
const tierConfig = theme.zoomTiers[zoomTier];
const stateConfig = theme.nodes.states[state];
const nodeVariant = resolveNodeVariant(state, attrs);
const variantConfig = theme.nodes.variants[nodeVariant];
const baseSize = Number(attrs.baseSize || attrs.size || 4);
const labelPriority = Number(attrs.labelPriority ?? 0);
const color = resolveNodeColor(theme, zoomTier, state, attrs, cameraRatio, attrs.color);
const shellColor = resolveNodeShellColor(theme, zoomTier, state, attrs, cameraRatio, attrs.color);
const sizeMultiplier = (state === "default" ? tierConfig.nodeScale : stateConfig.sizeMultiplier) * variantConfig.sizeMultiplier;
const overviewPresence = zoomTier === "overview" ? 1 + getOverviewPresenceBoost(cameraRatio) * 1.2 : 1;
const forceLabel = shouldForceNodeLabel(theme, zoomTier, state, attrs, labelPriority);
const badgeKind = attrs.badgeKind || variantConfig.badgeKind;
const forceVisibleState = theme.labels.forceVisibleStates.includes(state);
const showBadge = Boolean(
badgeKind
&& (forceVisibleState || (tierConfig.showBadges && zoomTierAtLeast(zoomTier, variantConfig.badgeVisibleFrom)))
&& state !== "muted"
&& state !== "inactive",
);
const ringSize = resolveNodeRingSize(theme, state, zoomTier);
const ringColor = resolveNodeRingColor(theme, state, attrs);
const showRing = ringSize > 0;
const showHalo = state === "hovered" || state === "selected" || state === "path";
const strokeBase = state === "muted" || state === "inactive"
? theme.nodes.strokeHierarchy[zoomTier].muted
: forceVisibleState
? theme.nodes.strokeHierarchy[zoomTier].emphasis
: theme.nodes.strokeHierarchy[zoomTier].base;
return {
color,
shellColor,
coreScale: resolveNodeCoreScale(zoomTier, state, cameraRatio),
size: Math.max(baseSize * sizeMultiplier * overviewPresence, stateConfig.minSize),
forceLabel,
label: forceLabel ? label : "",
zIndex: forceLabel && stateConfig.zIndex === 0 ? 1 : stateConfig.zIndex,
hidden: false,
borderColor: resolveNodeBorderColor(theme, zoomTier, state, nodeVariant, attrs, color),
borderSize: Math.max(
0.4,
Number(attrs.borderSize ?? 0.85) + strokeBase + stateConfig.borderBoost + variantConfig.borderBoost - 0.8,
),
nodeVariant,
badgeKind,
badgeCount: attrs.badgeCount,
showBadge,
showRing,
ringColor,
ringSize,
showHalo,
haloColor: attrs.haloColor || attrs.glowColor || withAlpha(color, theme.overlays.hoverGlowAlpha + variantConfig.haloBoost),
};
}
function resolveStraightEdgeType(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphEdgeVisualState,
variant: GraphEdgeVariant,
attrs: EdgeAttributes,
): "line" | "arrow" {
const variantConfig = theme.edges.variants[variant];
if (theme.edges.states[state].forceArrow || variantConfig.arrowPolicy === "always") {
return "arrow";
}
if (variantConfig.arrowPolicy === "contextual" && theme.zoomTiers[zoomTier].showContextualArrows) {
return "arrow";
}
if (state !== "default") {
return (attrs.type as "line" | "arrow" | undefined) || variantConfig.baseType;
}
return Number(attrs.visualPriority ?? 0) >= theme.zoomTiers[zoomTier].arrowPriorityThreshold && theme.zoomTiers[zoomTier].showContextualArrows
? "arrow"
: "line";
}
function resolveEdgeCurvature(
theme: GraphTheme,
state: GraphEdgeVisualState,
edgeVariant: GraphEdgeVariant,
attrs: EdgeAttributes,
sourceId: string | undefined,
targetId: string | undefined,
) {
const variantConfig = theme.edges.variants[edgeVariant];
const baseCurvature = edgeVariant === "line" && state === "selected"
? Math.max(variantConfig.curveStrength, 0.14)
: variantConfig.curveStrength;
if (baseCurvature === 0) {
return 0;
}
if (typeof attrs.parallelCount === "number" && attrs.parallelCount > 1 && typeof attrs.parallelIndex === "number") {
const center = (attrs.parallelCount - 1) / 2;
return (attrs.parallelIndex - center) * baseCurvature;
}
if ((edgeVariant === "bidirectionalCurve" || edgeVariant === "parallelCurve" || attrs.isBidirectional) && sourceId && targetId) {
return sourceId.localeCompare(targetId) <= 0 ? baseCurvature : -baseCurvature;
}
return baseCurvature;
}
export function resolveEdgeElementStyle(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphEdgeVisualState,
attrs: EdgeAttributes,
sourceId?: string,
targetId?: string,
): ResolvedEdgeStyle {
const tierConfig = theme.zoomTiers[zoomTier];
const stateConfig = theme.edges.states[state];
const edgeVariant = resolveEdgeVariant(state, attrs);
const variantConfig = theme.edges.variants[edgeVariant];
const baseSize = Number(attrs.baseSize || attrs.size || 0.9);
const visualPriority = Number(attrs.visualPriority ?? 0);
const belowPriorityThreshold = state === "default"
&& visualPriority < tierConfig.edgePriorityThreshold
&& edgeVariant === "line";
if (stateConfig.hide || belowPriorityThreshold) {
return {
hidden: true,
zIndex: 0,
edgeVariant,
arrowVisibilityPolicy: variantConfig.arrowPolicy,
curveStrength: variantConfig.curveStrength,
curvature: 0,
};
}
const sizeMultiplier = (state === "default" ? tierConfig.edgeSizeScale : stateConfig.sizeMultiplier) * variantConfig.sizeMultiplier;
const straightType = resolveStraightEdgeType(theme, zoomTier, state, edgeVariant, attrs);
const useCurvedRenderer = tierConfig.showCurves
&& zoomTier !== "overview"
&& (
edgeVariant === "pathSignal"
|| state === "selected"
|| ((state === "neighbor" || state === "hovered") && (edgeVariant === "bidirectionalCurve" || edgeVariant === "parallelCurve"))
);
const curvature = useCurvedRenderer
? resolveEdgeCurvature(theme, state, edgeVariant, attrs, sourceId, targetId)
: 0;
return {
hidden: false,
type: useCurvedRenderer
? (straightType === "arrow" ? "curvedArrow" : "curve")
: straightType,
color: resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color),
size: Math.max(baseSize * sizeMultiplier, stateConfig.minSize),
zIndex: stateConfig.zIndex,
edgeVariant,
arrowVisibilityPolicy: variantConfig.arrowPolicy,
curveStrength: variantConfig.curveStrength,
curvature,
};
}
export function createFocusedGraph(
nodeId: string,
activePath: string[],
activePathEdgeIds: string[] = [],
): Graph<NodeAttributes, EdgeAttributes> {
const focused = new Graph<NodeAttributes, EdgeAttributes>({
type: "directed",
multi: true,
allowSelfLoops: false,
});
const rankedNeighbors = rankNeighbors(nodeId).slice(0, MAX_FOCUS_NEIGHBORS);
const focusIds = new Set<string>([nodeId, ...rankedNeighbors]);
const labelledNeighborIds = new Set(rankedNeighbors.slice(0, FOCUS_PRIMARY_LABELS));
const pathNodeIds = new Set(activePath);
const pathEdgeIds = buildPathEdgeSet(graph, activePath, activePathEdgeIds);
const addNode = (id: string, attrs: NodeAttributes) => {
if (!focused.hasNode(id)) {
focused.addNode(id, attrs);
}
};
const selectedAttrs = graph.getNodeAttributes(nodeId) as NodeAttributes;
const selectedState = resolveNodeElementStyle(GRAPH_THEME, "inspection", "selected", selectedAttrs, selectedAttrs.label);
addNode(nodeId, {
...selectedAttrs,
x: 0,
y: 0,
color: selectedState.color,
size: Math.max(selectedState.size, 22),
baseColor: selectedState.color,
baseSize: Math.max(selectedState.size, 22),
label: selectedState.label,
});
rankedNeighbors.forEach((neighborId, index) => {
const baseAttrs = graph.getNodeAttributes(neighborId) as NodeAttributes;
const ring = Math.floor(index / FOCUS_RING_CAPACITY);
const ringIndex = index % FOCUS_RING_CAPACITY;
const itemsInRing = Math.min(
FOCUS_RING_CAPACITY,
rankedNeighbors.length - ring * FOCUS_RING_CAPACITY,
);
const radius = FOCUS_RING_GAP * (ring + 1);
const angle = (Math.PI * 2 * ringIndex) / itemsInRing - Math.PI / 2;
const visualState: GraphNodeVisualState = pathNodeIds.has(neighborId)
? "path"
: labelledNeighborIds.has(neighborId)
? "neighbor"
: "default";
const style = resolveNodeElementStyle(
GRAPH_THEME,
"inspection",
visualState,
{
...baseAttrs,
labelPriority: labelledNeighborIds.has(neighborId) || pathNodeIds.has(neighborId)
? Math.max(Number(baseAttrs.labelPriority ?? 0), 1)
: 0,
},
baseAttrs.label,
);
addNode(neighborId, {
...baseAttrs,
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
color: style.color,
size: Math.max(style.size, 8.5),
baseColor: style.color,
baseSize: Math.max(style.size, 8.5),
label: style.label,
});
});
for (const source of focusIds) {
for (const target of focusIds) {
if (source === target) {
continue;
}
forEachDirectedEdgeBetween(graph, source, target, (edgeId, attrs) => {
const state: GraphEdgeVisualState = pathEdgeIds.has(edgeId)
? "path"
: source === nodeId || target === nodeId
? "selected"
: "neighbor";
const style = resolveEdgeElementStyle(GRAPH_THEME, "inspection", state, attrs, source, target);
focused.mergeDirectedEdgeWithKey(edgeId, source, target, {
...attrs,
type: style.type,
size: style.size,
color: style.color,
baseSize: style.size,
baseColor: style.color,
curvature: style.curvature,
});
});
}
}
return focused;
}
export function resolveDisplayGraph(
selectedNodeId: string,
activePath: string[],
activePathEdgeIds: string[],
viewMode: GraphViewMode,
) {
const isFocusedView = viewMode === "focused" && Boolean(selectedNodeId) && graph.hasNode(selectedNodeId);
return isFocusedView && selectedNodeId ? createFocusedGraph(selectedNodeId, activePath, activePathEdgeIds) : graph;
}
export function createInteractionState(
hoveredNodeId: string | null,
selectedNodeId: string,
selectedEdgeId: string,
activePath: string[],
activePathEdgeIds: string[],
viewMode: GraphViewMode,
zoomTier: GraphZoomTier,
isLayoutRunning: boolean,
): GraphInteractionState {
return {
hoveredNodeId,
selectedNodeId,
selectedEdgeId,
focusedNodeId: selectedNodeId,
activePath,
activePathEdgeIds,
viewMode,
zoomTier,
isLayoutRunning,
};
}
@@ -1,6 +1,6 @@
export type GraphZoomTier = "overview" | "structure" | "inspection";
export type GraphNodeVisualState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphEdgeVisualState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphEdgeVisualState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphNodeShapeVariant = "default" | "temporal" | "inferred" | "provenance" | "selected";
export type GraphEdgeVariant = "line" | "directional" | "bidirectionalCurve" | "parallelCurve" | "pathSignal";
export type GraphArrowVisibilityPolicy = "hidden" | "contextual" | "always";
@@ -8,11 +8,24 @@ export type GraphLabelVisibilityPolicy = "none" | "priority" | "local" | "always
export type GraphBadgeKind = "inferred" | "temporal" | "provenance";
type GraphNodeColorMode = "base" | "selected" | "hovered" | "path" | "muted";
type GraphEdgeColorMode = "overview" | "structure" | "inspection" | "hover" | "path" | "focus" | "muted";
type GraphEdgeColorMode = "overview" | "backbone" | "structure" | "inspection" | "hover" | "path" | "focus" | "muted";
export interface GraphTheme {
palette: {
semantic: string[];
overview: {
nodeBase: string;
nodeCore: string;
nodeMuted: string;
nodeBorder: string;
nodeTintMix: number;
nodeCoreMix: number;
nodeShellAlpha: number;
nodeCoreAlpha: number;
edgeBackbone: string;
edgeStructure: string;
edgeInspection: string;
};
accent: {
selected: string;
hovered: string;
@@ -56,6 +69,46 @@ export interface GraphTheme {
policies: Record<GraphLabelVisibilityPolicy, {
minZoomTier: GraphZoomTier;
}>;
chip: {
fontFamily: string;
fontWeight: number;
fontSize: number;
maxFontSize: number;
sizeScale: number;
paddingX: number;
paddingY: number;
radius: number;
offsetX: number;
offsetY: number;
background: string;
borderColor: string;
borderAlpha: number;
textColor: string;
shadowColor: string;
shadowAlpha: number;
shadowBlur: number;
};
hoverCard: {
fontFamily: string;
titleWeight: number;
titleSize: number;
metaWeight: number;
metaSize: number;
paddingX: number;
paddingY: number;
radius: number;
offsetX: number;
offsetY: number;
metaGap: number;
background: string;
borderColor: string;
borderAlpha: number;
textColor: string;
metaColor: string;
shadowColor: string;
shadowAlpha: number;
shadowBlur: number;
};
};
nodes: {
backgroundScale: number;
@@ -83,6 +136,7 @@ export interface GraphTheme {
selectedRing: {
color: string;
width: number;
nativeSize: number;
glowAlpha: number;
visibleFrom: GraphZoomTier;
};
@@ -160,6 +214,35 @@ export interface GraphTheme {
edgeAlpha: number;
edgeLineWidth: number;
};
temporalEmphasis: {
minZoomTier: GraphZoomTier;
maxHighlights: number;
radiusMultiplier: number;
glowAlpha: number;
};
semanticRegions: {
minZoomTier: GraphZoomTier;
maxRegions: number;
minVisibleSamples: number;
fogResolutionScale: number;
splatRadius: number;
blurPasses: number;
densityThreshold: number;
contourThreshold: number;
minMaskPixels: number;
minOccupancyRatio: number;
dominantMassRatio: number;
outerContourMinMaskPixels: number;
fogAlpha: number;
innerContourAlpha: number;
outerContourAlpha: number;
};
contours: {
minZoomTier: GraphZoomTier;
maxContours: number;
baseRadius: number;
glowAlpha: number;
};
legend: {
maxGroups: number;
};
@@ -172,50 +255,62 @@ export interface GraphTheme {
export const GRAPH_THEME: GraphTheme = {
palette: {
semantic: [
"#63E6FF",
"#30D4C7",
"#72A8FF",
"#8D7CFF",
"#C07CFF",
"#FF67D4",
"#FFB24D",
"#C5F55A",
"#3E79F2",
"#149287",
"#2F9F61",
"#555FD6",
"#8A56D8",
"#B65473",
"#C9922E",
],
overview: {
nodeBase: "#0B1320",
nodeCore: "#435D7A",
nodeMuted: "#121927",
nodeBorder: "#64758C",
nodeTintMix: 0.03,
nodeCoreMix: 0.52,
nodeShellAlpha: 0.97,
nodeCoreAlpha: 1,
edgeBackbone: "rgba(83, 111, 148, 0.04)",
edgeStructure: "rgba(72, 90, 118, 0.009)",
edgeInspection: "rgba(98, 120, 148, 0.026)",
},
accent: {
selected: "#FFC857",
hovered: "#7FE0FF",
path: "#FFB870",
temporal: "#6DD6FF",
provenance: "#A98CFF",
inferred: "#FF9A61",
selected: "#F2D288",
hovered: "#8FE7FF",
path: "#D79056",
temporal: "#49D7FF",
provenance: "#C9A5FF",
inferred: "#D07B4D",
},
muted: {
fallback: "rgba(130, 145, 165, 0.12)",
nodeAlpha: 0.12,
edgeOverview: "rgba(116, 166, 255, 0.05)",
edgeStructure: "rgba(109, 164, 255, 0.11)",
edgeInspection: "rgba(146, 194, 255, 0.18)",
edgeFocus: "rgba(162, 184, 255, 0.34)",
fallback: "rgba(96, 112, 136, 0.1)",
nodeAlpha: 0.085,
edgeOverview: "rgba(82, 100, 124, 0.009)",
edgeStructure: "rgba(92, 112, 138, 0.02)",
edgeInspection: "rgba(124, 148, 176, 0.066)",
edgeFocus: "rgba(160, 186, 218, 0.16)",
},
background: {
canvas: "#060B17",
shell: "rgba(6, 13, 24, 0.76)",
shellBorder: "rgba(112, 196, 255, 0.14)",
shellGlow: "rgba(53, 123, 255, 0.16)",
grid: "rgba(88, 166, 255, 0.038)",
vignette: "rgba(1, 4, 10, 0.82)",
nodeBorder: "#07111C",
canvas: "#07101A",
shell: "rgba(8, 15, 26, 0.8)",
shellBorder: "rgba(118, 162, 207, 0.14)",
shellGlow: "rgba(48, 88, 140, 0.14)",
grid: "rgba(92, 126, 170, 0.034)",
vignette: "rgba(2, 5, 11, 0.84)",
nodeBorder: "#0C1522",
},
},
zoomTiers: {
overview: {
maxRatio: Number.POSITIVE_INFINITY,
nodeScale: 0.9,
nodeScale: 0.66,
labelThreshold: 0.985,
labelBudget: 18,
labelBudget: 10,
edgePriorityThreshold: 0.72,
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
edgeSizeScale: 0.72,
edgeSizeScale: 0.34,
showBadges: false,
showCurves: false,
showContextualArrows: false,
@@ -253,23 +348,63 @@ export const GRAPH_THEME: GraphTheme = {
local: { minZoomTier: "structure" },
always: { minZoomTier: "overview" },
},
chip: {
fontFamily: "\"IBM Plex Sans\", Inter, system-ui, sans-serif",
fontWeight: 500,
fontSize: 10,
maxFontSize: 11,
sizeScale: 0.25,
paddingX: 6,
paddingY: 3,
radius: 6,
offsetX: 12,
offsetY: 10,
background: "rgba(8, 14, 24, 0.9)",
borderColor: "rgba(154, 181, 212, 0.16)",
borderAlpha: 0.28,
textColor: "#EAF3FF",
shadowColor: "rgba(0, 0, 0, 0.6)",
shadowAlpha: 0.26,
shadowBlur: 12,
},
hoverCard: {
fontFamily: "\"IBM Plex Sans\", Inter, system-ui, sans-serif",
titleWeight: 700,
titleSize: 13,
metaWeight: 500,
metaSize: 10,
paddingX: 10,
paddingY: 7,
radius: 12,
offsetX: 16,
offsetY: 16,
metaGap: 5,
background: "rgba(8, 14, 24, 0.94)",
borderColor: "rgba(154, 181, 212, 0.18)",
borderAlpha: 0.32,
textColor: "#F6FBFF",
metaColor: "rgba(184, 214, 255, 0.58)",
shadowColor: "rgba(0, 0, 0, 0.62)",
shadowAlpha: 0.34,
shadowBlur: 15,
},
},
nodes: {
backgroundScale: 0.52,
mutedAlpha: 0.12,
mutedAlpha: 0.08,
strokeHierarchy: {
overview: { base: 0.8, emphasis: 1.2, muted: 0.45 },
overview: { base: 0.05, emphasis: 0.34, muted: 0.02 },
structure: { base: 1.05, emphasis: 1.45, muted: 0.55 },
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
},
states: {
default: { color: "base", sizeMultiplier: 1, minSize: 1.45, forceLabel: false, zIndex: 0, borderBoost: 0 },
hovered: { color: "hovered", sizeMultiplier: 1.34, minSize: 16, forceLabel: true, zIndex: 4, borderBoost: 0.5 },
selected: { color: "selected", sizeMultiplier: 1.18, minSize: 12, forceLabel: true, zIndex: 3, borderBoost: 0.42 },
neighbor: { color: "base", sizeMultiplier: 1.08, minSize: 7.2, forceLabel: true, zIndex: 2, borderBoost: 0.18 },
path: { color: "path", sizeMultiplier: 1.08, minSize: 7.2, forceLabel: true, zIndex: 2, borderBoost: 0.24 },
inactive: { color: "muted", sizeMultiplier: 0.52, minSize: 0.8, forceLabel: false, zIndex: 0, borderBoost: -0.15 },
muted: { color: "muted", sizeMultiplier: 0.52, minSize: 0.8, forceLabel: false, zIndex: 0, borderBoost: -0.15 },
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
hovered: { color: "hovered", sizeMultiplier: 1.18, minSize: 12.5, forceLabel: true, zIndex: 4, borderBoost: 0.22 },
selected: { color: "selected", sizeMultiplier: 1.06, minSize: 10.5, forceLabel: true, zIndex: 3, borderBoost: 0.2 },
neighbor: { color: "base", sizeMultiplier: 0.84, minSize: 4.8, forceLabel: true, zIndex: 2, borderBoost: -0.08 },
path: { color: "path", sizeMultiplier: 1.01, minSize: 6.2, forceLabel: true, zIndex: 2, borderBoost: 0.08 },
inactive: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
muted: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
},
variants: {
default: { sizeMultiplier: 1, borderBoost: 0, haloBoost: 0, badgeVisibleFrom: "inspection" },
@@ -279,35 +414,37 @@ export const GRAPH_THEME: GraphTheme = {
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
},
selectedRing: {
color: "#FFC857",
width: 2.4,
glowAlpha: 0.38,
visibleFrom: "inspection",
color: "#E7C57C",
width: 1.9,
nativeSize: 2.2,
glowAlpha: 0.2,
visibleFrom: "overview",
},
badges: {
inferred: { color: "#FF9A61", label: "I" },
temporal: { color: "#6DD6FF", label: "T" },
provenance: { color: "#A98CFF", label: "P" },
inferred: { color: "#C98658", label: "I" },
temporal: { color: "#52CDEF", label: "T" },
provenance: { color: "#A289D0", label: "P" },
},
badge: {
radius: 7,
offset: 3,
fontSize: 8,
textColor: "#08111d",
background: "rgba(8, 17, 29, 0.9)",
stroke: "rgba(255,255,255,0.2)",
glowAlpha: 0.34,
background: "rgba(8, 17, 29, 0.84)",
stroke: "rgba(255,255,255,0.14)",
glowAlpha: 0.24,
},
},
edges: {
states: {
default: { color: "inspection", sizeMultiplier: 1, minSize: 0.72, zIndex: 0, forceArrow: false, hide: false },
hovered: { color: "hover", sizeMultiplier: 1.55, minSize: 1.8, zIndex: 3, forceArrow: true, hide: false },
selected: { color: "hover", sizeMultiplier: 1.55, minSize: 1.8, zIndex: 3, forceArrow: true, hide: false },
neighbor: { color: "focus", sizeMultiplier: 1.08, minSize: 0.95, zIndex: 1, forceArrow: false, hide: false },
path: { color: "path", sizeMultiplier: 1.7, minSize: 2.2, zIndex: 4, forceArrow: true, hide: false },
inactive: { color: "muted", sizeMultiplier: 1, minSize: 0.45, zIndex: 0, forceArrow: false, hide: true },
muted: { color: "muted", sizeMultiplier: 1, minSize: 0.45, zIndex: 0, forceArrow: false, hide: true },
default: { color: "structure", sizeMultiplier: 0.74, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
backbone: { color: "backbone", sizeMultiplier: 0.72, minSize: 0.18, zIndex: 1, forceArrow: false, hide: false },
hovered: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
selected: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
neighbor: { color: "focus", sizeMultiplier: 0.92, minSize: 0.5, zIndex: 1, forceArrow: false, hide: false },
path: { color: "path", sizeMultiplier: 1.5, minSize: 1.8, zIndex: 4, forceArrow: true, hide: false },
inactive: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
muted: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
},
variants: {
line: { baseType: "line", arrowPolicy: "hidden", curveStrength: 0, sizeMultiplier: 1, glowAlpha: 0 },
@@ -318,8 +455,8 @@ export const GRAPH_THEME: GraphTheme = {
},
},
overlays: {
hoverGlowAlpha: 0.26,
pathGlowAlpha: 0.2,
hoverGlowAlpha: 0.18,
pathGlowAlpha: 0.16,
glowRadiusMultiplier: 4.8,
minGlowRadius: 16,
pulseRadius: 11,
@@ -360,6 +497,35 @@ export const GRAPH_THEME: GraphTheme = {
edgeAlpha: 0.42,
edgeLineWidth: 1.8,
},
temporalEmphasis: {
minZoomTier: "structure",
maxHighlights: 48,
radiusMultiplier: 4.2,
glowAlpha: 0.12,
},
semanticRegions: {
minZoomTier: "overview",
maxRegions: 3,
minVisibleSamples: 14,
fogResolutionScale: 0.22,
splatRadius: 13,
blurPasses: 2,
densityThreshold: 0.16,
contourThreshold: 0.28,
minMaskPixels: 170,
minOccupancyRatio: 0.0022,
dominantMassRatio: 0.62,
outerContourMinMaskPixels: 240,
fogAlpha: 0.11,
innerContourAlpha: 0.16,
outerContourAlpha: 0.045,
},
contours: {
minZoomTier: "overview",
maxContours: 3,
baseRadius: 88,
glowAlpha: 0.055,
},
legend: {
maxGroups: 8,
},
@@ -436,6 +602,35 @@ export function darkenHex(hexColor: string, amount: number): string {
return `#${[red, green, blue].map((value) => value.toString(16).padStart(2, "0")).join("")}`;
}
export function blendHex(baseColor: string, tintColor: string, amount: number): string {
if (!baseColor.startsWith("#") || !tintColor.startsWith("#")) {
return tintColor || baseColor;
}
const normalize = (value: string) => {
const hex = value.slice(1);
return hex.length === 3
? hex.split("").map((char) => `${char}${char}`).join("")
: hex;
};
const base = normalize(baseColor);
const tint = normalize(tintColor);
if (base.length !== 6 || tint.length !== 6) {
return tintColor || baseColor;
}
const mix = clamp(0, amount, 1);
const mixChannel = (left: number, right: number) => Math.round(left + (right - left) * mix);
const channels = [0, 2, 4].map((offset) => {
const left = Number.parseInt(base.slice(offset, offset + 2), 16);
const right = Number.parseInt(tint.slice(offset, offset + 2), 16);
return mixChannel(left, right).toString(16).padStart(2, "0");
});
return `#${channels.join("")}`;
}
export function getZoomTier(ratio: number): GraphZoomTier {
if (ratio <= GRAPH_THEME.zoomTiers.inspection.maxRatio) {
return "inspection";
@@ -0,0 +1,527 @@
import type { CSSProperties } from "react";
import type {
GraphAnalyticsSnapshot,
GraphDiagnosticsSnapshot,
GraphEffectAvailability,
GraphEffectToggle,
} from "../types";
import type { GraphPlugin } from "./types";
const EFFECTS_PANEL_ID = "effects-panel";
type EffectRowConfig = {
key: GraphEffectToggle;
label: string;
description: string;
};
const SCENE_EFFECT_ROWS: EffectRowConfig[] = [
{
key: "pathPulseEnabled",
label: "Path Pulse",
description: "Animated pulse on the active selected path.",
},
{
key: "pathFlowEnabled",
label: "Path Flow",
description: "Directional flow accents along the active selected path.",
},
{
key: "lensEnabled",
label: "Neighborhood Lens",
description: "Local emphasis around the hovered or selected node.",
},
{
key: "temporalEmphasisEnabled",
label: "Temporal Emphasis",
description: "Subtle glow around temporally relevant nodes in the active time window.",
},
{
key: "semanticRegionsEnabled",
label: "Semantic Regions",
description: "Quiet semantic hulls around the strongest visible topic clusters.",
},
{
key: "contoursEnabled",
label: "Contours",
description: "Low-contrast density halos around the strongest visible anchors.",
},
{
key: "legendEnabled",
label: "Regions Summary",
description: "Keep the regions and signals summary visible in the Effects panel.",
},
];
const INTELLIGENCE_EFFECT_ROWS: EffectRowConfig[] = [
{
key: "pathfindingEnabled",
label: "Directed Pathfinding",
description: "Compare the traced path against a strict local directed shortest path.",
},
{
key: "communitiesEnabled",
label: "Community Regions",
description: "Detect stable Louvain communities for orientation and scene grouping.",
},
{
key: "centralityEnabled",
label: "Centrality Ranking",
description: "Rank the strongest graph anchors for labels, regions, and navigation.",
},
];
const AVAILABILITY_KEYS: Record<GraphEffectToggle, keyof GraphDiagnosticsSnapshot["effectAvailability"]> = {
pathPulseEnabled: "pathPulse",
pathFlowEnabled: "pathFlow",
lensEnabled: "lens",
temporalEmphasisEnabled: "temporalEmphasis",
semanticRegionsEnabled: "semanticRegions",
contoursEnabled: "contours",
pathfindingEnabled: "pathfinding",
communitiesEnabled: "communities",
centralityEnabled: "centrality",
legendEnabled: "legend",
diagnosticsEnabled: "diagnostics",
};
function renderAvailabilityText(availability: GraphEffectAvailability) {
if (availability.available) {
if (typeof availability.visibleSegments === "number" && typeof availability.segmentCap === "number") {
return `${availability.reason} - ${availability.visibleSegments}/${availability.segmentCap} segments`;
}
return availability.reason;
}
return availability.detail ? `${availability.reason} - ${availability.detail}` : availability.reason;
}
function collectFallbackLegendItems(context: Parameters<NonNullable<GraphPlugin["renderPanel"]>>[0]) {
const groups = new Map<string, { count: number; color: string }>();
context.graph.forEachNode((_nodeId, attrs) => {
const semanticGroup = String(attrs.semanticGroup || attrs.nodeType || "entity");
const color = String(attrs.baseColor || context.theme.palette.semantic[0]);
const current = groups.get(semanticGroup);
groups.set(semanticGroup, {
count: (current?.count ?? 0) + 1,
color,
});
});
return [...groups.entries()]
.map(([group, data]) => ({ group, ...data }))
.sort((left, right) => right.count - left.count)
.slice(0, context.theme.effects.legend.maxGroups);
}
function resolveAvailability(
availabilityMap: GraphDiagnosticsSnapshot["effectAvailability"] | undefined,
key: GraphEffectToggle,
enabled: boolean,
): GraphEffectAvailability {
return availabilityMap?.[AVAILABILITY_KEYS[key]] ?? {
enabled,
available: false,
reason: "Waiting for graph runtime",
};
}
function EffectToggleRow({
label,
description,
checked,
availability,
onToggle,
}: {
label: string;
description: string;
checked: boolean;
availability: GraphEffectAvailability;
onToggle: () => void;
}) {
return (
<div style={toggleRowStyle}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={rowTitleStyle}>{label}</div>
<div style={rowDescriptionStyle}>{description}</div>
<div style={rowMetaStyle}>{renderAvailabilityText(availability)}</div>
</div>
<button type="button" onClick={onToggle} style={checked ? toggleButtonActiveStyle : toggleButtonStyle}>
{checked ? "On" : "Off"}
</button>
</div>
);
}
function renderRegionsAndSignals(
context: Parameters<NonNullable<GraphPlugin["renderPanel"]>>[0],
analytics: GraphAnalyticsSnapshot | null,
) {
const fallbackLegendItems = collectFallbackLegendItems(context);
const semanticRegions = analytics?.semanticRegions.summaries ?? [];
const communities = analytics?.communities.summaries ?? [];
const centrality = analytics?.centrality.topNodes ?? [];
const directedPath = analytics?.directedPath ?? null;
if (!semanticRegions.length && !communities.length && !centrality.length && !fallbackLegendItems.length && !directedPath) {
return <div style={emptyTextStyle}>Regions and intelligence summaries will populate when graph analytics are ready.</div>;
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{semanticRegions.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={subsectionTitleStyle}>Semantic regions</div>
{semanticRegions.map((region) => (
<div key={region.semanticGroup} style={legendRowStyle}>
<span
style={{
...legendSwatchStyle,
background: region.color,
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 14px ${region.color}40`,
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={rowTitleStyle}>{region.semanticGroup}</div>
<div style={rowMetaStyle}>
{region.visibleNodeCount.toLocaleString()} visible / {region.nodeCount.toLocaleString()} total
</div>
</div>
<div style={signalBadgeStyle}>{region.anchorLabel}</div>
</div>
))}
</div>
) : null}
{communities.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={subsectionTitleStyle}>Community anchors</div>
{communities.slice(0, 3).map((community) => (
<div key={community.communityId} style={signalRowStyle}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={rowTitleStyle}>{community.anchorLabel}</div>
<div style={rowMetaStyle}>
Community {community.communityId} - {community.visibleNodeCount} visible / {community.nodeCount} total
</div>
</div>
<div style={signalBadgeStyle}>{community.dominantSemanticGroup}</div>
</div>
))}
</div>
) : null}
{centrality.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={subsectionTitleStyle}>Centrality leaders</div>
{centrality.slice(0, 3).map((node) => (
<div key={node.id} style={signalRowStyle}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={rowTitleStyle}>{node.label}</div>
<div style={rowMetaStyle}>
{node.semanticGroup} - score {node.score.toFixed(3)}
</div>
</div>
<div style={signalBadgeStyle}>deg {node.degree.toFixed(3)}</div>
</div>
))}
</div>
) : null}
{directedPath ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={subsectionTitleStyle}>Directed pathfinding</div>
<div style={signalRowStyle}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={rowTitleStyle}>{directedPath.ready ? "Local directed path ready" : "Waiting for path context"}</div>
<div style={rowMetaStyle}>{directedPath.reason}</div>
</div>
{directedPath.ready ? (
<div style={signalBadgeStyle}>
{directedPath.length} hops{directedPath.verifiedAgainstActivePath ? " - match" : ""}
</div>
) : null}
</div>
</div>
) : null}
{!semanticRegions.length && !communities.length && !centrality.length && fallbackLegendItems.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={subsectionTitleStyle}>Fallback semantic legend</div>
{fallbackLegendItems.map((item) => (
<div key={item.group} style={legendRowStyle}>
<span
style={{
...legendSwatchStyle,
background: item.color,
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 14px ${item.color}40`,
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={rowTitleStyle}>{item.group}</div>
<div style={rowMetaStyle}>{item.count.toLocaleString()} nodes</div>
</div>
</div>
))}
</div>
) : null}
</div>
);
}
export const explorationEffectsPluginPhaseC: GraphPlugin = {
id: "exploration-effects",
mount: () => {},
unmount: () => {},
onStateChange: () => {},
toolbarItems: (context) => [
{
id: "effects-toggle",
label: "Effects",
title: "Open exploration effects controls",
active: context.isPanelOpen(EFFECTS_PANEL_ID),
order: 18,
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: EFFECTS_PANEL_ID }),
},
],
renderPanel: (context) => {
if (!context.isPanelOpen(EFFECTS_PANEL_ID)) {
return null;
}
const effectsState = context.getEffectsState();
const diagnosticsSnapshot = context.getDiagnosticsSnapshot();
const analyticsSnapshot = context.getAnalyticsSnapshot();
const availability = diagnosticsSnapshot?.effectAvailability;
const showSignalsSection =
effectsState.legendEnabled
|| effectsState.semanticRegionsEnabled
|| effectsState.communitiesEnabled
|| effectsState.centralityEnabled
|| effectsState.pathfindingEnabled;
return {
id: EFFECTS_PANEL_ID,
title: "Effects",
placement: "bottom",
order: 8,
defaultOpen: false,
preferredWidth: 460,
preferredHeight: 360,
content: (
<div style={panelBodyStyle}>
<div style={panelEyebrowStyle}>Exploration effects</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>Scene effects</div>
{SCENE_EFFECT_ROWS.map((row) => (
<EffectToggleRow
key={row.key}
label={row.label}
description={row.description}
checked={effectsState[row.key]}
availability={resolveAvailability(availability, row.key, effectsState[row.key])}
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: row.key })}
/>
))}
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>Graph intelligence</div>
{INTELLIGENCE_EFFECT_ROWS.map((row) => (
<EffectToggleRow
key={row.key}
label={row.label}
description={row.description}
checked={effectsState[row.key]}
availability={resolveAvailability(availability, row.key, effectsState[row.key])}
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: row.key })}
/>
))}
</div>
{showSignalsSection ? (
<div style={sectionStyle}>
<div style={sectionTitleStyle}>Regions and signals</div>
{renderRegionsAndSignals(context, analyticsSnapshot)}
</div>
) : null}
{import.meta.env.DEV ? (
<div style={sectionStyle}>
<div style={sectionTitleStyle}>Diagnostics</div>
<EffectToggleRow
label="Dev Diagnostics"
description="Inspect plugin, interaction, and effect gating state."
checked={effectsState.diagnosticsEnabled}
availability={resolveAvailability(availability, "diagnosticsEnabled", effectsState.diagnosticsEnabled)}
onToggle={() => context.dispatchAction({ type: "toggleEffect", effect: "diagnosticsEnabled" })}
/>
{effectsState.diagnosticsEnabled && diagnosticsSnapshot ? (
<details style={detailsStyle}>
<summary style={summaryStyle}>Runtime snapshot</summary>
<pre style={diagnosticsPreStyle}>
{JSON.stringify({ diagnostics: diagnosticsSnapshot, analytics: analyticsSnapshot }, null, 2)}
</pre>
</details>
) : null}
</div>
) : null}
</div>
),
};
},
};
const panelBodyStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 12,
};
const panelEyebrowStyle: CSSProperties = {
color: "#8ea4be",
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
};
const sectionStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 8,
padding: "10px 12px",
borderRadius: 14,
border: "1px solid rgba(255,255,255,0.06)",
background: "rgba(255,255,255,0.025)",
};
const sectionTitleStyle: CSSProperties = {
color: "#dce9f8",
fontSize: 12,
fontWeight: 700,
};
const subsectionTitleStyle: CSSProperties = {
color: "#9cc4ec",
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
};
const toggleRowStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 12,
padding: "8px 0",
};
const rowTitleStyle: CSSProperties = {
color: "#f3f7fd",
fontSize: 13,
fontWeight: 600,
};
const rowDescriptionStyle: CSSProperties = {
color: "#a1b7cf",
fontSize: 12,
lineHeight: 1.45,
};
const rowMetaStyle: CSSProperties = {
color: "#7fc6ff",
fontSize: 11,
lineHeight: 1.45,
};
const toggleButtonStyle: CSSProperties = {
minWidth: 52,
padding: "8px 10px",
borderRadius: 999,
border: "1px solid rgba(255,255,255,0.08)",
background: "rgba(255,255,255,0.03)",
color: "#cfe0f4",
fontSize: 12,
fontWeight: 700,
cursor: "pointer",
};
const toggleButtonActiveStyle: CSSProperties = {
...toggleButtonStyle,
background: "rgba(31, 111, 235, 0.24)",
border: "1px solid rgba(127, 208, 255, 0.28)",
color: "#eef6ff",
};
const legendRowStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 10px",
borderRadius: 12,
border: "1px solid rgba(255,255,255,0.06)",
background: "rgba(255,255,255,0.025)",
};
const legendSwatchStyle: CSSProperties = {
width: 10,
height: 10,
borderRadius: 999,
flexShrink: 0,
};
const signalRowStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 10px",
borderRadius: 12,
border: "1px solid rgba(255,255,255,0.05)",
background: "rgba(255,255,255,0.02)",
};
const signalBadgeStyle: CSSProperties = {
padding: "5px 8px",
borderRadius: 999,
background: "rgba(31, 111, 235, 0.16)",
border: "1px solid rgba(127, 208, 255, 0.16)",
color: "#dce9f8",
fontSize: 11,
fontWeight: 700,
whiteSpace: "nowrap",
};
const detailsStyle: CSSProperties = {
borderRadius: 12,
border: "1px solid rgba(255,255,255,0.05)",
background: "rgba(0,0,0,0.14)",
overflow: "hidden",
};
const summaryStyle: CSSProperties = {
cursor: "pointer",
padding: "10px 12px",
color: "#c6d4e3",
fontSize: 12,
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase",
};
const diagnosticsPreStyle: CSSProperties = {
margin: 0,
padding: "0 12px 12px",
color: "#dce9f8",
fontSize: 11,
lineHeight: 1.55,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
};
const emptyTextStyle: CSSProperties = {
color: "#8ea4be",
fontSize: 12,
lineHeight: 1.5,
};
@@ -1,4 +1,4 @@
export { explorationEffectsPlugin } from "./explorationEffectsPlugin";
export { explorationEffectsPluginPhaseC as explorationEffectsPlugin } from "./explorationEffectsPluginPhaseC";
export { legendPlugin } from "./legendPlugin";
export { neighborhoodPanelPlugin } from "./neighborhoodPanelPlugin";
export { temporalOverlayPlugin } from "./temporalOverlayPlugin";
@@ -11,7 +11,6 @@ export type {
GraphPluginOverlayDescriptor,
GraphPluginPanelDescriptor,
GraphPluginRegistryEntry,
GraphPluginRuntime,
GraphPluginToolbarItem,
GraphTemporalState,
} from "./types";
@@ -5,6 +5,14 @@ import type { GraphPlugin } from "./types";
const NEIGHBORHOOD_PANEL_ID = "neighborhood-panel";
const MAX_NEIGHBORS = 10;
function maxWeightBetween(graphRef: any, sourceId: string, targetId: string): number {
let weight = 0;
graphRef.forEachDirectedEdge(sourceId, targetId, (_edgeId: string, attrs: { weight?: number }) => {
weight = Math.max(weight, Number(attrs.weight ?? 0));
});
return weight;
}
function formatNeighborMeta(neighbor: { nodeType: string; degree: number; weight: number }) {
const parts = [neighbor.nodeType, `degree ${neighbor.degree}`];
if (neighbor.weight > 0) {
@@ -51,15 +59,10 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
.neighbors(selected.id)
.map((neighborId) => {
const attrs = context.graph.getNodeAttributes(neighborId);
let weight = 0;
if (context.graph.hasDirectedEdge(selected.id, neighborId)) {
const edge = context.graph.getDirectedEdgeAttributes(selected.id, neighborId) as { weight?: number };
weight = Math.max(weight, Number(edge.weight ?? 0));
}
if (context.graph.hasDirectedEdge(neighborId, selected.id)) {
const edge = context.graph.getDirectedEdgeAttributes(neighborId, selected.id) as { weight?: number };
weight = Math.max(weight, Number(edge.weight ?? 0));
}
const weight = Math.max(
maxWeightBetween(context.graph, selected.id, neighborId),
maxWeightBetween(context.graph, neighborId, selected.id),
);
return {
id: neighborId,
label: String(attrs.label || neighborId),
@@ -1,19 +1,23 @@
import type { ReactNode } from "react";
import type Graph from "graphology";
import type Sigma from "sigma";
import { graph, type EdgeAttributes, type NodeAttributes } from "../../../store/graphStore";
import type { GraphTheme } from "../graphTheme";
import type { GraphSceneRuntime } from "../scene";
import type {
GraphAnalyticsSnapshot,
GraphDiagnosticsSnapshot,
GraphEffectsState,
GraphEffectToggle,
GraphInteractionState,
GraphLoadSummary,
GraphSelectedNodeState,
GraphTemporalState,
GraphViewMode,
} from "../types";
export type { GraphTemporalState } from "../types";
export type GraphPluginId = string;
export type GraphPluginPanelPlacement = "side" | "bottom";
@@ -22,13 +26,6 @@ export interface GraphInspectorState {
ownsSelectionDetails: boolean;
}
export interface GraphTemporalState {
currentTime: Date | null;
activeNodeCount: number | null;
minDate?: string;
maxDate?: string;
}
export type GraphPluginActionRequest =
| { type: "fitView" }
| { type: "focusNode"; nodeId: string }
@@ -67,14 +64,8 @@ export interface GraphPluginOverlayDescriptor {
element: ReactNode;
}
export interface GraphPluginRuntime {
sigma: Sigma;
graph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
}
export interface GraphPluginContext {
readonly sigma: Sigma | null;
readonly scene: GraphSceneRuntime | null;
readonly graph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
readonly displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
readonly theme: GraphTheme;
@@ -85,6 +76,7 @@ export interface GraphPluginContext {
getTemporalState: () => GraphTemporalState | null;
getEffectsState: () => GraphEffectsState;
getDiagnosticsSnapshot: () => GraphDiagnosticsSnapshot | null;
getAnalyticsSnapshot: () => GraphAnalyticsSnapshot | null;
isPanelOpen: (panelId: string) => boolean;
dispatchAction: (action: GraphPluginActionRequest) => void;
}
@@ -0,0 +1,65 @@
import type { ForwardRefExoticComponent, ReactNode, RefAttributes } from "react";
import type Graph from "graphology";
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
import type {
GraphAnalyticsSnapshot,
GraphCameraState,
GraphDiagnosticsSnapshot,
GraphEffectsState,
GraphInteractionState,
GraphLayoutSource,
GraphLayoutStatus,
GraphTemporalState,
GraphViewMode,
} from "./types";
export type GraphSceneGraph = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
export type GraphSceneRenderer = "sigma";
export interface GraphSceneRuntime {
renderer: GraphSceneRenderer;
scene: unknown;
graph: GraphSceneGraph;
displayGraph: GraphSceneGraph;
requestRender: () => void;
getCameraState: () => GraphCameraState | null;
}
export interface GraphSceneEventMap {
onNodeSelect?: (nodeId: string) => void;
onEdgeSelect?: (edgeId: string) => void;
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
onCameraStateChange?: (cameraState: GraphCameraState) => void;
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
onRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
}
export interface GraphSceneProps extends GraphSceneEventMap {
selectedNodeId: string;
selectedEdgeId: string;
activePath?: string[];
activePathEdgeIds?: string[];
effectsState: GraphEffectsState;
temporalState?: GraphTemporalState | null;
isLayoutRunning: boolean;
onLayoutRunningChange?: (running: boolean) => void;
layoutSource?: GraphLayoutSource;
onLayoutStatusChange?: (status: GraphLayoutStatus) => void;
viewMode: GraphViewMode;
className?: string;
showFitViewButton?: boolean;
pluginOverlays?: ReactNode[];
}
export interface GraphSceneHandle {
fitView: () => void;
focusNode: (nodeId: string) => void;
getRuntime: () => GraphSceneRuntime | null;
setLayoutRunning?: (running: boolean) => void;
}
export type GraphSceneAdapter = ForwardRefExoticComponent<
GraphSceneProps & RefAttributes<GraphSceneHandle>
>;
@@ -0,0 +1,336 @@
import EdgeCurveProgram, { EdgeCurvedArrowProgram } from "@sigma/edge-curve";
import { NodeProgram, type ProgramInfo } from "sigma/rendering";
import { DEFAULT_EDGE_PROGRAM_CLASSES, DEFAULT_NODE_PROGRAM_CLASSES } from "sigma/settings";
import type { NodeDisplayData, RenderParams } from "sigma/types";
import { floatColor } from "sigma/utils";
import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering";
import { GRAPH_THEME, withAlpha } from "./graphTheme";
type SemanticaNodeDrawData = {
x: number;
y: number;
size: number;
label: string;
color: string;
shellColor?: string;
coreScale?: number;
borderColor?: string;
ringColor?: string;
ringSize?: number;
nodeType?: string;
};
const MINERAL_DISC_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
const MINERAL_DISC_FRAGMENT_SHADER = /* glsl */ `
precision highp float;
varying vec4 v_coreColor;
varying vec4 v_shellColor;
varying vec4 v_ringColor;
varying vec4 v_color;
varying vec2 v_diffVector;
varying float v_radius;
varying float v_ringSize;
varying float v_coreScale;
uniform float u_correctionRatio;
const float bias = 255.0 / 254.0;
const vec4 transparent = vec4(0.0, 0.0, 0.0, 0.0);
float discMetric(vec2 point) {
return length(point);
}
void main(void) {
vec2 unit = v_diffVector / max(v_radius, 0.0001);
float metric = discMetric(unit);
float aa = (2.4 * u_correctionRatio) / max(v_radius, 1.0);
float alpha = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, metric);
#ifdef PICKING_MODE
if (alpha <= 0.0) {
gl_FragColor = transparent;
} else {
gl_FragColor = v_color;
gl_FragColor.a *= bias;
}
#else
if (alpha <= 0.0) {
gl_FragColor = transparent;
return;
}
float ringNorm = clamp(v_ringSize / max(v_radius, 1.0), 0.0, 0.45);
float ringStart = max(0.0, 1.0 - ringNorm);
float coreEdge = clamp(v_coreScale, 0.06, 0.78);
float coreBlend = 1.0 - smoothstep(max(coreEdge - 0.14, 0.0), coreEdge, metric);
float bodyLight = 1.0 - smoothstep(0.0, 0.82, metric);
vec4 color = mix(v_shellColor, v_coreColor, coreBlend);
color.rgb += vec3(0.022) * pow(bodyLight, 1.45);
if (ringNorm > 0.0 && metric >= ringStart) {
color = v_ringColor;
}
color.a *= alpha;
gl_FragColor = color;
#endif
}
`;
const MINERAL_DISC_VERTEX_SHADER = /* glsl */ `
attribute vec4 a_id;
attribute vec2 a_position;
attribute float a_size;
attribute float a_angle;
attribute vec4 a_coreColor;
attribute vec4 a_shellColor;
attribute vec4 a_ringColor;
attribute float a_ringSize;
attribute float a_coreScale;
uniform mat3 u_matrix;
uniform float u_sizeRatio;
uniform float u_correctionRatio;
varying vec4 v_coreColor;
varying vec4 v_shellColor;
varying vec4 v_ringColor;
varying vec4 v_color;
varying vec2 v_diffVector;
varying float v_radius;
varying float v_ringSize;
varying float v_coreScale;
const float bias = 255.0 / 254.0;
void main() {
float size = a_size * u_correctionRatio / u_sizeRatio * 4.0;
vec2 diffVector = size * vec2(cos(a_angle), sin(a_angle));
vec2 position = a_position + diffVector;
gl_Position = vec4(
(u_matrix * vec3(position, 1)).xy,
0,
1
);
v_diffVector = diffVector;
v_radius = size / 2.0;
v_ringSize = a_ringSize;
v_coreScale = a_coreScale;
#ifdef PICKING_MODE
v_color = a_id;
#else
v_coreColor = a_coreColor;
v_shellColor = a_shellColor;
v_ringColor = a_ringColor;
#endif
v_color.a *= bias;
}
`;
class MineralDiscNodeProgram extends NodeProgram<(typeof MINERAL_DISC_UNIFORMS)[number]> {
static readonly ANGLE_1 = 0;
static readonly ANGLE_2 = (2 * Math.PI) / 3;
static readonly ANGLE_3 = (4 * Math.PI) / 3;
drawLabel = drawSemanticaNodeLabel;
drawHover = drawSemanticaNodeHover;
getDefinition() {
return {
VERTICES: 3,
VERTEX_SHADER_SOURCE: MINERAL_DISC_VERTEX_SHADER,
FRAGMENT_SHADER_SOURCE: MINERAL_DISC_FRAGMENT_SHADER,
METHOD: WebGLRenderingContext.TRIANGLES,
UNIFORMS: MINERAL_DISC_UNIFORMS,
ATTRIBUTES: [
{ name: "a_position", size: 2, type: WebGLRenderingContext.FLOAT },
{ name: "a_size", size: 1, type: WebGLRenderingContext.FLOAT },
{ name: "a_coreColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
{ name: "a_shellColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
{ name: "a_ringColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
{ name: "a_ringSize", size: 1, type: WebGLRenderingContext.FLOAT },
{ name: "a_coreScale", size: 1, type: WebGLRenderingContext.FLOAT },
{ name: "a_id", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
],
CONSTANT_ATTRIBUTES: [
{ name: "a_angle", size: 1, type: WebGLRenderingContext.FLOAT },
],
CONSTANT_DATA: [
[MineralDiscNodeProgram.ANGLE_1],
[MineralDiscNodeProgram.ANGLE_2],
[MineralDiscNodeProgram.ANGLE_3],
],
};
}
processVisibleItem(nodeIndex: number, startIndex: number, data: NodeDisplayData & SemanticaNodeDrawData): void {
const array = this.array;
const ringColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, GRAPH_THEME.nodes.selectedRing.color);
array[startIndex++] = data.x;
array[startIndex++] = data.y;
array[startIndex++] = data.size;
array[startIndex++] = floatColor(data.color || GRAPH_THEME.palette.overview.nodeCore);
array[startIndex++] = floatColor(data.shellColor || withAlpha(GRAPH_THEME.palette.overview.nodeBase, GRAPH_THEME.palette.overview.nodeShellAlpha));
array[startIndex++] = floatColor(ringColor);
array[startIndex++] = data.ringSize || 0;
array[startIndex++] = data.coreScale ?? 0.22;
array[startIndex++] = nodeIndex;
}
setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo<(typeof MINERAL_DISC_UNIFORMS)[number]>): void {
gl.uniform1f(uniformLocations.u_correctionRatio, params.correctionRatio);
gl.uniform1f(uniformLocations.u_sizeRatio, params.sizeRatio);
gl.uniformMatrix3fv(uniformLocations.u_matrix, false, params.matrix);
}
}
function resolveAccentBorderColor(
ringSize: number | undefined,
ringColor: string | undefined,
borderColor: string | undefined,
fallbackColor: string,
) {
if (typeof ringSize === "number" && ringSize > 0 && ringColor) {
return ringColor;
}
return borderColor || fallbackColor;
}
function drawRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
) {
const clampedRadius = Math.max(0, Math.min(radius, Math.min(width, height) / 2));
context.beginPath();
context.moveTo(x + clampedRadius, y);
context.lineTo(x + width - clampedRadius, y);
context.quadraticCurveTo(x + width, y, x + width, y + clampedRadius);
context.lineTo(x + width, y + height - clampedRadius);
context.quadraticCurveTo(x + width, y + height, x + width - clampedRadius, y + height);
context.lineTo(x + clampedRadius, y + height);
context.quadraticCurveTo(x, y + height, x, y + height - clampedRadius);
context.lineTo(x, y + clampedRadius);
context.quadraticCurveTo(x, y, x + clampedRadius, y);
context.closePath();
}
export const drawSemanticaNodeLabel: NodeLabelDrawingFunction = (context, rawData) => {
const data = rawData as typeof rawData & SemanticaNodeDrawData;
if (!data.label) {
return;
}
const chipTheme = GRAPH_THEME.labels.chip;
const fontSize = Math.max(
chipTheme.fontSize,
Math.min(chipTheme.maxFontSize, data.size * chipTheme.sizeScale),
);
const font = `${chipTheme.fontWeight} ${fontSize}px ${chipTheme.fontFamily}`;
const paddingX = chipTheme.paddingX;
const paddingY = chipTheme.paddingY;
const borderColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, chipTheme.borderColor);
context.save();
context.font = font;
context.textBaseline = "middle";
const metrics = context.measureText(data.label);
const width = metrics.width + paddingX * 2;
const height = fontSize + paddingY * 2;
const x = data.x + Math.max(data.size * 0.7, chipTheme.offsetX);
const y = data.y - Math.max(data.size * 0.9, chipTheme.offsetY) - height;
context.shadowColor = withAlpha(chipTheme.shadowColor, chipTheme.shadowAlpha);
context.shadowBlur = chipTheme.shadowBlur;
context.fillStyle = chipTheme.background;
drawRoundedRect(context, x, y, width, height, chipTheme.radius);
context.fill();
context.shadowBlur = 0;
context.strokeStyle = withAlpha(borderColor, chipTheme.borderAlpha);
context.lineWidth = 1;
drawRoundedRect(context, x, y, width, height, chipTheme.radius);
context.stroke();
context.fillStyle = chipTheme.textColor;
context.fillText(data.label, x + paddingX, y + height / 2);
context.restore();
};
export const drawSemanticaNodeHover: NodeHoverDrawingFunction = (context, rawData) => {
const data = rawData as typeof rawData & SemanticaNodeDrawData;
if (!data.label) {
return;
}
const hoverTheme = GRAPH_THEME.labels.hoverCard;
const borderColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, hoverTheme.borderColor);
const metaLabel = (typeof data.nodeType === "string" && data.nodeType.trim().length > 0)
? data.nodeType.replaceAll("_", " ").toUpperCase()
: "NODE";
context.save();
context.textBaseline = "top";
const titleFont = `${hoverTheme.titleWeight} ${hoverTheme.titleSize}px ${hoverTheme.fontFamily}`;
const metaFont = `${hoverTheme.metaWeight} ${hoverTheme.metaSize}px ${hoverTheme.fontFamily}`;
context.font = titleFont;
const titleWidth = context.measureText(data.label).width;
context.font = metaFont;
const metaWidth = context.measureText(metaLabel).width;
const width = Math.max(titleWidth, metaWidth) + hoverTheme.paddingX * 2;
const height = hoverTheme.paddingY * 2 + hoverTheme.titleSize + hoverTheme.metaGap + hoverTheme.metaSize;
const x = data.x + Math.max(data.size * 0.9, hoverTheme.offsetX);
const y = data.y - Math.max(data.size * 1.1, hoverTheme.offsetY) - height;
context.shadowColor = withAlpha(hoverTheme.shadowColor, hoverTheme.shadowAlpha);
context.shadowBlur = hoverTheme.shadowBlur;
context.fillStyle = hoverTheme.background;
drawRoundedRect(context, x, y, width, height, hoverTheme.radius);
context.fill();
context.shadowBlur = 0;
context.strokeStyle = withAlpha(borderColor, hoverTheme.borderAlpha);
context.lineWidth = 1.2;
drawRoundedRect(context, x, y, width, height, hoverTheme.radius);
context.stroke();
context.fillStyle = hoverTheme.textColor;
context.font = titleFont;
context.fillText(data.label, x + hoverTheme.paddingX, y + hoverTheme.paddingY);
context.fillStyle = hoverTheme.metaColor;
context.font = metaFont;
context.fillText(
metaLabel,
x + hoverTheme.paddingX,
y + hoverTheme.paddingY + hoverTheme.titleSize + hoverTheme.metaGap,
);
context.restore();
};
export const SEMANTICA_NODE_PROGRAM_CLASSES = {
...DEFAULT_NODE_PROGRAM_CLASSES,
circle: MineralDiscNodeProgram,
};
export const SEMANTICA_EDGE_PROGRAM_CLASSES = {
...DEFAULT_EDGE_PROGRAM_CLASSES,
curve: EdgeCurveProgram,
curvedArrow: EdgeCurvedArrowProgram,
};
@@ -1,8 +1,17 @@
export type GraphViewMode = "focused" | "full";
export type GraphLayoutSource = "provided" | "carried" | "runtime";
export type GraphLayoutState = "idle" | "bootstrapping" | "running" | "stabilized" | "interactive" | "failed";
export type GraphLoadPhase =
| "bootstrapping"
| "fetching_nodes"
| "fetching_edges"
| "computing_styling"
| "hydrating_scene"
| "stabilizing_layout"
| "ready";
export type GraphLoadProgressKind = "determinate" | "indeterminate";
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphEdgeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphEdgeInteractionState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export interface GraphCameraState {
x: number;
@@ -13,8 +22,10 @@ export interface GraphCameraState {
export interface GraphInteractionState {
hoveredNodeId: string | null;
selectedNodeId: string;
selectedEdgeId: string;
focusedNodeId: string;
activePath: string[];
activePathEdgeIds: string[];
viewMode: GraphViewMode;
zoomTier: "overview" | "structure" | "inspection";
isLayoutRunning: boolean;
@@ -24,6 +35,12 @@ export type GraphEffectToggle =
| "pathPulseEnabled"
| "pathFlowEnabled"
| "lensEnabled"
| "temporalEmphasisEnabled"
| "semanticRegionsEnabled"
| "contoursEnabled"
| "pathfindingEnabled"
| "communitiesEnabled"
| "centralityEnabled"
| "legendEnabled"
| "diagnosticsEnabled";
@@ -31,6 +48,12 @@ export interface GraphEffectsState {
pathPulseEnabled: boolean;
pathFlowEnabled: boolean;
lensEnabled: boolean;
temporalEmphasisEnabled: boolean;
semanticRegionsEnabled: boolean;
contoursEnabled: boolean;
pathfindingEnabled: boolean;
communitiesEnabled: boolean;
centralityEnabled: boolean;
legendEnabled: boolean;
diagnosticsEnabled: boolean;
lensMode: "neighborhood";
@@ -55,11 +78,95 @@ export interface GraphDiagnosticsSnapshot {
pathPulse: GraphEffectAvailability;
pathFlow: GraphEffectAvailability;
lens: GraphEffectAvailability;
temporalEmphasis: GraphEffectAvailability;
semanticRegions: GraphEffectAvailability;
contours: GraphEffectAvailability;
pathfinding: GraphEffectAvailability;
communities: GraphEffectAvailability;
centrality: GraphEffectAvailability;
legend: GraphEffectAvailability;
diagnostics: GraphEffectAvailability;
};
}
export interface GraphTemporalState {
currentTime: Date | null;
activeNodeCount: number | null;
minDate?: string;
maxDate?: string;
}
export interface GraphDirectedPathSnapshot {
ready: boolean;
reason: string;
sourceId: string | null;
targetId: string | null;
path: string[];
length: number | null;
verifiedAgainstActivePath: boolean;
}
export interface GraphCommunitySummary {
communityId: string;
nodeCount: number;
visibleNodeCount: number;
dominantSemanticGroup: string;
color: string;
anchorNodeId: string | null;
anchorLabel: string;
prominence: number;
}
export interface GraphSemanticRegionSummary {
semanticGroup: string;
nodeCount: number;
visibleNodeCount: number;
color: string;
anchorNodeId: string | null;
anchorLabel: string;
dominantCommunityId: string | null;
prominence: number;
}
export interface GraphCentralityNodeSummary {
id: string;
label: string;
semanticGroup: string;
color: string;
degree: number;
betweenness: number;
score: number;
}
export interface GraphOverviewBackboneSnapshot {
ready: boolean;
reason: string;
edgeIds: string[];
}
export interface GraphAnalyticsSnapshot {
generatedAt: number;
directedPath: GraphDirectedPathSnapshot;
communities: {
ready: boolean;
reason: string;
count: number;
modularity: number | null;
summaries: GraphCommunitySummary[];
};
centrality: {
ready: boolean;
reason: string;
topNodes: GraphCentralityNodeSummary[];
};
semanticRegions: {
ready: boolean;
reason: string;
summaries: GraphSemanticRegionSummary[];
};
overviewBackbone: GraphOverviewBackboneSnapshot;
}
export interface ApiNode {
id: string;
type: string;
@@ -72,6 +179,8 @@ export interface ApiNode {
}
export interface ApiEdge {
id: string;
familyId: string;
source: string;
target: string;
type: string;
@@ -89,13 +198,21 @@ export interface GraphLoadSummary {
}
export interface GraphLoadProgress {
phase: "nodes" | "edges" | "styling" | "rendering";
phase: GraphLoadPhase;
title: string;
nodesLoaded: number;
nodesTotal: number | null;
edgesLoaded: number;
edgesTotal: number | null;
message: string;
progress: number;
progressKind: GraphLoadProgressKind;
loaded: number | null;
total: number | null;
showGraphBehind: boolean;
stageIndex?: number;
stageCount?: number;
layoutSource?: GraphLayoutSource;
layoutState?: GraphLayoutState;
}
export interface GraphDataSnapshot {
@@ -130,6 +247,21 @@ export interface GraphSelectedNodeState {
neighborCount: number;
}
export interface GraphSelectedEdgeState {
id: string;
familyId: string;
sourceId: string;
sourceLabel: string;
targetId: string;
targetLabel: string;
edgeType: string;
weight: number;
properties: Record<string, unknown>;
provenanceCount: number;
familySize: number;
siblingCount: number;
}
export interface GraphStageHandle {
fitView: () => void;
focusNode: (nodeId: string) => void;
@@ -1,4 +1,5 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { createGraphLoadProgress } from "./graphLoading";
import type { ApiEdge, ApiNode, GraphDataSnapshot, GraphLoadProgress, GraphLayoutSource } from "./types";
interface NodeListResponse {
@@ -46,8 +47,11 @@ async function fetchAllNodes(
total = data.total ?? total;
collected.push(...data.nodes);
onProgress?.({
phase: "nodes",
onProgress?.(createGraphLoadProgress({
phase: "fetching_nodes",
progressKind: total ? "determinate" : "indeterminate",
loaded: collected.length,
total,
nodesLoaded: collected.length,
nodesTotal: total,
edgesLoaded: 0,
@@ -55,8 +59,7 @@ async function fetchAllNodes(
message: total
? `Loading nodes ${collected.length.toLocaleString()} of ${total.toLocaleString()}`
: `Loading nodes ${collected.length.toLocaleString()}`,
progress: total ? Math.min(collected.length / Math.max(total, 1), 0.45) : 0.18,
});
}));
if (!data.next_cursor) {
break;
@@ -76,7 +79,9 @@ async function fetchAllEdges(
): Promise<ApiEdge[]> {
let cursor: string | null = null;
const collected: ApiEdge[] = [];
const seenEdgeIds = new Set<string>();
let total: number | null = null;
let warnedOverTotal = false;
while (true) {
const url = new URL("/api/graph/edges", window.location.origin);
@@ -96,19 +101,38 @@ async function fetchAllEdges(
}
total = data.total ?? total;
const validEdges = data.edges.filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target));
const validEdges = data.edges.filter((edge) => {
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
return false;
}
if (seenEdgeIds.has(edge.id)) {
return false;
}
seenEdgeIds.add(edge.id);
return true;
});
collected.push(...validEdges);
onProgress?.({
phase: "edges",
const safeLoaded = total ? Math.min(seenEdgeIds.size, total) : seenEdgeIds.size;
if (!warnedOverTotal && total !== null && seenEdgeIds.size > total) {
warnedOverTotal = true;
console.warn("[graph-runtime] edge pagination returned more unique edge ids than total", {
uniqueEdgesLoaded: seenEdgeIds.size,
total,
});
}
onProgress?.(createGraphLoadProgress({
phase: "fetching_edges",
progressKind: total ? "determinate" : "indeterminate",
loaded: safeLoaded,
total,
nodesLoaded: nodeProgress.loaded,
nodesTotal: nodeProgress.total,
edgesLoaded: collected.length,
edgesLoaded: safeLoaded,
edgesTotal: total,
message: total
? `Loading edges ${collected.length.toLocaleString()} of ${total.toLocaleString()}`
: `Loading edges ${collected.length.toLocaleString()}`,
progress: total ? 0.45 + Math.min(collected.length / Math.max(total, 1), 1) * 0.33 : 0.68,
});
? `Loading edges ${safeLoaded.toLocaleString()} of ${total.toLocaleString()}`
: `Loading edges ${safeLoaded.toLocaleString()}`,
}));
if (!data.next_cursor) {
break;
@@ -145,15 +169,15 @@ export function useGraphData(options: UseGraphDataOptions = {}) {
staleTime: Infinity,
queryFn: async ({ signal }): Promise<GraphDataSnapshot> => {
const startedAt = performance.now();
onProgress?.({
phase: "nodes",
onProgress?.(createGraphLoadProgress({
phase: "bootstrapping",
progressKind: "indeterminate",
nodesLoaded: 0,
nodesTotal: null,
edgesLoaded: 0,
edgesTotal: null,
message: "Preparing graph load",
progress: 0.06,
});
message: "Preparing graph session",
}));
const nodes = await fetchAllNodes(signal, onProgress);
const nodeIds = new Set(nodes.map((node) => node.id));
@@ -164,15 +188,15 @@ export function useGraphData(options: UseGraphDataOptions = {}) {
onProgress,
);
onProgress?.({
phase: "edges",
onProgress?.(createGraphLoadProgress({
phase: "hydrating_scene",
progressKind: "indeterminate",
nodesLoaded: nodes.length,
nodesTotal: nodes.length,
edgesLoaded: edges.length,
edgesTotal: edges.length,
message: "Preparing graph runtime",
progress: 0.78,
});
message: "Preparing graph runtime snapshot",
}));
return {
nodes,
@@ -1,6 +1,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { batchMergeEdges, batchMergeNodes, clearGraph } from "../../store/graphStore";
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
import { curveGroupForPair, pairRegistryKey } from "../../store/edgePairKeys.js";
import {
GRAPH_THEME,
clamp,
@@ -12,6 +13,8 @@ import {
type GraphLabelVisibilityPolicy,
type GraphNodeShapeVariant,
} from "./graphTheme";
import { createGraphLoadProgress } from "./graphLoading";
import type { GraphLoadProgress, GraphLoadSummary } from "./types";
const SEMANTIC_COLOR_FIELDS = [
"community",
@@ -110,6 +113,82 @@ function structuralColorKey(nodeId: string, attributes: NodeAttributes): string
return `${attributes.nodeType || "entity"}:${shard}`;
}
function readFiniteCoordinate(value: unknown): number | null {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function seededUnit(value: string): number {
return (hashString(value) % 10000) / 10000;
}
function buildClusterSeedPositions(
nodes: Array<{
id: string;
semanticGroup: string;
priority: number;
}>,
): Map<string, { x: number; y: number }> {
const grouped = new Map<string, Array<{ id: string; priority: number }>>();
nodes.forEach((node) => {
const entry = grouped.get(node.semanticGroup);
if (entry) {
entry.push({ id: node.id, priority: node.priority });
} else {
grouped.set(node.semanticGroup, [{ id: node.id, priority: node.priority }]);
}
});
const groupEntries = [...grouped.entries()]
.sort((left, right) => {
const countDelta = right[1].length - left[1].length;
if (countDelta !== 0) {
return countDelta;
}
return left[0].localeCompare(right[0]);
});
const groupCenters = new Map<string, { x: number; y: number; spread: number }>();
groupEntries.forEach(([group, members], index) => {
const angle = index * 2.399963229728653;
const radius = 170 + Math.sqrt(index + 1) * 195;
const spread = 72 + Math.sqrt(members.length) * 16;
groupCenters.set(group, {
x: Math.cos(angle) * radius * 1.14,
y: Math.sin(angle) * radius * 0.84,
spread,
});
});
const seeded = new Map<string, { x: number; y: number }>();
groupEntries.forEach(([group, members]) => {
const center = groupCenters.get(group);
if (!center) {
return;
}
members
.sort((left, right) => {
if (right.priority !== left.priority) {
return right.priority - left.priority;
}
return left.id.localeCompare(right.id);
})
.forEach((member, index) => {
const angle = index * 2.399963229728653 + seededUnit(`${group}:${member.id}:angle`) * 0.72;
const radial = Math.sqrt((index + 0.5) / Math.max(members.length, 1)) * center.spread;
const jitterX = (seededUnit(`${member.id}:jx`) - 0.5) * center.spread * 0.22;
const jitterY = (seededUnit(`${member.id}:jy`) - 0.5) * center.spread * 0.18;
seeded.set(member.id, {
x: center.x + Math.cos(angle) * radial + jitterX,
y: center.y + Math.sin(angle) * radial * 0.86 + jitterY,
});
});
});
return seeded;
}
function getProvenanceCount(properties: Record<string, unknown>): number {
return PROVENANCE_KEYS.reduce(
(count, key) => (properties[key] !== undefined && properties[key] !== null ? count + 1 : count),
@@ -201,6 +280,8 @@ interface ApiNode {
}
interface ApiEdge {
id: string;
familyId: string;
source: string;
target: string;
type: string;
@@ -224,22 +305,6 @@ interface EdgeListResponse {
next_cursor?: string | null;
}
export interface GraphLoadSummary {
nodeCount: number;
edgeCount: number;
loadTimeMs: number;
}
export interface GraphLoadProgress {
phase: "nodes" | "edges" | "styling" | "rendering";
nodesLoaded: number;
nodesTotal: number | null;
edgesLoaded: number;
edgesTotal: number | null;
message: string;
progress: number;
}
const PAGE_LIMIT = 1000;
async function fetchAllNodes(
@@ -269,8 +334,11 @@ async function fetchAllNodes(
total = data.total ?? total;
collected.push(...data.nodes);
onProgress?.({
phase: "nodes",
onProgress?.(createGraphLoadProgress({
phase: "fetching_nodes",
progressKind: total ? "determinate" : "indeterminate",
loaded: collected.length,
total,
nodesLoaded: collected.length,
nodesTotal: total,
edgesLoaded: 0,
@@ -278,8 +346,7 @@ async function fetchAllNodes(
message: total
? `Loading nodes ${collected.length.toLocaleString()} of ${total.toLocaleString()}`
: `Loading nodes ${collected.length.toLocaleString()}`,
progress: total ? Math.min(collected.length / Math.max(total, 1), 0.45) : 0.18,
});
}));
if (!data.next_cursor) {
break;
@@ -299,7 +366,9 @@ async function fetchAllEdges(
): Promise<ApiEdge[]> {
let cursor: string | null = null;
const collected: ApiEdge[] = [];
const seenEdgeIds = new Set<string>();
let total: number | null = null;
let warnedOverTotal = false;
while (true) {
const url = new URL("/api/graph/edges", window.location.origin);
@@ -319,19 +388,38 @@ async function fetchAllEdges(
}
total = data.total ?? total;
const validEdges = data.edges.filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target));
const validEdges = data.edges.filter((edge) => {
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
return false;
}
if (seenEdgeIds.has(edge.id)) {
return false;
}
seenEdgeIds.add(edge.id);
return true;
});
collected.push(...validEdges);
onProgress?.({
phase: "edges",
const safeLoaded = total ? Math.min(seenEdgeIds.size, total) : seenEdgeIds.size;
if (!warnedOverTotal && total !== null && seenEdgeIds.size > total) {
warnedOverTotal = true;
console.warn("[graph-load] edge pagination returned more unique edge ids than total", {
uniqueEdgesLoaded: seenEdgeIds.size,
total,
});
}
onProgress?.(createGraphLoadProgress({
phase: "fetching_edges",
progressKind: total ? "determinate" : "indeterminate",
loaded: safeLoaded,
total,
nodesLoaded: nodeProgress.loaded,
nodesTotal: nodeProgress.total,
edgesLoaded: collected.length,
edgesLoaded: safeLoaded,
edgesTotal: total,
message: total
? `Loading edges ${collected.length.toLocaleString()} of ${total.toLocaleString()}`
: `Loading edges ${collected.length.toLocaleString()}`,
progress: total ? 0.45 + Math.min(collected.length / Math.max(total, 1), 1) * 0.35 : 0.62,
});
? `Loading edges ${safeLoaded.toLocaleString()} of ${total.toLocaleString()}`
: `Loading edges ${safeLoaded.toLocaleString()}`,
}));
if (!data.next_cursor) {
break;
@@ -363,17 +451,18 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
queryKey: ["graph", "full-load"],
enabled,
staleTime: Infinity,
retry: 0,
queryFn: async ({ signal }): Promise<GraphLoadSummary> => {
const startedAt = performance.now();
onProgress?.({
phase: "nodes",
onProgress?.(createGraphLoadProgress({
phase: "bootstrapping",
progressKind: "indeterminate",
nodesLoaded: 0,
nodesTotal: null,
edgesLoaded: 0,
edgesTotal: null,
message: "Preparing graph load",
progress: 0.06,
});
message: "Preparing graph session",
}));
const fetchedNodes = await fetchAllNodes(signal, onProgress);
const nodeIds = new Set(fetchedNodes.map((node) => node.id));
@@ -394,33 +483,29 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
}
const maxDegree = Math.max(...degreeByNode.values(), 1);
const draftAttributes = fetchedNodes.map((node) => {
const x = Number((node.properties as Record<string, unknown>)?.x ?? Math.random() * 1000 - 500);
const y = Number((node.properties as Record<string, unknown>)?.y ?? Math.random() * 1000 - 500);
return {
id: node.id,
attributes: {
label: node.content || node.id,
x,
y,
nodeType: node.type,
content: node.content,
valid_from: node.valid_from,
valid_until: node.valid_until,
properties: node.properties,
} as NodeAttributes,
};
});
const draftAttributes = fetchedNodes.map((node) => ({
id: node.id,
attributes: {
label: node.content || node.id,
x: 0,
y: 0,
nodeType: node.type,
content: node.content,
valid_from: node.valid_from,
valid_until: node.valid_until,
properties: node.properties,
} as NodeAttributes,
}));
onProgress?.({
phase: "styling",
onProgress?.(createGraphLoadProgress({
phase: "computing_styling",
progressKind: "indeterminate",
nodesLoaded: fetchedNodes.length,
nodesTotal: fetchedNodes.length,
edgesLoaded: fetchedEdges.length,
edgesTotal: fetchedEdges.length,
message: "Computing graph styling",
progress: 0.86,
});
message: "Applying semantic color, sizing, and structural styling",
}));
const colorAccessor = chooseColorAccessor(draftAttributes);
const nodePriorityById = new Map<string, number>();
@@ -431,95 +516,173 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
nodePriorityById.set(nodeId, sizeRatio);
}
const semanticKeyByNodeId = new Map<string, string>();
draftAttributes.forEach(({ id, attributes }) => {
semanticKeyByNodeId.set(id, colorAccessor(id, attributes));
});
const providedCoordinateCount = fetchedNodes.reduce((count, node) => {
const properties = node.properties as Record<string, unknown>;
return readFiniteCoordinate(properties?.x) !== null && readFiniteCoordinate(properties?.y) !== null
? count + 1
: count;
}, 0);
const coordinateCoverage = fetchedNodes.length > 0 ? providedCoordinateCount / fetchedNodes.length : 0;
const useProvidedCoordinates = coordinateCoverage >= 0.92;
const seededPositions = useProvidedCoordinates
? null
: buildClusterSeedPositions(
draftAttributes.map(({ id, attributes }) => ({
id,
semanticGroup: semanticKeyByNodeId.get(id) ?? structuralColorKey(id, attributes),
priority: nodePriorityById.get(id) ?? 0,
})),
);
const nodesToMerge = draftAttributes.map(({ id, attributes }) => {
const semanticGroup = colorAccessor(id, attributes);
const semanticGroup = semanticKeyByNodeId.get(id) ?? colorAccessor(id, attributes);
const colorIndex = hashString(semanticGroup) % GRAPH_THEME.palette.semantic.length;
const baseColor = GRAPH_THEME.palette.semantic[colorIndex];
const sizeRatio = nodePriorityById.get(id) ?? 0;
const dynamicSize = clamp(2.6, 2.6 + 12.4 * sizeRatio, 15.8);
const dynamicSize = clamp(1.8, 1.8 + 8.8 * sizeRatio, 11.8);
const hasTemporalBounds = Boolean(attributes.valid_from || attributes.valid_until);
const provenanceCount = getProvenanceCount(attributes.properties ?? {});
const properties = attributes.properties as Record<string, unknown>;
const providedX = readFiniteCoordinate(properties?.x);
const providedY = readFiniteCoordinate(properties?.y);
const seededPosition = seededPositions?.get(id);
const x = useProvidedCoordinates
? providedX ?? 0
: providedX ?? seededPosition?.x ?? 0;
const y = useProvidedCoordinates
? providedY ?? 0
: providedY ?? seededPosition?.y ?? 0;
return {
id,
attributes: {
...attributes,
x,
y,
semanticGroup,
color: baseColor,
baseColor,
mutedColor: withAlpha(baseColor, GRAPH_THEME.nodes.mutedAlpha),
glowColor: withAlpha(baseColor, 0.34),
glowColor: withAlpha(baseColor, 0.24),
size: dynamicSize,
baseSize: dynamicSize,
visualPriority: sizeRatio,
labelPriority: sizeRatio,
strokeColor: darkenHex(baseColor, 112),
borderColor: darkenHex(baseColor, 112),
borderSize: 0.85,
borderSize: 0.72,
...resolveNodeVariantMetadata(baseColor, sizeRatio, hasTemporalBounds, provenanceCount),
} as NodeAttributes,
};
});
const edgeKeys = new Set(fetchedEdges.map((edge) => `${edge.source}::${edge.target}`));
const edgeKeys = new Set(fetchedEdges.map((edge) => pairRegistryKey(edge.source, edge.target)));
const parallelCounts = new Map<string, number>();
const familyCounts = new Map<string, number>();
fetchedEdges.forEach((edge) => {
const pairKey = pairRegistryKey(edge.source, edge.target);
parallelCounts.set(pairKey, (parallelCounts.get(pairKey) ?? 0) + 1);
familyCounts.set(edge.familyId, (familyCounts.get(edge.familyId) ?? 0) + 1);
});
const parallelOffsets = new Map<string, number>();
const edgesToMerge = fetchedEdges.map((edge) => {
const sourcePriority = nodePriorityById.get(edge.source) ?? 0;
const targetPriority = nodePriorityById.get(edge.target) ?? 0;
const isBidirectional = edgeKeys.has(`${edge.target}::${edge.source}`);
const isBidirectional = edgeKeys.has(pairRegistryKey(edge.target, edge.source));
const pairKey = pairRegistryKey(edge.source, edge.target);
const parallelIndex = parallelOffsets.get(pairKey) ?? 0;
parallelOffsets.set(pairKey, parallelIndex + 1);
const parallelCount = parallelCounts.get(pairKey) ?? 1;
return {
id: edge.id,
familyId: edge.familyId,
source: edge.source,
target: edge.target,
attributes: {
edgeId: edge.id,
familyId: edge.familyId,
sourceId: edge.source,
targetId: edge.target,
weight: edge.weight,
edgeType: edge.type,
properties: edge.properties,
size: clamp(0.45, 0.5 + Math.sqrt(Math.max(Number(edge.weight) || 1, 1)) * 0.38, 1.8),
baseSize: clamp(0.45, 0.5 + Math.sqrt(Math.max(Number(edge.weight) || 1, 1)) * 0.38, 1.8),
size: clamp(0.18, 0.22 + Math.sqrt(Math.max(Number(edge.weight) || 1, 1)) * 0.2, 0.88),
baseSize: clamp(0.18, 0.22 + Math.sqrt(Math.max(Number(edge.weight) || 1, 1)) * 0.2, 0.88),
color: GRAPH_THEME.palette.muted.edgeStructure,
baseColor: GRAPH_THEME.palette.muted.edgeStructure,
mutedColor: GRAPH_THEME.palette.muted.edgeOverview,
visualPriority: Math.max(sourcePriority, targetPriority),
isBidirectional,
edgeFamily: isBidirectional ? "bidirectional" : "line",
curveGroup: isBidirectional
? [edge.source, edge.target].sort().join("::")
: null,
curveGroup: curveGroupForPair(edge.source, edge.target),
type: "line",
isParallelPair: parallelCount > 1,
parallelIndex,
parallelCount,
familySize: familyCounts.get(edge.familyId) ?? 1,
...resolveEdgeVariantMetadata(edge, sourcePriority, targetPriority, isBidirectional),
} as EdgeAttributes,
};
});
onProgress?.({
phase: "rendering",
onProgress?.(createGraphLoadProgress({
phase: "hydrating_scene",
progressKind: "indeterminate",
nodesLoaded: nodesToMerge.length,
nodesTotal: nodesToMerge.length,
edgesLoaded: edgesToMerge.length,
edgesTotal: edgesToMerge.length,
message: "Rendering graph",
progress: 0.96,
});
message: "Preparing renderer and hydrating graph scene",
}));
clearGraph();
batchMergeNodes(nodesToMerge);
batchMergeEdges(edgesToMerge);
try {
clearGraph();
} catch (error) {
console.error("[graph-load] clearGraph failed", error);
throw error;
}
try {
batchMergeNodes(nodesToMerge);
} catch (error) {
console.error("[graph-load] batchMergeNodes failed", error);
throw error;
}
try {
batchMergeEdges(edgesToMerge);
} catch (error) {
console.error("[graph-load] batchMergeEdges failed", error);
throw error;
}
const summary = {
nodeCount: nodesToMerge.length,
edgeCount: edgesToMerge.length,
loadTimeMs: Math.round(performance.now() - startedAt),
hasCoordinates: useProvidedCoordinates,
layoutSource: useProvidedCoordinates ? "provided" : "runtime",
layoutReady: useProvidedCoordinates,
} satisfies GraphLoadSummary;
onProgress?.({
phase: "rendering",
onProgress?.(createGraphLoadProgress({
phase: summary.layoutReady ? "ready" : "stabilizing_layout",
progressKind: "indeterminate",
nodesLoaded: summary.nodeCount,
nodesTotal: summary.nodeCount,
edgesLoaded: summary.edgeCount,
edgesTotal: summary.edgeCount,
message: "Graph ready",
progress: 1,
});
message: summary.layoutReady ? "Graph ready" : "Settling runtime layout",
showGraphBehind: !summary.layoutReady,
layoutSource: summary.layoutSource,
layoutState: summary.layoutReady ? "interactive" : "bootstrapping",
}));
onGraphReady?.(summary);
return summary;
@@ -0,0 +1,111 @@
import test from "node:test";
import assert from "node:assert/strict";
import Graph from "graphology";
import { curveGroupForPair, pairRegistryKey } from "../src/store/edgePairKeys.js";
function normalizeParallelMetadataForPair(graph, source, target) {
const edgeIds = [];
graph.forEachDirectedEdge(source, target, (edgeId) => {
edgeIds.push(String(edgeId));
});
const pairCount = edgeIds.length;
const familyCounts = new Map();
edgeIds.forEach((edgeId) => {
const attrs = graph.getEdgeAttributes(edgeId);
const familyId = String(attrs.familyId || edgeId);
familyCounts.set(familyId, (familyCounts.get(familyId) ?? 0) + 1);
});
edgeIds
.sort((left, right) => left.localeCompare(right))
.forEach((edgeId, index) => {
const attrs = graph.getEdgeAttributes(edgeId);
const familyId = String(attrs.familyId || edgeId);
graph.mergeEdgeAttributes(edgeId, {
edgeId,
familyId,
sourceId: source,
targetId: target,
isParallelPair: pairCount > 1,
parallelIndex: index,
parallelCount: pairCount,
familySize: familyCounts.get(familyId) ?? 1,
curveGroup: curveGroupForPair(source, target),
});
});
}
function batchMergeEdges(graph, edges) {
const touchedPairs = new Map();
for (const { id, familyId, source, target, attributes } of edges) {
const edgeId = String(attributes.edgeId || id);
const resolvedFamilyId = String(attributes.familyId || familyId || edgeId);
if (graph.hasNode(source) && graph.hasNode(target)) {
graph.mergeDirectedEdgeWithKey(edgeId, source, target, {
...attributes,
edgeId,
familyId: resolvedFamilyId,
sourceId: source,
targetId: target,
});
touchedPairs.set(pairRegistryKey(source, target), { source, target });
}
}
touchedPairs.forEach(({ source, target }) => {
normalizeParallelMetadataForPair(graph, source, target);
});
}
test("structured pair keys support node ids containing double colons", () => {
const graph = new Graph({ type: "directed", multi: true, allowSelfLoops: false });
graph.addNode("gene/protein::10");
graph.addNode("gene/protein::472");
graph.addNode("gene/protein::500");
assert.doesNotThrow(() => {
batchMergeEdges(graph, [
{
id: "edge-a",
familyId: "family-1",
source: "gene/protein::10",
target: "gene/protein::472",
attributes: { edgeType: "protein_protein", weight: 1, properties: {} },
},
{
id: "edge-b",
familyId: "family-2",
source: "gene/protein::10",
target: "gene/protein::472",
attributes: { edgeType: "protein_protein", weight: 1, properties: {} },
},
{
id: "edge-c",
familyId: "family-3",
source: "gene/protein::10",
target: "gene/protein::500",
attributes: { edgeType: "protein_protein", weight: 1, properties: {} },
},
]);
});
const edgeA = graph.getEdgeAttributes("edge-a");
const edgeB = graph.getEdgeAttributes("edge-b");
const edgeC = graph.getEdgeAttributes("edge-c");
assert.equal(edgeA.sourceId, "gene/protein::10");
assert.equal(edgeA.targetId, "gene/protein::472");
assert.equal(edgeA.parallelCount, 2);
assert.equal(edgeB.parallelCount, 2);
assert.equal(edgeA.isParallelPair, true);
assert.equal(edgeB.isParallelPair, true);
assert.equal(edgeC.parallelCount, 1);
assert.equal(edgeC.isParallelPair, false);
assert.equal(edgeA.curveGroup, JSON.stringify(["gene/protein::10", "gene/protein::472"]));
assert.equal(edgeC.curveGroup, JSON.stringify(["gene/protein::10", "gene/protein::500"]));
});
+151 -9
View File
@@ -108,6 +108,7 @@ Production Use Cases:
from collections import defaultdict, deque
from dataclasses import dataclass, field
from datetime import datetime, timezone
import json
import threading
import itertools
from typing import Any, Dict, List, Optional, Set, Tuple, Union
@@ -188,6 +189,62 @@ def _pick_first(*values: Any) -> Any:
return None
def _default_edge_id(
source_id: str,
target_id: str,
edge_type: str,
weight: float,
metadata: Dict[str, Any],
valid_from: Optional[str],
valid_until: Optional[str],
) -> str:
payload = json.dumps(
{
"source": source_id,
"target": target_id,
"type": edge_type,
"weight": weight,
"valid_from": valid_from,
"valid_until": valid_until,
"metadata": metadata,
},
sort_keys=True,
default=str,
separators=(",", ":"),
)
return str(uuid.uuid5(uuid.NAMESPACE_URL, payload))
def _resolve_edge_identity(
*,
source_id: str,
target_id: str,
edge_type: str,
weight: float,
metadata: Dict[str, Any],
valid_from: Optional[str],
valid_until: Optional[str],
edge_id: Any = None,
family_id: Any = None,
) -> Tuple[str, str]:
resolved_edge_id = str(
_pick_first(
edge_id,
_default_edge_id(
source_id=source_id,
target_id=target_id,
edge_type=edge_type,
weight=weight,
metadata=metadata,
valid_from=valid_from,
valid_until=valid_until,
),
)
)
resolved_family_id = str(_pick_first(family_id, resolved_edge_id))
return resolved_edge_id, resolved_family_id
def _coerce_metadata_map(*values: Any) -> Dict[str, Any]:
merged: Dict[str, Any] = {}
for value in values:
@@ -282,10 +339,31 @@ class ContextEdge:
source_id: str
target_id: str
edge_type: str
edge_id: str = ""
weight: float = 1.0
family_id: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
valid_from: Optional[str] = None
valid_until: Optional[str] = None
def __post_init__(self) -> None:
self.source_id = str(self.source_id)
self.target_id = str(self.target_id)
self.edge_type = str(self.edge_type or "related_to")
self.weight = _coerce_float(self.weight, default=1.0)
if not isinstance(self.metadata, dict):
self.metadata = {}
self.edge_id, self.family_id = _resolve_edge_identity(
source_id=self.source_id,
target_id=self.target_id,
edge_type=self.edge_type,
weight=self.weight,
metadata=self.metadata,
valid_from=self.valid_from,
valid_until=self.valid_until,
edge_id=self.edge_id,
family_id=self.family_id,
)
def is_active(self, at_time: Optional[datetime] = None) -> bool:
"""Return True if this edge is active at the given time (defaults to now).
@@ -308,6 +386,8 @@ class ContextEdge:
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary format."""
d = {
"id": self.edge_id,
"familyId": self.family_id or self.edge_id,
"source_id": self.source_id,
"target_id": self.target_id,
"type": self.edge_type,
@@ -529,7 +609,19 @@ class ContextGraph:
valid_from = _pick_first(raw_edge.get("valid_from"), edge_props.get("valid_from"))
valid_until = _pick_first(raw_edge.get("valid_until"), edge_props.get("valid_until"))
weight = _coerce_float(_pick_first(raw_edge.get("weight"), edge_props.get("weight")), default=1.0)
internal_edge = ContextEdge(
explicit_edge_id = _pick_first(
raw_edge.get("id"),
raw_edge.get("edge_id"),
edge_props.pop("id", None),
edge_props.pop("edge_id", None),
)
explicit_family_id = _pick_first(
raw_edge.get("familyId"),
raw_edge.get("family_id"),
edge_props.pop("familyId", None),
edge_props.pop("family_id", None),
)
edge_id, family_id = _resolve_edge_identity(
source_id=source_id,
target_id=target_id,
edge_type=str(edge_type or "related_to"),
@@ -537,6 +629,19 @@ class ContextGraph:
metadata=edge_props,
valid_from=valid_from,
valid_until=valid_until,
edge_id=explicit_edge_id,
family_id=explicit_family_id,
)
internal_edge = ContextEdge(
edge_id=edge_id,
source_id=source_id,
target_id=target_id,
edge_type=str(edge_type or "related_to"),
family_id=str(family_id) if family_id is not None else edge_id,
weight=weight,
metadata=edge_props,
valid_from=valid_from,
valid_until=valid_until,
)
if self._add_internal_edge(internal_edge):
@@ -619,6 +724,8 @@ class ContextGraph:
for edge in self._adjacency.get(source_id, []):
if edge.target_id == target_id:
data = edge.metadata.copy()
data["id"] = edge.edge_id
data["familyId"] = edge.family_id or edge.edge_id
data["type"] = edge.edge_type
data["weight"] = edge.weight
return data
@@ -785,12 +892,27 @@ class ContextGraph:
"""
valid_from = properties.pop("valid_from", None)
valid_until = properties.pop("valid_until", None)
explicit_edge_id = properties.pop("id", properties.pop("edge_id", None))
explicit_family_id = properties.pop("familyId", properties.pop("family_id", None))
edge_id, family_id = _resolve_edge_identity(
source_id=source_id,
target_id=target_id,
edge_type=edge_type,
weight=weight,
metadata=properties,
valid_from=valid_from,
valid_until=valid_until,
edge_id=explicit_edge_id,
family_id=explicit_family_id,
)
with self._lock:
return self._add_internal_edge(
ContextEdge(
edge_id=edge_id,
source_id=source_id,
target_id=target_id,
edge_type=edge_type,
family_id=str(family_id) if family_id is not None else edge_id,
weight=weight,
metadata=properties,
valid_from=valid_from,
@@ -1118,11 +1240,15 @@ class ContextGraph:
gen = (
{
"id": e.edge_id,
"familyId": e.family_id or e.edge_id,
"source": e.source_id,
"target": e.target_id,
"type": e.edge_type,
"weight": e.weight,
"metadata": e.metadata,
"valid_from": e.valid_from,
"valid_until": e.valid_until,
}
for e in source
)
@@ -1243,14 +1369,11 @@ class ContextGraph:
if getattr(self, "mutation_callback", None) and not getattr(
self, "_suspend_mutation_callback", False
):
import json
edge_id = json.dumps([edge.source_id, edge.edge_type, edge.target_id])
try:
self.mutation_callback("ADD_EDGE", edge_id, edge.to_dict())
self.mutation_callback("ADD_EDGE", edge.edge_id, edge.to_dict())
except Exception as e:
self.logger.warning(
f"Audit trail callback failed for edge {edge_id}: {e}"
f"Audit trail callback failed for edge {edge.edge_id}: {e}"
)
return True
@@ -1508,11 +1631,15 @@ class ContextGraph:
edges_out = []
for e in self.edges:
entry = {
"id": e.edge_id,
"familyId": e.family_id or e.edge_id,
"source": e.source_id,
"target": e.target_id,
"type": e.edge_type,
"weight": e.weight,
}
if e.metadata:
entry["metadata"] = e.metadata
if e.valid_from is not None:
entry["valid_from"] = e.valid_from
if e.valid_until is not None:
@@ -1549,12 +1676,27 @@ class ContextGraph:
# Add edges — restore validity windows if present
for edge_data in graph_dict.get("edges", []):
edge = ContextEdge(
edge_metadata = edge_data.get("metadata", edge_data.get("properties", {})) or {}
edge_weight = edge_data.get("weight", 1.0)
edge_id, family_id = _resolve_edge_identity(
source_id=edge_data["source"],
target_id=edge_data["target"],
edge_type=edge_data["type"],
weight=edge_data.get("weight", 1.0),
metadata=edge_data.get("metadata", {}),
weight=edge_weight,
metadata=edge_metadata,
valid_from=edge_data.get("valid_from"),
valid_until=edge_data.get("valid_until"),
edge_id=edge_data.get("id", edge_data.get("edge_id")),
family_id=edge_data.get("familyId", edge_data.get("family_id")),
)
edge = ContextEdge(
edge_id=edge_id,
source_id=edge_data["source"],
target_id=edge_data["target"],
edge_type=edge_data["type"],
weight=edge_weight,
family_id=family_id,
metadata=edge_metadata,
valid_from=edge_data.get("valid_from"),
valid_until=edge_data.get("valid_until"),
)
+38 -5
View File
@@ -79,13 +79,18 @@ async def import_file(
target = raw_edge.get("target") or raw_edge.get("target_id") or raw_edge.get("end") or raw_edge.get("end_id") or raw_edge.get("END_ID")
if not source or not target:
continue
edge_properties = raw_edge.get("metadata", raw_edge.get("properties", {})) or {}
edges.append(
{
"id": raw_edge.get("id", raw_edge.get("edge_id")),
"familyId": raw_edge.get("familyId", raw_edge.get("family_id")),
"source_id": str(source),
"target_id": str(target),
"type": raw_edge.get("type", raw_edge.get("relationship", "related_to")),
"weight": float(raw_edge.get("weight", 1.0)),
"properties": raw_edge.get("metadata", raw_edge.get("properties", {})) or {},
"properties": edge_properties,
"valid_from": raw_edge.get("valid_from", edge_properties.get("valid_from")),
"valid_until": raw_edge.get("valid_until", edge_properties.get("valid_until")),
}
)
@@ -111,10 +116,29 @@ async def import_file(
edge_props = {
key: value
for key, value in row.items()
if key not in {"source", "source_id", "target", "target_id", "type", "relationship", "weight", ":START_ID", "START_ID", ":END_ID", "END_ID", ":TYPE"}
if key not in {
"id",
"edge_id",
"familyId",
"family_id",
"source",
"source_id",
"target",
"target_id",
"type",
"relationship",
"weight",
":START_ID",
"START_ID",
":END_ID",
"END_ID",
":TYPE",
}
}
edges.append(
{
"id": row.get("id") or row.get("edge_id"),
"familyId": row.get("familyId") or row.get("family_id"),
"source_id": str(source),
"target_id": str(target),
"type": row.get("type") or row.get("relationship") or row.get(":TYPE") or "related_to",
@@ -165,11 +189,20 @@ async def export_graph(
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["kind", "id", "type", "content", "source", "target", "weight"])
writer.writerow(["kind", "id", "familyId", "type", "content", "source", "target", "weight"])
for node in graph_dict.get("entities", []):
writer.writerow(["node", node.get("id"), node.get("type"), node.get("text"), "", "", ""])
writer.writerow(["node", node.get("id"), "", node.get("type"), node.get("text"), "", "", ""])
for edge in graph_dict.get("relationships", []):
writer.writerow(["edge", "", edge.get("type"), "", edge.get("source"), edge.get("target"), edge.get("metadata", {}).get("weight", "")])
writer.writerow([
"edge",
edge.get("id"),
edge.get("familyId"),
edge.get("type"),
"",
edge.get("source"),
edge.get("target"),
edge.get("metadata", {}).get("weight", edge.get("weight", "")),
])
content = output.getvalue()
media_type = "text/csv"
+2
View File
@@ -156,12 +156,14 @@ async def find_path(
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
total_weight = result.get("total_weight", 0.0) if isinstance(result, dict) else 0.0
edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes)
return PathResponse(
source=node_id,
target=target,
algorithm=algorithm,
path=path_nodes,
edge_ids=edge_ids,
total_weight=total_weight,
)
+3
View File
@@ -22,6 +22,8 @@ class NodeResponse(BaseModel):
class EdgeResponse(BaseModel):
id: str
familyId: str
source: str
target: str
type: str
@@ -63,6 +65,7 @@ class PathResponse(BaseModel):
target: str
algorithm: str
path: List[str]
edge_ids: List[str] = Field(default_factory=list)
total_weight: float = 0.0
+55 -8
View File
@@ -9,7 +9,7 @@ import uuid
from datetime import datetime, UTC
from typing import Any, Dict, Iterable, List, Optional
from ..context.context_graph import ContextGraph
from ..context.context_graph import ContextGraph, _resolve_edge_identity
_KG_AVAILABLE = False
try:
@@ -220,7 +220,31 @@ class GraphSession:
if valid_until is not None:
properties["valid_until"] = valid_until
edge_id, family_id = _resolve_edge_identity(
source_id=str(edge.get("source", edge.get("source_id", ""))),
target_id=str(edge.get("target", edge.get("target_id", ""))),
edge_type=str(edge.get("type", "related_to")),
weight=weight,
metadata=properties,
valid_from=valid_from,
valid_until=valid_until,
edge_id=(
edge.get("id")
or edge.get("edge_id")
or meta.get("id")
or meta.get("edge_id")
),
family_id=(
edge.get("familyId")
or edge.get("family_id")
or meta.get("familyId")
or meta.get("family_id")
),
)
return {
"id": edge_id,
"familyId": family_id,
"source": str(edge.get("source", edge.get("source_id", ""))),
"target": str(edge.get("target", edge.get("target_id", ""))),
"type": str(edge.get("type", "related_to")),
@@ -323,10 +347,7 @@ class GraphSession:
continue
if target and normalized["target"] != target:
continue
edge_key = json.dumps(
[normalized["source"], normalized["type"], normalized["target"]],
separators=(",", ":"),
)
edge_key = str(normalized["id"])
normalized_edges.append(normalized)
keys.append(edge_key)
@@ -344,9 +365,7 @@ class GraphSession:
next_cursor = None
if start_index + limit < total and page_edges:
last = page_edges[-1]
next_cursor = self._encode_cursor(
json.dumps([last["source"], last["type"], last["target"]], separators=(",", ":"))
)
next_cursor = self._encode_cursor(str(last["id"]))
return page_edges, total, next_cursor
@@ -532,15 +551,43 @@ class GraphSession:
],
"relationships": [
{
"id": edge.get("id"),
"familyId": edge.get("familyId"),
"source": edge.get("source"),
"target": edge.get("target"),
"type": edge.get("type", "related_to"),
"weight": edge.get("weight", 1.0),
"metadata": edge.get("properties", {}),
}
for edge in edges
],
}
def resolve_path_edge_ids(self, path_nodes: List[str]) -> List[str]:
if len(path_nodes) < 2:
return []
edge_ids: List[str] = []
with self._lock:
for index in range(len(path_nodes) - 1):
source_id = path_nodes[index]
target_id = path_nodes[index + 1]
candidates = [
edge for edge in self.graph._adjacency.get(source_id, [])
if edge.target_id == target_id
]
if not candidates:
continue
candidates.sort(
key=lambda edge: (
-float(edge.weight),
str(edge.edge_type),
str(edge.edge_id),
)
)
edge_ids.append(str(candidates[0].edge_id))
return edge_ids
def add_nodes(self, nodes: List[Dict[str, Any]]) -> int:
with self._lock:
return self.graph.add_nodes(nodes)
@@ -1,4 +1,4 @@
import{i as e}from"./rolldown-runtime-B1FJdls4.js";import{T as t,a as n}from"./query-vendor-Cbp9LWgA.js";import{r}from"./index-BVBOQ8an.js";var i=r(),a=e(t(),1),o=n(),s=`
import{i as e}from"./rolldown-runtime-B1FJdls4.js";import{T as t,a as n}from"./query-vendor-Cbp9LWgA.js";import{r}from"./index-DDOaKEbH.js";var i=r(),a=e(t(),1),o=n(),s=`
.glass-panel {
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
backdrop-filter: blur(16px) saturate(1.2);
@@ -1,4 +1,4 @@
import{i as e}from"./rolldown-runtime-B1FJdls4.js";import{T as t,a as n}from"./query-vendor-Cbp9LWgA.js";import{r}from"./index-BVBOQ8an.js";var i=r(),a=e(t(),1),o=n(),s=`
import{i as e}from"./rolldown-runtime-B1FJdls4.js";import{T as t,a as n}from"./query-vendor-Cbp9LWgA.js";import{r}from"./index-DDOaKEbH.js";var i=r(),a=e(t(),1),o=n(),s=`
.glass-panel {
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
backdrop-filter: blur(16px) saturate(1.2);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{i as e}from"./rolldown-runtime-B1FJdls4.js";import{T as t,a as n}from"./query-vendor-Cbp9LWgA.js";import{a as r,i,n as a,r as o,t as s}from"./es-BveUcJeu.js";import{n as c}from"./index-BVBOQ8an.js";var l=c(`circle-alert`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`12`,x2:`12`,y1:`8`,y2:`12`,key:`1pkeuh`}],[`line`,{x1:`12`,x2:`12.01`,y1:`16`,y2:`16`,key:`4dfq90`}]]),u=c(`download`,[[`path`,{d:`M12 15V3`,key:`m9g1x1`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`path`,{d:`m7 10 5 5 5-5`,key:`brsn70`}]]),d=c(`file-braces`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`1oajmo`}],[`path`,{d:`M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`mpwhp6`}]]),f=e(t(),1),p=n(),m=`
import{i as e}from"./rolldown-runtime-B1FJdls4.js";import{T as t,a as n}from"./query-vendor-Cbp9LWgA.js";import{a as r,i,n as a,r as o,t as s}from"./es-CS4MDIhr.js";import{n as c}from"./index-DDOaKEbH.js";var l=c(`circle-alert`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`12`,x2:`12`,y1:`8`,y2:`12`,key:`1pkeuh`}],[`line`,{x1:`12`,x2:`12.01`,y1:`16`,y2:`16`,key:`4dfq90`}]]),u=c(`download`,[[`path`,{d:`M12 15V3`,key:`m9g1x1`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`path`,{d:`m7 10 5 5 5-5`,key:`brsn70`}]]),d=c(`file-braces`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`1oajmo`}],[`path`,{d:`M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`mpwhp6`}]]),f=e(t(),1),p=n(),m=`
.glass-panel {
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
backdrop-filter: blur(16px) saturate(1.2);
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{i as e}from"./rolldown-runtime-B1FJdls4.js";import{T as t,a as n}from"./query-vendor-Cbp9LWgA.js";import{r}from"./index-BVBOQ8an.js";import{n as ee,t as te}from"./timeline-vendor-DxuOyw5t.js";var ne=r(),i=e(t(),1),a=n(),o=new Date(`1970-01-01T00:00:00Z`),re=new Date(`2030-01-01T00:00:00Z`),s=`playhead`,ie=500,ae=6,oe=`
import{i as e}from"./rolldown-runtime-B1FJdls4.js";import{T as t,a as n}from"./query-vendor-Cbp9LWgA.js";import{r}from"./index-DDOaKEbH.js";import{n as ee,t as te}from"./timeline-vendor-DxuOyw5t.js";var ne=r(),i=e(t(),1),a=n(),o=new Date(`1970-01-01T00:00:00Z`),re=new Date(`2030-01-01T00:00:00Z`),s=`playhead`,ie=500,ae=6,oe=`
.sem-timeline-wrap .vis-timeline { border: none !important; background: transparent !important; overflow: visible !important; }
.sem-timeline-wrap .vis-panel.vis-background, .sem-timeline-wrap .vis-panel.vis-center { background: transparent !important; }
.sem-timeline-wrap .vis-panel { border-color: rgba(88, 166, 255, 0.15) !important; }
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{t as e}from"./explorationEffectsPlugin-BhFwJ4hP.js";export{e as explorationEffectsPlugin};
@@ -1 +0,0 @@
import{a as e}from"./query-vendor-Cbp9LWgA.js";import{r as t}from"./index-BVBOQ8an.js";var n=t(),r=e(),i=`effects-panel`,a=[{key:`pathPulseEnabled`,label:`Path Pulse`,description:`Animated pulse on the active selected path.`},{key:`pathFlowEnabled`,label:`Path Flow`,description:`Directional flow accents along the active selected path.`},{key:`lensEnabled`,label:`Neighborhood Lens`,description:`Local emphasis around the hovered or selected node.`},{key:`legendEnabled`,label:`Semantic Legend`,description:`Compact semantic group legend for graph orientation.`}];function o(e){return e.available?typeof e.visibleSegments==`number`&&typeof e.segmentCap==`number`?`${e.reason} · ${e.visibleSegments}/${e.segmentCap} segments`:e.reason:e.detail?`${e.reason} · ${e.detail}`:e.reason}function s(e){let t=new Map;return e.graph.forEachNode((n,r)=>{let i=String(r.semanticGroup||r.nodeType||`entity`),a=String(r.baseColor||e.theme.palette.semantic[0]),o=t.get(i);t.set(i,{count:(o?.count??0)+1,color:a})}),[...t.entries()].map(([e,t])=>({group:e,...t})).sort((e,t)=>t.count-e.count).slice(0,e.theme.effects.legend.maxGroups)}function c(e){let t=(0,n.c)(20),{label:i,description:a,checked:s,availability:c,onToggle:l}=e,u;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(u={minWidth:0,flex:1},t[0]=u):u=t[0];let d;t[1]===i?d=t[2]:(d=(0,r.jsx)(`div`,{style:h,children:i}),t[1]=i,t[2]=d);let f;t[3]===a?f=t[4]:(f=(0,r.jsx)(`div`,{style:g,children:a}),t[3]=a,t[4]=f);let p;t[5]===c?p=t[6]:(p=o(c),t[5]=c,t[6]=p);let b;t[7]===p?b=t[8]:(b=(0,r.jsx)(`div`,{style:_,children:p}),t[7]=p,t[8]=b);let x;t[9]!==d||t[10]!==f||t[11]!==b?(x=(0,r.jsxs)(`div`,{style:u,children:[d,f,b]}),t[9]=d,t[10]=f,t[11]=b,t[12]=x):x=t[12];let S=s?y:v,C=s?`On`:`Off`,w;t[13]!==l||t[14]!==S||t[15]!==C?(w=(0,r.jsx)(`button`,{type:`button`,onClick:l,style:S,children:C}),t[13]=l,t[14]=S,t[15]=C,t[16]=w):w=t[16];let T;return t[17]!==x||t[18]!==w?(T=(0,r.jsxs)(`div`,{style:m,children:[x,w]}),t[17]=x,t[18]=w,t[19]=T):T=t[19],T}var l={id:`exploration-effects`,mount:()=>{},unmount:()=>{},onStateChange:()=>{},toolbarItems:e=>[{id:`effects-toggle`,label:`Effects`,title:`Open exploration effects controls`,active:e.isPanelOpen(i),order:18,onClick:()=>e.dispatchAction({type:`togglePanel`,panelId:i})}],renderPanel:e=>{if(!e.isPanelOpen(i))return null;let t=e.getEffectsState(),n=e.getDiagnosticsSnapshot()?.effectAvailability,o=t.legendEnabled?s(e):[];return{id:i,title:`Effects`,placement:`bottom`,order:8,defaultOpen:!1,preferredWidth:420,preferredHeight:320,content:(0,r.jsxs)(`div`,{style:u,children:[(0,r.jsx)(`div`,{style:d,children:`Exploration effects`}),(0,r.jsxs)(`div`,{style:f,children:[(0,r.jsx)(`div`,{style:p,children:`Path and focus`}),a.map(i=>(0,r.jsx)(c,{label:i.label,description:i.description,checked:t[i.key],availability:n?.[i.key===`pathPulseEnabled`?`pathPulse`:i.key===`pathFlowEnabled`?`pathFlow`:i.key===`lensEnabled`?`lens`:`legend`]??{enabled:t[i.key],available:!1,reason:`Waiting for graph runtime`},onToggle:()=>e.dispatchAction({type:`toggleEffect`,effect:i.key})},i.key))]}),t.legendEnabled?(0,r.jsxs)(`div`,{style:f,children:[(0,r.jsx)(`div`,{style:p,children:`Semantic legend`}),o.length?(0,r.jsx)(`div`,{style:{display:`flex`,flexDirection:`column`,gap:8},children:o.map(e=>(0,r.jsxs)(`div`,{style:b,children:[(0,r.jsx)(`span`,{style:{...x,background:e.color,boxShadow:`0 0 0 1px rgba(255,255,255,0.06), 0 0 14px ${e.color}40`}}),(0,r.jsxs)(`div`,{style:{minWidth:0,flex:1},children:[(0,r.jsx)(`div`,{style:h,children:e.group}),(0,r.jsxs)(`div`,{style:_,children:[e.count.toLocaleString(),` nodes`]})]})]},e.group))}):(0,r.jsx)(`div`,{style:S,children:`Legend data will populate when graph metadata is available.`})]}):null,null]})}}},u={display:`flex`,flexDirection:`column`,gap:12},d={color:`#8ea4be`,fontSize:11,fontWeight:700,letterSpacing:`0.08em`,textTransform:`uppercase`},f={display:`flex`,flexDirection:`column`,gap:8,padding:`10px 12px`,borderRadius:14,border:`1px solid rgba(255,255,255,0.06)`,background:`rgba(255,255,255,0.025)`},p={color:`#dce9f8`,fontSize:12,fontWeight:700},m={display:`flex`,alignItems:`center`,gap:12,padding:`8px 0`},h={color:`#f3f7fd`,fontSize:13,fontWeight:600},g={color:`#a1b7cf`,fontSize:12,lineHeight:1.45},_={color:`#7fc6ff`,fontSize:11,lineHeight:1.45},v={minWidth:52,padding:`8px 10px`,borderRadius:999,border:`1px solid rgba(255,255,255,0.08)`,background:`rgba(255,255,255,0.03)`,color:`#cfe0f4`,fontSize:12,fontWeight:700,cursor:`pointer`},y={...v,background:`rgba(31, 111, 235, 0.24)`,border:`1px solid rgba(127, 208, 255, 0.28)`,color:`#eef6ff`},b={display:`flex`,alignItems:`center`,gap:10,padding:`8px 10px`,borderRadius:12,border:`1px solid rgba(255,255,255,0.06)`,background:`rgba(255,255,255,0.025)`},x={width:10,height:10,borderRadius:999,flexShrink:0},S={color:`#8ea4be`,fontSize:12,lineHeight:1.5};export{l as t};
@@ -0,0 +1 @@
import{t as e}from"./explorationEffectsPluginPhaseC-D5jYu8XU.js";export{e as explorationEffectsPluginPhaseC};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{t as e}from"./neighborhoodPanelPlugin-Dra5vWrD.js";export{e as neighborhoodPanelPlugin};
@@ -1 +0,0 @@
import{a as e}from"./query-vendor-Cbp9LWgA.js";var t=e(),n=`neighborhood-panel`,r=10;function i(e){let t=[e.nodeType,`degree ${e.degree}`];return e.weight>0&&t.push(`weight ${e.weight.toFixed(2)}`),t.join(` · `)}var a={id:`neighborhood-panel`,mount:()=>{},unmount:()=>{},onStateChange:()=>{},toolbarItems:e=>[{id:`neighborhood-toggle`,label:`Neighbors`,title:`Toggle neighborhood panel`,active:e.isPanelOpen(n),order:30,onClick:()=>e.dispatchAction({type:`togglePanel`,panelId:n})}],renderPanel:e=>{if(!e.isPanelOpen(n))return null;let a=e.getSelectedNodeState();if(!a)return{id:n,title:`Neighborhood`,placement:`bottom`,order:20,defaultOpen:!1,preferredWidth:360,preferredHeight:260,content:(0,t.jsx)(`div`,{style:p,children:`Select a node to inspect its local neighborhood.`})};let m=e.graph.neighbors(a.id).map(t=>{let n=e.graph.getNodeAttributes(t),r=0;if(e.graph.hasDirectedEdge(a.id,t)){let n=e.graph.getDirectedEdgeAttributes(a.id,t);r=Math.max(r,Number(n.weight??0))}if(e.graph.hasDirectedEdge(t,a.id)){let n=e.graph.getDirectedEdgeAttributes(t,a.id);r=Math.max(r,Number(n.weight??0))}return{id:t,label:String(n.label||t),nodeType:String(n.nodeType||`Entity`),color:String(n.baseColor||n.color||e.theme.palette.semantic[0]),weight:r,degree:e.graph.degree(t)}}).sort((e,t)=>t.weight===e.weight?t.degree===e.degree?e.label.localeCompare(t.label):t.degree-e.degree:t.weight-e.weight).slice(0,r);return{id:n,title:`Neighborhood`,placement:`bottom`,order:20,defaultOpen:!1,preferredWidth:360,preferredHeight:260,content:(0,t.jsxs)(`div`,{style:o,children:[(0,t.jsx)(`div`,{style:s,children:a.label}),(0,t.jsxs)(`div`,{style:c,children:[a.neighborCount.toLocaleString(),` direct neighbors in the full graph`]}),m.length?(0,t.jsx)(`div`,{style:{display:`flex`,flexDirection:`column`,gap:8},children:m.map(n=>(0,t.jsxs)(`button`,{type:`button`,onClick:()=>e.dispatchAction({type:`selectNode`,nodeId:n.id}),style:l,children:[(0,t.jsx)(`span`,{style:{...u,background:n.color,boxShadow:`0 0 16px ${n.color}40`}}),(0,t.jsxs)(`div`,{style:{minWidth:0,flex:1,textAlign:`left`},children:[(0,t.jsx)(`div`,{style:d,children:n.label}),(0,t.jsx)(`div`,{style:f,children:i(n)})]})]},n.id))}):(0,t.jsx)(`div`,{style:p,children:`No direct neighbors are available for this node.`})]})}}},o={display:`flex`,flexDirection:`column`,gap:12},s={color:`#f3f7fd`,fontSize:14,fontWeight:700},c={color:`#8ea4be`,fontSize:12,lineHeight:1.5},l={display:`flex`,alignItems:`center`,gap:10,width:`100%`,padding:`8px 10px`,background:`rgba(255,255,255,0.025)`,border:`1px solid rgba(255,255,255,0.06)`,borderRadius:12,cursor:`pointer`},u={width:10,height:10,borderRadius:999,flexShrink:0},d={color:`#f3f7fd`,fontSize:13,fontWeight:600,overflow:`hidden`,textOverflow:`ellipsis`,whiteSpace:`nowrap`},f={color:`#8ea4be`,fontSize:12},p={color:`#8ea4be`,fontSize:12,lineHeight:1.5};export{a as t};
@@ -0,0 +1 @@
import{t as e}from"./neighborhoodPanelPlugin-qsLfEPn1.js";export{e as neighborhoodPanelPlugin};
@@ -0,0 +1 @@
import{a as e}from"./query-vendor-Cbp9LWgA.js";var t=e(),n=`neighborhood-panel`,r=10;function i(e,t,n){let r=0;return e.forEachDirectedEdge(t,n,(e,t)=>{r=Math.max(r,Number(t.weight??0))}),r}function a(e){let t=[e.nodeType,`degree ${e.degree}`];return e.weight>0&&t.push(`weight ${e.weight.toFixed(2)}`),t.join(` · `)}var o={id:`neighborhood-panel`,mount:()=>{},unmount:()=>{},onStateChange:()=>{},toolbarItems:e=>[{id:`neighborhood-toggle`,label:`Neighbors`,title:`Toggle neighborhood panel`,active:e.isPanelOpen(n),order:30,onClick:()=>e.dispatchAction({type:`togglePanel`,panelId:n})}],renderPanel:e=>{if(!e.isPanelOpen(n))return null;let o=e.getSelectedNodeState();if(!o)return{id:n,title:`Neighborhood`,placement:`bottom`,order:20,defaultOpen:!1,preferredWidth:360,preferredHeight:260,content:(0,t.jsx)(`div`,{style:m,children:`Select a node to inspect its local neighborhood.`})};let h=e.graph.neighbors(o.id).map(t=>{let n=e.graph.getNodeAttributes(t),r=Math.max(i(e.graph,o.id,t),i(e.graph,t,o.id));return{id:t,label:String(n.label||t),nodeType:String(n.nodeType||`Entity`),color:String(n.baseColor||n.color||e.theme.palette.semantic[0]),weight:r,degree:e.graph.degree(t)}}).sort((e,t)=>t.weight===e.weight?t.degree===e.degree?e.label.localeCompare(t.label):t.degree-e.degree:t.weight-e.weight).slice(0,r);return{id:n,title:`Neighborhood`,placement:`bottom`,order:20,defaultOpen:!1,preferredWidth:360,preferredHeight:260,content:(0,t.jsxs)(`div`,{style:s,children:[(0,t.jsx)(`div`,{style:c,children:o.label}),(0,t.jsxs)(`div`,{style:l,children:[o.neighborCount.toLocaleString(),` direct neighbors in the full graph`]}),h.length?(0,t.jsx)(`div`,{style:{display:`flex`,flexDirection:`column`,gap:8},children:h.map(n=>(0,t.jsxs)(`button`,{type:`button`,onClick:()=>e.dispatchAction({type:`selectNode`,nodeId:n.id}),style:u,children:[(0,t.jsx)(`span`,{style:{...d,background:n.color,boxShadow:`0 0 16px ${n.color}40`}}),(0,t.jsxs)(`div`,{style:{minWidth:0,flex:1,textAlign:`left`},children:[(0,t.jsx)(`div`,{style:f,children:n.label}),(0,t.jsx)(`div`,{style:p,children:a(n)})]})]},n.id))}):(0,t.jsx)(`div`,{style:m,children:`No direct neighbors are available for this node.`})]})}}},s={display:`flex`,flexDirection:`column`,gap:12},c={color:`#f3f7fd`,fontSize:14,fontWeight:700},l={color:`#8ea4be`,fontSize:12,lineHeight:1.5},u={display:`flex`,alignItems:`center`,gap:10,width:`100%`,padding:`8px 10px`,background:`rgba(255,255,255,0.025)`,border:`1px solid rgba(255,255,255,0.06)`,borderRadius:12,cursor:`pointer`},d={width:10,height:10,borderRadius:999,flexShrink:0},f={color:`#f3f7fd`,fontSize:13,fontWeight:600,overflow:`hidden`,textOverflow:`ellipsis`,whiteSpace:`nowrap`},p={color:`#8ea4be`,fontSize:12},m={color:`#8ea4be`,fontSize:12,lineHeight:1.5};export{o as t};
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>semantica-explorer</title>
<script type="module" crossorigin src="/assets/index-BVBOQ8an.js"></script>
<script type="module" crossorigin src="/assets/index-DDOaKEbH.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-B1FJdls4.js">
<link rel="modulepreload" crossorigin href="/assets/query-vendor-Cbp9LWgA.js">
<link rel="stylesheet" crossorigin href="/assets/index-Bw-pAf6p.css">
+127 -7
View File
@@ -193,6 +193,43 @@ class TestGraphEdges:
{json.dumps(edge, sort_keys=True) for edge in second_payload["edges"]}
)
def test_list_edges_repeated_request_is_stable(self, client):
first_response = client.get("/api/graph/edges?limit=20")
second_response = client.get("/api/graph/edges?limit=20")
assert first_response.status_code == 200
assert second_response.status_code == 200
assert first_response.json() == second_response.json()
def test_list_edges_pagination_union_matches_total(self, client):
cursor = None
seen_ids: set[str] = set()
seen_rows: set[str] = set()
total = None
while True:
url = "/api/graph/edges?limit=2"
if cursor:
url = f"{url}&cursor={cursor}"
response = client.get(url)
assert response.status_code == 200
payload = response.json()
total = payload["total"] if total is None else total
for edge in payload["edges"]:
row_key = json.dumps(edge, sort_keys=True)
assert row_key not in seen_rows
seen_rows.add(row_key)
assert edge["id"] not in seen_ids
seen_ids.add(edge["id"])
cursor = payload["next_cursor"]
if not cursor:
break
assert total is not None
assert len(seen_ids) == total
class TestSearchAndStats:
def test_search(self, client):
@@ -385,12 +422,14 @@ class TestImportExport:
{"id": "meta_tgt", "type": "test", "properties": {"content": "tgt"}},
],
"edges": [
{
"source": "meta_src",
"target": "meta_tgt",
"type": "tagged",
"metadata": {"label": "important", "weight": 0.7},
}
{
"id": "meta-edge-1",
"familyId": "meta-family",
"source": "meta_src",
"target": "meta_tgt",
"type": "tagged",
"metadata": {"label": "important", "weight": 0.7},
}
],
}
)
@@ -406,7 +445,10 @@ class TestImportExport:
edge_lookup = client.get("/api/graph/edges?source=meta_src&target=meta_tgt")
assert edge_lookup.status_code == 200
props = edge_lookup.json()["edges"][0]["properties"]
edge_payload = edge_lookup.json()["edges"][0]
props = edge_payload["properties"]
assert edge_payload["id"] == "meta-edge-1"
assert edge_payload["familyId"] == "meta-family"
assert props["label"] == "important"
def test_import_csv(self, client):
@@ -518,3 +560,81 @@ class TestGenericGraphFileLoading:
payload = edges.json()
assert payload["total"] == 1
assert payload["edges"][0]["type"] == "candidate"
def test_generic_file_loading_assigns_stable_legacy_edge_ids(self):
payload = {
"nodes": [
{"id": "legacy_src", "type": "entity", "properties": {"content": "Legacy source"}},
{"id": "legacy_tgt", "type": "entity", "properties": {"content": "Legacy target"}},
],
"edges": [
{
"source": "legacy_src",
"target": "legacy_tgt",
"type": "related_to",
"weight": 0.75,
"metadata": {"evidence": "legacy"},
}
],
}
graph_path = self._write_graph_payload(payload)
session_one = GraphSession.from_file(str(graph_path))
session_two = GraphSession.from_file(str(graph_path))
edges_one, total_one = session_one.get_edges(limit=10)
edges_two, total_two = session_two.get_edges(limit=10)
assert total_one == 1
assert total_two == 1
assert edges_one[0]["id"] == edges_two[0]["id"]
assert edges_one[0]["familyId"] == edges_two[0]["familyId"]
def test_multi_edges_same_pair_paginate_as_distinct_stable_edges(self):
graph = ContextGraph(advanced_analytics=False)
graph.add_node("shared_src", node_type="entity", content="Shared source")
graph.add_node("shared_tgt", node_type="entity", content="Shared target")
graph.add_edges(
[
{
"id": "edge-alpha",
"familyId": "family-shared",
"source_id": "shared_src",
"target_id": "shared_tgt",
"type": "supports",
"weight": 0.9,
"properties": {"provenance": "paper-a"},
},
{
"id": "edge-beta",
"familyId": "family-shared",
"source_id": "shared_src",
"target_id": "shared_tgt",
"type": "contradicts",
"weight": 0.6,
"properties": {"provenance": "paper-b"},
},
]
)
app = create_app(session=GraphSession(graph))
with TestClient(app) as test_client:
first = test_client.get("/api/graph/edges?source=shared_src&target=shared_tgt&limit=1")
assert first.status_code == 200
first_payload = first.json()
assert first_payload["total"] == 2
assert len(first_payload["edges"]) == 1
assert first_payload["edges"][0]["familyId"] == "family-shared"
second = test_client.get(
f"/api/graph/edges?source=shared_src&target=shared_tgt&limit=1&cursor={first_payload['next_cursor']}"
)
assert second.status_code == 200
second_payload = second.json()
assert len(second_payload["edges"]) == 1
assert first_payload["edges"][0]["id"] != second_payload["edges"][0]["id"]
repeat = test_client.get("/api/graph/edges?source=shared_src&target=shared_tgt&limit=10")
assert repeat.status_code == 200
repeat_ids = [edge["id"] for edge in repeat.json()["edges"]]
assert repeat_ids == ["edge-alpha", "edge-beta"]