feat(explorer): polish graph explorer visual language

This commit is contained in:
Zohaib Hassnain
2026-04-27 03:10:28 +05:00
parent ca5f081793
commit 379994867d
16 changed files with 3150 additions and 575 deletions
+2
View File
@@ -3,6 +3,7 @@ import type {
GraphArrowVisibilityPolicy,
GraphBadgeKind,
GraphEdgeVariant,
GraphEntityShapeVariant,
GraphLabelVisibilityPolicy,
GraphNodeShapeVariant,
} from "../workspaces/GraphWorkspace/graphTheme";
@@ -36,6 +37,7 @@ export interface NodeAttributes {
borderSize?: number;
nodeVariant?: GraphNodeShapeVariant;
nodeShapeVariant?: GraphNodeShapeVariant;
entityShape?: GraphEntityShapeVariant;
badgeKind?: GraphBadgeKind;
badgeCount?: number;
ringColor?: string;
@@ -25,6 +25,8 @@ import {
collectInteractionRefreshTargets,
createInteractionState,
isEdgeInteractable,
classifyFullGraphEdge,
mapFullEdgeClassToVisualState,
resolveEdgeElementStyle,
resolveEdgeVisualState,
resolveNodeElementStyle,
@@ -47,6 +49,15 @@ import {
drawSemanticaNodeHover,
drawSemanticaNodeLabel,
} from "./sigmaNativeRendering";
import {
buildGraphStructureCurveCache,
clearGraphStructureLayer,
createGraphStructureCacheKey,
drawGraphStructureLayer,
evaluateGraphStructureLayerGate,
getGraphStructureLayerDiagnostics,
type GraphStructureCurveCache,
} from "./graphStructureLayer";
import type {
GraphAnalyticsSnapshot,
GraphCameraState,
@@ -54,8 +65,13 @@ import type {
GraphDisplayStateSnapshot,
GraphDiagnosticsSnapshot,
GraphEffectsState,
GraphFullEdgeClass,
GraphFullEdgeClassCounts,
GraphFullEdgeClassDiagnostics,
GraphInteractionState,
GraphLayoutStatus,
GraphRuntimeDiagnosticsSnapshot,
GraphStructureLayerDiagnostics,
GraphTemporalState,
GraphViewMode,
} from "./types";
@@ -98,7 +114,7 @@ export interface GraphCanvasProps {
onSceneRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
onCameraStateChange?: (cameraState: GraphCameraState) => void;
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
onDiagnosticsChange?: (diagnostics: GraphRuntimeDiagnosticsSnapshot) => void;
onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
}
@@ -800,6 +816,7 @@ function drawNodeBadge(
}
type ReducerSceneState = {
viewMode: GraphViewMode;
zoomTier: GraphZoomTier;
hoveredNodeId: string | null;
selectedNodeId: string;
@@ -810,15 +827,133 @@ type ReducerSceneState = {
edgeEndpointIds: Set<string>;
pathNodeIds: Set<string>;
pathEdgeIds: Set<string>;
highlightedIncidentEdgeIds: Set<string>;
overviewBackboneEdgeIds: Set<string>;
};
const FULL_EDGE_CLASSES: GraphFullEdgeClass[] = [
"hidden",
"backbone",
"bridge",
"local-context",
"selected",
"path",
"muted",
];
function createFullEdgeClassCounts(): GraphFullEdgeClassCounts {
return FULL_EDGE_CLASSES.reduce((counts, edgeClass) => {
counts[edgeClass] = 0;
return counts;
}, {} as GraphFullEdgeClassCounts);
}
function getIncidentEdgeRevealCap(viewMode: GraphViewMode, zoomTier: GraphZoomTier): number {
return GRAPH_THEME.edges.contextCaps[viewMode]?.[zoomTier] ?? 0;
}
function scoreIncidentEdge(
attrs: EdgeAttributes,
edgeId: string,
otherEndpointId: string,
visibleNeighborIds: Set<string>,
focusIds: Set<string>,
pathEdgeIds: Set<string>,
selectedEdgeId: string,
): number {
if (pathEdgeIds.has(edgeId)) {
return Number.POSITIVE_INFINITY;
}
if (selectedEdgeId && edgeId === selectedEdgeId) {
return Number.POSITIVE_INFINITY;
}
const weight = Math.max(Number(attrs.weight ?? attrs.representativeWeight ?? 1) || 1, 1);
const normalizedWeight = Math.min(1, Math.log1p(weight) / Math.log(25));
const visualPriority = Math.max(0, Math.min(Number(attrs.visualPriority ?? 0), 1));
const relationshipStrength = Math.max(0, Math.min(Number(attrs.relationshipStrength ?? 0), 1));
return (visibleNeighborIds.has(otherEndpointId) ? 6 : 0)
+ (focusIds.has(otherEndpointId) ? 1.25 : 0)
+ visualPriority * 2
+ normalizedWeight * 1.4
+ relationshipStrength;
}
function buildHighlightedIncidentEdgeIds(
displayGraph: GraphSceneGraph,
interactionState: GraphInteractionState,
displayState: GraphDisplayStateSnapshot | undefined,
focusIds: Set<string>,
pathEdgeIds: Set<string>,
): Set<string> {
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
if (!primaryNodeId || !displayGraph.hasNode(primaryNodeId)) {
return new Set();
}
const cap = getIncidentEdgeRevealCap(interactionState.viewMode, interactionState.zoomTier);
if (cap <= 0 && !interactionState.selectedEdgeId && pathEdgeIds.size === 0) {
return new Set();
}
const visibleNeighborIds = new Set(
(displayState?.selectedVisibleNeighborIds ?? [])
.filter((nodeId) => displayGraph.hasNode(nodeId)),
);
const candidates: Array<{ edgeId: string; score: number }> = [];
displayGraph.edges(primaryNodeId).forEach((edge) => {
const edgeId = String(edge);
if (!displayGraph.hasEdge(edgeId)) {
return;
}
const [source, target] = displayGraph.extremities(edgeId);
const sourceId = String(source);
const targetId = String(target);
const otherEndpointId = sourceId === primaryNodeId ? targetId : sourceId;
const attrs = displayGraph.getEdgeAttributes(edgeId) as EdgeAttributes;
candidates.push({
edgeId,
score: scoreIncidentEdge(
attrs,
edgeId,
otherEndpointId,
visibleNeighborIds,
focusIds,
pathEdgeIds,
interactionState.selectedEdgeId,
),
});
});
candidates.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.edgeId.localeCompare(right.edgeId);
});
const selected = new Set<string>();
candidates.forEach((candidate) => {
if (
candidate.edgeId === interactionState.selectedEdgeId
|| pathEdgeIds.has(candidate.edgeId)
|| selected.size < cap
) {
selected.add(candidate.edgeId);
}
});
return selected;
}
function buildReducerSceneState(
displayGraph: GraphSceneGraph,
interactionState: GraphInteractionState,
analyticsSnapshot: GraphAnalyticsSnapshot | null,
displayState?: GraphDisplayStateSnapshot,
analyticsSnapshot?: GraphAnalyticsSnapshot | null,
): ReducerSceneState {
const { zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
const { viewMode, zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
const primaryNodeId = hoveredNodeId || selectedNodeId;
const focusIds = primaryNodeId
? (
@@ -827,8 +962,10 @@ function buildReducerSceneState(
: new Set<string>()
)
: new Set<string>();
const pathEdgeIds = buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds);
return {
viewMode,
zoomTier,
hoveredNodeId,
selectedNodeId,
@@ -838,8 +975,74 @@ function buildReducerSceneState(
focusIds,
edgeEndpointIds: buildEdgeEndpointSet(displayGraph, selectedEdgeId),
pathNodeIds: new Set(activePath),
pathEdgeIds: buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds),
pathEdgeIds,
overviewBackboneEdgeIds: new Set(analyticsSnapshot?.overviewBackbone.edgeIds ?? []),
highlightedIncidentEdgeIds: buildHighlightedIncidentEdgeIds(
displayGraph,
interactionState,
displayState,
focusIds,
pathEdgeIds,
),
};
}
function getFullGraphEdgeClass(
displayGraph: GraphSceneGraph,
edgeId: string,
currentState: ReducerSceneState,
): GraphFullEdgeClass {
if (!displayGraph.hasEdge(edgeId)) {
return "hidden";
}
const [source, target] = displayGraph.extremities(edgeId);
const sourceId = String(source);
const targetId = String(target);
const sourceAttrs = displayGraph.hasNode(sourceId)
? displayGraph.getNodeAttributes(sourceId) as NodeAttributes
: undefined;
const targetAttrs = displayGraph.hasNode(targetId)
? displayGraph.getNodeAttributes(targetId) as NodeAttributes
: undefined;
return classifyFullGraphEdge(
edgeId,
sourceId,
targetId,
currentState.zoomTier,
currentState.hoveredNodeId,
currentState.selectedNodeId,
currentState.selectedEdgeId,
currentState.focusIds,
currentState.pathEdgeIds,
currentState.highlightedIncidentEdgeIds,
currentState.overviewBackboneEdgeIds,
sourceAttrs,
targetAttrs,
);
}
function buildFullGraphEdgeClassDiagnostics(
displayGraph: GraphSceneGraph,
currentState: ReducerSceneState,
): GraphFullEdgeClassDiagnostics {
const counts = createFullEdgeClassCounts();
displayGraph.forEachEdge((edgeId) => {
const edgeClass = currentState.viewMode === "full"
? getFullGraphEdgeClass(displayGraph, String(edgeId), currentState)
: "hidden";
counts[edgeClass] += 1;
});
return {
mode: currentState.viewMode,
zoomTier: currentState.zoomTier,
totalEdges: displayGraph.size,
visibleEdges: displayGraph.size - counts.hidden,
counts,
updatedAt: Date.now(),
};
}
@@ -904,6 +1107,9 @@ function applySceneState(
borderSize: style.borderSize,
ringColor: style.showRing ? style.ringColor : style.borderColor,
ringSize: style.ringSize,
entityShape: style.entityShape,
entityShapeKind: style.entityShapeKind,
entityAspectRatio: style.entityAspectRatio,
};
});
@@ -927,18 +1133,35 @@ function applySceneState(
const attrs = data as EdgeAttributes;
const [source, target] = currentGraph.extremities(edge);
const stableEdgeId = String(edge);
const state = resolveEdgeVisualState(
stableEdgeId,
source,
target,
currentState.zoomTier,
currentState.hoveredNodeId,
currentState.selectedNodeId,
currentState.selectedEdgeId,
currentState.focusIds,
currentState.pathEdgeIds,
currentState.overviewBackboneEdgeIds,
const hasActiveInteraction = Boolean(
currentState.hoveredNodeId
|| currentState.selectedNodeId
|| currentState.selectedEdgeId
|| currentState.pathEdgeIds.size > 0,
);
const fullEdgeClass = currentState.viewMode === "full"
? getFullGraphEdgeClass(currentGraph, stableEdgeId, currentState)
: undefined;
const state = currentState.viewMode === "full"
? mapFullEdgeClassToVisualState(
fullEdgeClass ?? "hidden",
{
hoveredNodeId: currentState.hoveredNodeId,
hasActiveInteraction,
},
)
: resolveEdgeVisualState(
stableEdgeId,
String(source),
String(target),
currentState.zoomTier,
currentState.hoveredNodeId,
currentState.selectedNodeId,
currentState.selectedEdgeId,
currentState.focusIds,
currentState.pathEdgeIds,
currentState.highlightedIncidentEdgeIds,
);
const style = resolveEdgeElementStyle(
GRAPH_THEME,
currentState.zoomTier,
@@ -946,6 +1169,9 @@ function applySceneState(
attrs,
source,
target,
currentState.viewMode,
stableEdgeId,
fullEdgeClass,
);
return {
@@ -1017,6 +1243,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
) {
const containerRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLCanvasElement>(null);
const structureLayerCanvasRef = useRef<HTMLCanvasElement | null>(null);
const structureLayerCacheRef = useRef<GraphStructureCurveCache | null>(null);
const structureLayerLastDrawAtRef = useRef<number | null>(null);
const structureLayerDiagnosticsRef = useRef<GraphStructureLayerDiagnostics | null>(null);
const sigmaRef = useRef<Sigma | null>(null);
const fa2Ref = useRef<FA2Layout | null>(null);
const behaviorContextRef = useRef<GraphBehaviorContext | null>(null);
@@ -1037,6 +1267,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [zoomTier, setZoomTier] = useState<GraphZoomTier>("overview");
const [analyticsSnapshot, setAnalyticsSnapshot] = useState<GraphAnalyticsSnapshot | null>(null);
const [layoutSettledEpoch, setLayoutSettledEpoch] = useState(0);
const appliedGraphVersionRef = useRef<number | null>(null);
const fittedDisplaySignatureRef = useRef<DisplayFitSignature | null>(null);
const layoutSyncFrameRef = useRef<number | null>(null);
@@ -1100,6 +1331,12 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
const interactionStateRef = useRef<GraphInteractionState>(interactionState);
interactionStateRef.current = interactionState;
const previousInteractionStateRef = useRef<GraphInteractionState | null>(null);
useEffect(() => {
if (!isLayoutRunning) {
setLayoutSettledEpoch((epoch) => epoch + 1);
}
}, [displayGraph, graphVersion, isLayoutRunning]);
const shouldComputeCommunities = effectsState.communitiesEnabled || effectsState.semanticRegionsEnabled;
const shouldComputeCentrality = effectsState.centralityEnabled || effectsState.semanticRegionsEnabled || effectsState.contoursEnabled;
const analyticsBase = useMemo(
@@ -1110,11 +1347,55 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
[displayGraph, shouldComputeCentrality, shouldComputeCommunities],
);
const reducerSceneState = useMemo(
() => buildReducerSceneState(displayGraph, interactionState, analyticsSnapshot),
[analyticsSnapshot, displayGraph, interactionState],
() => buildReducerSceneState(displayGraph, interactionState, displayState, analyticsSnapshot),
[analyticsSnapshot, displayGraph, displayState, interactionState],
);
const reducerSceneStateRef = useRef<ReducerSceneState>(reducerSceneState);
reducerSceneStateRef.current = reducerSceneState;
const edgeClassDiagnostics = useMemo(
() => buildFullGraphEdgeClassDiagnostics(displayGraph, reducerSceneState),
[displayGraph, reducerSceneState],
);
const structureLayerGate = useMemo(
() => evaluateGraphStructureLayerGate({
mode: GRAPH_THEME.edges.fullGraphStructureLayer.mode,
viewMode,
isLayoutRunning,
edgeDiagnostics: edgeClassDiagnostics,
minimumLiteralEdges: GRAPH_THEME.edges.fullGraphStructureLayer.minimumLiteralEdges,
}),
[edgeClassDiagnostics, isLayoutRunning, viewMode],
);
const structureLayerCache = useMemo(() => {
if (!structureLayerGate.enabled) {
return null;
}
const cacheKey = createGraphStructureCacheKey({
graphVersion,
zoomTier,
layoutSettledEpoch,
overviewBackboneEdgeIds: reducerSceneState.overviewBackboneEdgeIds,
});
return buildGraphStructureCurveCache({
graphRef: displayGraph,
cacheKey,
classifyEdge: (edgeId) => getFullGraphEdgeClass(displayGraph, edgeId, reducerSceneState),
maxCurves: GRAPH_THEME.edges.fullGraphStructureLayer.maxCurves,
curveStrength: GRAPH_THEME.edges.fullGraphStructureLayer.curveStrength,
});
}, [displayGraph, graphVersion, layoutSettledEpoch, reducerSceneState, structureLayerGate.enabled, zoomTier]);
structureLayerCacheRef.current = structureLayerCache;
const structureLayerDiagnostics = useMemo(
() => getGraphStructureLayerDiagnostics({
gate: structureLayerGate,
cache: structureLayerCache,
minimumCurves: GRAPH_THEME.edges.fullGraphStructureLayer.minimumCurves,
canvasAvailable: Boolean(structureLayerCanvasRef.current),
lastDrawAt: structureLayerLastDrawAtRef.current,
}),
[structureLayerCache, structureLayerGate],
);
structureLayerDiagnosticsRef.current = structureLayerDiagnostics;
const displayFitSignature = useMemo<DisplayFitSignature>(() => ({
graphVersion,
viewMode,
@@ -1237,17 +1518,40 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
graphVersion: graphVersionRef.current,
viewMode: viewModeRef.current,
});
fitDisplayGraphInView();
return;
}
const bounds = computeGraphSpaceBounds(currentDisplayGraph, selectionNodeIds);
const selectedDisplayNodeId = selectionNodeIds[0];
const selectedDisplayData = sigma.getNodeDisplayData(selectedDisplayNodeId);
if (selectedDisplayData) {
const viewportPoint = sigma.graphToViewport({
x: selectedDisplayData.x,
y: selectedDisplayData.y,
});
const dimensions = sigma.getDimensions();
if (isPointNearViewport(viewportPoint, dimensions.width, dimensions.height, 96)) {
debugGraphRuntime("camera-selection-visible-noop", {
nodeId,
selectedDisplayNodeId,
graphVersion: graphVersionRef.current,
viewMode: viewModeRef.current,
viewportX: viewportPoint.x,
viewportY: viewportPoint.y,
});
sigma.scheduleRefresh();
return;
}
}
const bounds = computeDisplayedNodeBounds(sigma, selectionNodeIds);
if (!bounds) {
if (attempt < 3) {
debugGraphRuntime("camera-selection-deferred", {
debugGraphRuntime("camera-selection-display-bounds-deferred", {
nodeId,
graphVersion: graphVersionRef.current,
attempt: attempt + 1,
viewMode: viewModeRef.current,
contextCount: selectionNodeIds.length,
});
if (deferredFocusFrameRef.current !== null) {
window.cancelAnimationFrame(deferredFocusFrameRef.current);
@@ -1257,45 +1561,43 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
centerSelectionInViewInternal(nodeId, attempt + 1);
});
} else {
debugGraphRuntime("camera-selection-fallback-fit", {
debugGraphRuntime("camera-selection-display-bounds-fallback-fit", {
nodeId,
graphVersion: graphVersionRef.current,
viewMode: viewModeRef.current,
contextCount: selectionNodeIds.length,
});
fitDisplayGraphInView();
}
return;
}
const globalBounds = computeDisplayedGraphBounds(sigma, currentDisplayGraph);
const selectionBBox = expandGraphSpaceBounds(bounds, globalBounds, {
paddingRatio: 0.2,
minSpanRatio: 0.035,
minSpanFloor: 0.02,
});
if (!selectionBBox) {
debugGraphRuntime("camera-selection-invalid-bounds", {
nodeId,
graphVersion: graphVersionRef.current,
count: bounds.count,
});
fitDisplayGraphInView();
return;
}
const camera = sigma.getCamera();
const currentCameraState = camera.getState();
const target = {
x: (bounds.minX + bounds.maxX) / 2,
y: (bounds.minY + bounds.maxY) / 2,
ratio: currentCameraState.ratio,
angle: currentCameraState.angle,
};
animateCameraToBounds("fit-selection-context", selectionBBox, {
debugGraphRuntime("camera-selection-gentle-center", {
nodeId,
contextCount: bounds.count,
boundsSource: "displayed-selection-context",
minX: bounds.minX,
maxX: bounds.maxX,
minY: bounds.minY,
maxY: bounds.maxY,
referenceMinX: globalBounds?.minX ?? null,
referenceMaxX: globalBounds?.maxX ?? null,
referenceMinY: globalBounds?.minY ?? null,
referenceMaxY: globalBounds?.maxY ?? null,
targetX: target.x,
targetY: target.y,
preservedRatio: target.ratio,
});
}, [animateCameraToBounds, fitDisplayGraphInView]);
void camera.animate(
target,
{ duration: GRAPH_THEME.motion.cameraMs, easing: "quadraticOut" },
);
}, []);
const centerGroupedSelectionInView = useCallback((nodeId: string) => {
const sigma = sigmaRef.current;
@@ -1580,6 +1882,22 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
}
});
try {
const structureCanvas = sigma.createCanvas("structure", {
beforeLayer: "nodes",
afterLayer: "edges",
style: {
pointerEvents: "none",
},
});
structureLayerCanvasRef.current = structureCanvas;
} catch (error) {
debugGraphRuntime("structure-layer-create-failed", {
error: error instanceof Error ? error.message : String(error),
});
structureLayerCanvasRef.current = null;
}
requestAnimationFrame(() => {
syncCameraState(sigma);
});
@@ -1607,6 +1925,14 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
debugGraphRuntime("sigma-killed", {
graphVersion: graphVersionRef.current,
});
if (structureLayerCanvasRef.current) {
try {
sigma.killLayer("structure");
} catch {
// Sigma.kill() also cleans layers; ignore if already removed.
}
}
structureLayerCanvasRef.current = null;
sigma.kill();
}
if (deferredFocusFrameRef.current !== null) {
@@ -1764,8 +2090,24 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
container?.clientWidth ?? 0,
container?.clientHeight ?? 0,
);
onDiagnosticsChange(availability);
}, [analyticsSnapshot, effectsState, interactionState, isLayoutRunning, onDiagnosticsChange, temporalState]);
onDiagnosticsChange({
effectAvailability: availability,
edgeClasses: edgeClassDiagnostics,
structureLayer: structureLayerDiagnosticsRef.current ?? structureLayerDiagnostics,
});
if (import.meta.env.DEV && effectsState.diagnosticsEnabled) {
console.debug("[Edge Truth]", edgeClassDiagnostics);
}
}, [
analyticsSnapshot,
edgeClassDiagnostics,
effectsState,
interactionState,
isLayoutRunning,
onDiagnosticsChange,
structureLayerDiagnostics,
temporalState,
]);
useEffect(() => {
previousInteractionStateRef.current = null;
@@ -1786,6 +2128,59 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
previousInteractionStateRef.current = interactionState;
}, [displayGraph, interactionState, reducerSceneStateRef]);
const drawStructureLayerFrame = useCallback(() => {
const sigma = sigmaRef.current;
const canvas = structureLayerCanvasRef.current;
const cache = structureLayerCacheRef.current;
const diagnostics = getGraphStructureLayerDiagnostics({
gate: structureLayerGate,
cache,
minimumCurves: GRAPH_THEME.edges.fullGraphStructureLayer.minimumCurves,
canvasAvailable: Boolean(canvas),
lastDrawAt: structureLayerLastDrawAtRef.current,
});
structureLayerDiagnosticsRef.current = diagnostics;
if (!sigma || !canvas || !diagnostics.enabled || !cache) {
clearGraphStructureLayer(canvas);
return;
}
const drawn = drawGraphStructureLayer({
sigma,
canvas,
cache,
});
if (drawn) {
structureLayerLastDrawAtRef.current = Date.now();
structureLayerDiagnosticsRef.current = {
...diagnostics,
lastDrawAt: structureLayerLastDrawAtRef.current,
};
}
}, [structureLayerGate]);
useEffect(() => {
const sigma = sigmaRef.current;
if (!graphReady || !sigma) {
return;
}
const draw = () => drawStructureLayerFrame();
sigma.on("afterRender", draw);
draw();
return () => {
sigma.off("afterRender", draw);
};
}, [drawStructureLayerFrame, graphReady, structureLayerCache]);
useEffect(() => {
if (isLayoutRunning || viewMode !== "full") {
clearGraphStructureLayer(structureLayerCanvasRef.current);
}
}, [isLayoutRunning, viewMode]);
const drawOverlayFrame = useCallback(() => {
const sigma = sigmaRef.current;
const overlay = overlayRef.current;
@@ -1847,6 +2242,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
...pathNodeIds,
...(primaryNodeId ? [primaryNodeId] : []),
]);
const lensFocusIds = new Set<string>();
if (primaryNodeId) {
reducerSceneStateRef.current.highlightedIncidentEdgeIds.forEach((edgeId) => {
if (!displayGraph.hasEdge(edgeId)) {
return;
}
const [source, target] = displayGraph.extremities(edgeId);
const otherEndpointId = String(source) === primaryNodeId ? String(target) : String(source);
if (displayGraph.hasNode(otherEndpointId)) {
lensFocusIds.add(otherEndpointId);
}
});
}
const now = performance.now() / 1000;
if (!isLayoutRunning) {
@@ -1909,7 +2317,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
});
if (!isLayoutRunning && primaryNodeId && effectAvailability.lens.available) {
drawLensLayer(context, sigma, primaryNodeId, focusIds);
drawLensLayer(context, sigma, primaryNodeId, lensFocusIds);
}
drawPathEffectsLayer(context, pathSegments, effectsState, effectAvailability, now);
@@ -172,10 +172,10 @@ export function GraphInspectorPanel({
if (!nodeId) {
return (
<div style={{ padding: 32, textAlign: "center", display: "flex", flexDirection: "column", alignItems: "center", gap: 12, marginTop: 32 }}>
<div style={{ width: 40, height: 40, borderRadius: "50%", background: "rgba(74,163,255,0.08)", border: "1px solid rgba(74,163,255,0.14)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: 14, height: 14, borderRadius: "50%", background: "rgba(127,208,255,0.3)" }} />
<div style={{ width: 40, height: 40, borderRadius: "50%", background: "rgba(98, 226, 205, 0.07)", border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: 14, height: 14, borderRadius: "50%", background: GRAPH_THEME.ui.timeline.playheadSoft }} />
</div>
<p style={{ color: "#8b949e", fontSize: 14, margin: 0, lineHeight: 1.6 }}>
<p style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 14, margin: 0, lineHeight: 1.6 }}>
Search for a node or click one in the canvas to inspect its properties.
</p>
</div>
@@ -191,19 +191,19 @@ export function GraphInspectorPanel({
if (!effectiveNodeId) {
return (
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
<div style={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.divider}`, paddingBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<span style={{ background: "#58a6ff", boxShadow: "0 0 10px rgba(88,166,255,0.45)", width: 8, height: 8, borderRadius: "50%" }} />
<span style={{ color: "#58a6ff", fontSize: 12, fontWeight: 700 }}>Selection</span>
<span style={{ background: GRAPH_THEME.ui.timeline.playhead, boxShadow: "0 0 10px rgba(98, 226, 205, 0.34)", width: 8, height: 8, borderRadius: "50%" }} />
<span style={{ color: GRAPH_THEME.ui.timeline.playhead, fontSize: 12, fontWeight: 700 }}>Selection</span>
</div>
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
<h3 style={{ margin: 0, color: GRAPH_THEME.ui.text.strong, fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
{nodeId}
</h3>
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
</div>
<div style={groupedSelectionNoticeStyle}>
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, lineHeight: 1.6 }}>
{canActivateFocused
? "Activate Focused mode to resolve this grouped selection to its canonical node."
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
@@ -233,23 +233,23 @@ export function GraphInspectorPanel({
return (
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
{/* Node identity */}
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
<div style={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.divider}`, paddingBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>
{groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")}
</span>
</div>
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
<h3 style={{ margin: 0, color: GRAPH_THEME.ui.text.strong, fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
{String(attributes?.label ?? effectiveNodeId)}
</h3>
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>
{groupedDisplaySelection ? nodeId : effectiveNodeId}
</div>
{groupedDisplaySelection ? (
<div style={groupedSelectionNoticeStyle}>
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, lineHeight: 1.6 }}>
{canActivateFocused
? `Canonical node available: ${effectiveNodeId}`
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
@@ -267,7 +267,7 @@ export function GraphInspectorPanel({
{/* Temporal bounds */}
{(attributes?.valid_from || attributes?.valid_until) ? (
<div style={{ padding: "10px 12px", background: "rgba(88,166,255,0.08)", border: "1px solid rgba(88,166,255,0.2)", borderRadius: 8, fontSize: 12, color: "#79c0ff", fontFamily: "monospace" }}>
<div style={{ padding: "10px 12px", background: "rgba(233, 196, 122, 0.075)", border: "1px solid rgba(233, 196, 122, 0.22)", borderRadius: 8, fontSize: 12, color: GRAPH_THEME.palette.accent.selected, fontFamily: "monospace" }}>
{attributes?.valid_from ? <div>from: {attributes.valid_from}</div> : null}
{attributes?.valid_until ? <div>until: {attributes.valid_until}</div> : null}
</div>
@@ -343,8 +343,8 @@ export function GraphInspectorPanel({
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
<div>
<div style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600 }}>{prediction.label || prediction.target}</div>
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12 }}>{prediction.type}</div>
</div>
<div style={{ flexShrink: 0 }}>
<div style={{
@@ -352,9 +352,9 @@ export function GraphInspectorPanel({
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
background: "rgba(88,166,255,0.12)",
border: "1px solid rgba(88,166,255,0.22)",
color: "#58a6ff",
background: GRAPH_THEME.ui.timeline.playheadSoft,
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
color: GRAPH_THEME.ui.timeline.playhead,
}}>
{(prediction.score * 100).toFixed(1)}%
</div>
@@ -382,8 +382,8 @@ export function GraphInspectorPanel({
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{attribution.map(({ key, value }) => (
<div key={key} style={propertyCardStyle}>
<div style={{ color: "rgba(88,166,255,0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
<div style={{ color: GRAPH_THEME.ui.timeline.playhead, fontSize: 11, marginBottom: 4 }}>{key}</div>
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, wordBreak: "break-word" }}>
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</div>
</div>
@@ -403,8 +403,8 @@ export function GraphInspectorPanel({
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{propertyEntries.map(([key, value]) => (
<div key={key} style={propertyCardStyle}>
<div style={{ color: "rgba(88,166,255,0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
<div style={{ color: GRAPH_THEME.ui.timeline.playhead, fontSize: 11, marginBottom: 4 }}>{key}</div>
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, wordBreak: "break-word" }}>
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</div>
</div>
@@ -423,27 +423,27 @@ export function GraphInspectorPanel({
const inputStyle: CSSProperties = {
width: "100%",
background: "rgba(4, 10, 18, 0.5)",
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
color: "#edf5ff",
background: GRAPH_THEME.ui.control.inputBg,
border: `1px solid ${GRAPH_THEME.ui.control.inputBorder}`,
color: GRAPH_THEME.ui.text.strong,
borderRadius: 12,
padding: "11px 13px",
fontSize: 13,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.035)",
};
const groupedSelectionNoticeStyle: CSSProperties = {
marginTop: 12,
padding: "10px 12px",
background: "rgba(88,166,255,0.08)",
border: "1px solid rgba(88,166,255,0.2)",
background: "rgba(98, 226, 205, 0.07)",
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
borderRadius: 12,
};
const actionButtonStyle: CSSProperties = {
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
color: "#fff",
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
background: GRAPH_THEME.ui.control.primaryBg,
color: GRAPH_THEME.ui.control.primaryText,
border: `1px solid ${GRAPH_THEME.ui.control.primaryBorder}`,
borderRadius: 12,
padding: "9px 12px",
cursor: "pointer",
@@ -452,47 +452,47 @@ const actionButtonStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
boxShadow: `0 8px 22px ${GRAPH_THEME.palette.background.shellGlow}`,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.07), 0 10px 24px rgba(0,0,0,0.18)",
};
const secondaryActionButtonStyle: CSSProperties = {
...actionButtonStyle,
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
color: "#c6d4e3",
background: GRAPH_THEME.ui.control.defaultBg,
border: `1px solid ${GRAPH_THEME.ui.control.defaultBorder}`,
color: GRAPH_THEME.ui.control.defaultText,
fontWeight: 600,
};
const predictionCardStyle: CSSProperties = {
textAlign: "left",
padding: "10px 12px",
background: "rgba(88, 166, 255, 0.08)",
border: "1px solid rgba(88, 166, 255, 0.12)",
background: "rgba(255, 255, 255, 0.035)",
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
borderRadius: 10,
cursor: "pointer",
width: "100%",
};
const propertyCardStyle: CSSProperties = {
background: "rgba(0, 0, 0, 0.2)",
background: "rgba(255, 255, 255, 0.028)",
padding: "10px 12px",
borderRadius: 10,
border: "1px solid rgba(255, 255, 255, 0.05)",
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
};
const emptyTextStyle: CSSProperties = {
color: "#8b949e",
color: GRAPH_THEME.ui.text.muted,
fontSize: 12,
lineHeight: 1.5,
};
const subtleChipStyle: CSSProperties = {
background: "rgba(255, 255, 255, 0.04)",
color: "#9fb6d2",
color: GRAPH_THEME.ui.text.body,
padding: "4px 8px",
borderRadius: 999,
fontSize: 11,
border: "1px solid rgba(255, 255, 255, 0.06)",
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
};
const sectionStyle: CSSProperties = {
@@ -500,13 +500,13 @@ const sectionStyle: CSSProperties = {
flexDirection: "column",
gap: 10,
padding: 14,
background: "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015))",
border: "1px solid rgba(255, 255, 255, 0.06)",
background: GRAPH_THEME.ui.surface.cardSubtle,
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
borderRadius: 14,
};
const sectionTitleStyle: CSSProperties = {
color: "#8b949e",
color: GRAPH_THEME.ui.text.muted,
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
@@ -527,9 +527,9 @@ const pathNodeChipStyle: CSSProperties = {
gap: 6,
padding: "5px 10px",
borderRadius: 999,
background: "rgba(88,166,255,0.1)",
border: "1px solid rgba(88,166,255,0.22)",
color: "#e6edf3",
background: "rgba(98, 226, 205, 0.08)",
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
color: GRAPH_THEME.ui.text.strong,
fontSize: 12,
fontWeight: 600,
maxWidth: 160,
@@ -542,8 +542,8 @@ const pathNodeIndexStyle: CSSProperties = {
width: 16,
height: 16,
borderRadius: "50%",
background: "rgba(88,166,255,0.22)",
color: "#79c0ff",
background: GRAPH_THEME.ui.timeline.playheadSoft,
color: GRAPH_THEME.ui.timeline.playhead,
fontSize: 9,
fontWeight: 800,
flexShrink: 0,
@@ -559,7 +559,7 @@ const pathEdgeConnectorStyle: CSSProperties = {
const pathEdgeLabelStyle: CSSProperties = {
fontSize: 9,
fontWeight: 700,
color: "#6a7f97",
color: GRAPH_THEME.ui.text.subtle,
letterSpacing: "0.04em",
textTransform: "uppercase",
maxWidth: 70,
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ import { DataSet } from "vis-data";
import { Timeline } from "vis-timeline";
import type { TimelineOptions } from "vis-timeline";
import "vis-timeline/styles/vis-timeline-graph2d.css";
import { GRAPH_THEME } from "./graphTheme";
export interface TimelinePanelProps {
onTimeChange: (time: Date) => void;
@@ -19,35 +20,35 @@ const PLAY_STEP_MONTHS = 6;
const VIS_OVERRIDE_CSS = `
.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; }
.sem-timeline-wrap .vis-panel { border-color: ${GRAPH_THEME.ui.timeline.border} !important; }
.sem-timeline-wrap .vis-time-axis .vis-text {
color: #8b949e !important;
color: ${GRAPH_THEME.ui.timeline.text} !important;
font-size: 11px !important;
font-family: 'JetBrains Mono', 'Fira Code', monospace !important;
padding-top: 3px !important;
}
.sem-timeline-wrap .vis-time-axis .vis-text.vis-major {
color: #c9d1d9 !important;
color: ${GRAPH_THEME.ui.timeline.textStrong} !important;
font-weight: 700 !important;
font-size: 12px !important;
}
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-minor { border-color: rgba(88, 166, 255, 0.07) !important; }
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-major { border-color: rgba(88, 166, 255, 0.18) !important; }
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-minor { border-color: ${GRAPH_THEME.ui.timeline.gridMinor} !important; }
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-major { border-color: ${GRAPH_THEME.ui.timeline.gridMajor} !important; }
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} {
background: rgba(88, 166, 255, 0.15) !important;
background: ${GRAPH_THEME.ui.timeline.playheadSoft} !important;
width: 2px !important;
cursor: ew-resize !important;
z-index: 5 !important;
}
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} > .vis-custom-time-marker {
background: #58a6ff !important;
color: #0d1117 !important;
background: ${GRAPH_THEME.ui.timeline.playhead} !important;
color: ${GRAPH_THEME.ui.text.inverse} !important;
font-size: 10px !important;
font-weight: 700 !important;
border-radius: 3px !important;
padding: 1px 5px !important;
white-space: nowrap !important;
box-shadow: 0 0 8px rgba(88, 166, 255, 0.7) !important;
box-shadow: 0 0 8px rgba(98, 226, 205, 0.45) !important;
}
.sem-timeline-wrap .vis-current-time { display: none !important; }
.sem-timeline-wrap .vis-panel.vis-left { display: none !important; }
@@ -166,14 +167,14 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
useEffect(() => () => stopPlay(), [stopPlay]);
return (
<div style={{ position: "relative", width: "100%", height: "90px", borderTop: "1px solid rgba(88, 166, 255, 0.2)", background: "rgba(1, 4, 9, 0.88)", backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)", display: "flex", alignItems: "stretch", flexShrink: 0 }}>
<div style={{ position: "relative", width: "100%", height: "90px", borderTop: `1px solid ${GRAPH_THEME.ui.timeline.border}`, background: GRAPH_THEME.ui.timeline.background, backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)", display: "flex", alignItems: "stretch", flexShrink: 0 }}>
<style>{VIS_OVERRIDE_CSS}</style>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 16px", borderRight: "1px solid rgba(88, 166, 255, 0.15)", minWidth: 80, flexShrink: 0 }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 16px", borderRight: `1px solid ${GRAPH_THEME.ui.timeline.border}`, minWidth: 80, flexShrink: 0 }}>
<button
id="temporal-play-btn"
onClick={togglePlay}
title={isPlaying ? "Pause Evolution" : "Play Evolution"}
style={{ width: 34, height: 34, borderRadius: "50%", border: `1.5px solid ${isPlaying ? "#58a6ff" : "rgba(88, 166, 255, 0.35)"}`, background: isPlaying ? "rgba(88, 166, 255, 0.2)" : "rgba(88, 166, 255, 0.06)", color: "#58a6ff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s", boxShadow: isPlaying ? "0 0 10px rgba(88, 166, 255, 0.4)" : "none" }}
style={{ width: 34, height: 34, borderRadius: "50%", border: `1.5px solid ${isPlaying ? GRAPH_THEME.ui.control.activeBorder : GRAPH_THEME.ui.control.defaultBorder}`, background: isPlaying ? GRAPH_THEME.ui.timeline.playheadSoft : GRAPH_THEME.ui.control.defaultBg, color: GRAPH_THEME.ui.timeline.playhead, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s", boxShadow: isPlaying ? "0 0 10px rgba(98, 226, 205, 0.32)" : "none" }}
>
{isPlaying ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
@@ -181,12 +182,12 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
)}
</button>
<span style={{ fontSize: 10, color: isPlaying ? "#58a6ff" : "#8b949e", fontFamily: "monospace", letterSpacing: "0.04em", transition: "color 0.2s" }}>
<span style={{ fontSize: 10, color: isPlaying ? GRAPH_THEME.ui.timeline.playhead : GRAPH_THEME.ui.timeline.text, fontFamily: "monospace", letterSpacing: "0.04em", transition: "color 0.2s" }}>
{displayDate}
</span>
</div>
<div style={{ position: "absolute", top: 5, left: 100, fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: "rgba(88, 166, 255, 0.55)", textTransform: "uppercase", pointerEvents: "none", zIndex: 2 }}>
<div style={{ position: "absolute", top: 5, left: 100, fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: GRAPH_THEME.ui.text.subtle, textTransform: "uppercase", pointerEvents: "none", zIndex: 2 }}>
Temporal Scrubber · {minBound.getFullYear()}-{maxBound.getFullYear()}
</div>
@@ -7,11 +7,19 @@ export const clickSelectionBehavior: GraphBehavior = {
onNodeClick: (context, nodeId) => {
context.setHoveredNodeId(nodeId);
context.onEdgeSelectionChange("");
context.onNodeSelectionChange(nodeId);
if (context.getInteractionState().selectedNodeId === nodeId) {
context.onNodeSelectionChange("");
} else {
context.onNodeSelectionChange(nodeId);
}
},
onEdgeClick: (context, edgeId) => {
context.setHoveredNodeId(null);
context.onEdgeSelectionChange(edgeId);
if (context.getInteractionState().selectedEdgeId === edgeId) {
context.onEdgeSelectionChange("");
} else {
context.onEdgeSelectionChange(edgeId);
}
},
onStageClick: (context) => {
context.setHoveredNodeId(null);
@@ -44,8 +44,18 @@ 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;
const MAX_BACKBONE_CENTRAL_LINKS = 36;
const MAX_BACKBONE_BRIDGES = 80;
const MAX_BACKBONE_TOTAL_EDGES = 128;
const MAX_BACKBONE_EDGES_PER_NODE = 5;
const MAX_BACKBONE_PARALLEL_PAIR_EDGES = 2;
type BackboneCandidate = {
edgeId: string;
source: string;
target: string;
score: number;
};
function getNodeLabel(graphRef: GraphRef, nodeId: string): string {
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
@@ -364,6 +374,61 @@ function scoreBackboneEdge(
return weight * 1.4 + (sourceScore + targetScore) * 2.4 + priority * 0.6 + parallelBoost + bidirectionalBoost;
}
function upsertBackboneCandidate(
candidates: Map<string, BackboneCandidate>,
key: string,
candidate: BackboneCandidate,
) {
const current = candidates.get(key);
if (
!current
|| candidate.score > current.score
|| (candidate.score === current.score && candidate.edgeId.localeCompare(current.edgeId) < 0)
) {
candidates.set(key, candidate);
}
}
function addRankedBackboneCandidates(
selected: BackboneCandidate[],
selectedEdgeIds: Set<string>,
nodeUseCounts: Map<string, number>,
pairUseCounts: Map<string, number>,
candidates: Iterable<BackboneCandidate>,
maxToAdd: number,
) {
const ranked = [...candidates].sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.edgeId.localeCompare(right.edgeId);
});
for (const candidate of ranked) {
if (selected.length >= MAX_BACKBONE_TOTAL_EDGES || maxToAdd <= 0 || selectedEdgeIds.has(candidate.edgeId)) {
continue;
}
const pairKey = [candidate.source, candidate.target].sort().join("::");
if ((nodeUseCounts.get(candidate.source) ?? 0) >= MAX_BACKBONE_EDGES_PER_NODE) {
continue;
}
if ((nodeUseCounts.get(candidate.target) ?? 0) >= MAX_BACKBONE_EDGES_PER_NODE) {
continue;
}
if ((pairUseCounts.get(pairKey) ?? 0) >= MAX_BACKBONE_PARALLEL_PAIR_EDGES) {
continue;
}
selected.push(candidate);
selectedEdgeIds.add(candidate.edgeId);
nodeUseCounts.set(candidate.source, (nodeUseCounts.get(candidate.source) ?? 0) + 1);
nodeUseCounts.set(candidate.target, (nodeUseCounts.get(candidate.target) ?? 0) + 1);
pairUseCounts.set(pairKey, (pairUseCounts.get(pairKey) ?? 0) + 1);
maxToAdd -= 1;
}
}
function buildOverviewBackboneSnapshot(
graphRef: GraphRef,
visibleNodeIds: Set<string>,
@@ -379,7 +444,10 @@ function buildOverviewBackboneSnapshot(
};
}
const selected: BackboneCandidate[] = [];
const selectedEdgeIds = new Set<string>();
const nodeUseCounts = new Map<string, number>();
const pairUseCounts = new Map<string, number>();
const regionByNode = new Map<string, string>();
visibleNodeIds.forEach((nodeId) => {
regionByNode.set(nodeId, getNodeSemanticGroup(graphRef, nodeId));
@@ -397,7 +465,7 @@ function buildOverviewBackboneSnapshot(
.map((summary) => summary.id)
.filter((nodeId) => visibleNodeIds.has(nodeId));
const coreLinkCandidates = new Map<string, { edgeId: string; score: number }>();
const coreLinkCandidates = new Map<string, BackboneCandidate>();
anchorIds.forEach((anchorId) => {
collectNodeIncidentEdges(graphRef, anchorId, visibleNodeIds)
.filter((entry) => {
@@ -415,60 +483,88 @@ function buildOverviewBackboneSnapshot(
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 });
}
upsertBackboneCandidate(coreLinkCandidates, pairKey, {
edgeId: entry.edgeId,
source: entry.source,
target: entry.target,
score,
});
});
});
const bridgeByPair = new Map<string, { edgeId: string; score: number }>();
const bridgeCandidates = new Map<string, BackboneCandidate>();
const structuralCandidates = new Map<string, BackboneCandidate>();
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;
const edgeKey = String(edgeId);
const sourceId = String(source);
const targetId = String(target);
const sourceRegion = regionByNode.get(sourceId);
const targetRegion = regionByNode.get(targetId);
const sourceCommunity = base.communitiesByNode.get(sourceId);
const targetCommunity = base.communitiesByNode.get(targetId);
const crossesSemanticRegion = Boolean(sourceRegion && targetRegion && sourceRegion !== targetRegion);
const crossesCommunity = sourceCommunity !== undefined && targetCommunity !== undefined && sourceCommunity !== targetCommunity;
const sourceCentrality = base.centralityByNode.get(sourceId)?.score ?? 0;
const targetCentrality = base.centralityByNode.get(targetId)?.score ?? 0;
const baseScore = scoreBackboneEdge(attrs as EdgeAttributes, sourceId, targetId, base);
const semanticBoost = crossesSemanticRegion ? 0.5 : 0;
const communityBoost = crossesCommunity ? 0.36 : 0;
const topRegionBoost = sourceRegion && targetRegion && (topRegionIds.has(sourceRegion) || topRegionIds.has(targetRegion)) ? 0.32 : 0;
const centralityBalance = Math.min(sourceCentrality, targetCentrality) * 1.2;
const score = baseScore + semanticBoost + communityBoost + topRegionBoost + centralityBalance;
const candidate = {
edgeId: edgeKey,
source: sourceId,
target: targetId,
score,
};
if (crossesSemanticRegion || crossesCommunity) {
const bridgeKey = [
sourceRegion ?? `community:${sourceCommunity ?? sourceId}`,
targetRegion ?? `community:${targetCommunity ?? targetId}`,
Math.min(sourceCentrality, targetCentrality).toFixed(4),
].sort().join("::");
upsertBackboneCandidate(bridgeCandidates, bridgeKey, candidate);
}
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 });
}
const pairKey = [sourceId, targetId].sort().join("::");
upsertBackboneCandidate(structuralCandidates, pairKey, candidate);
});
[...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));
addRankedBackboneCandidates(
selected,
selectedEdgeIds,
nodeUseCounts,
pairUseCounts,
bridgeCandidates.values(),
MAX_BACKBONE_BRIDGES,
);
addRankedBackboneCandidates(
selected,
selectedEdgeIds,
nodeUseCounts,
pairUseCounts,
coreLinkCandidates.values(),
MAX_BACKBONE_CENTRAL_LINKS,
);
addRankedBackboneCandidates(
selected,
selectedEdgeIds,
nodeUseCounts,
pairUseCounts,
structuralCandidates.values(),
MAX_BACKBONE_TOTAL_EDGES - selected.length,
);
[...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));
const edgeIds = selected
.map((entry) => entry.edgeId)
.filter((edgeId) => graphRef.hasEdge(edgeId));
return {
ready: edgeIds.length > 0,
@@ -266,8 +266,8 @@ function renderDensityField(
(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(0, "rgba(255,255,255,0.05)");
gradient.addColorStop(0.58, "rgba(255,255,255,0.02)");
gradient.addColorStop(1, "rgba(255,255,255,0)");
context.fillStyle = gradient;
context.beginPath();
@@ -9,6 +9,7 @@ import {
type GraphBadgeKind,
type GraphEdgeVariant,
type GraphEdgeVisualState,
type GraphEntityShapeVariant,
type GraphLabelVisibilityPolicy,
type GraphNodeShapeVariant,
type GraphNodeVisualState,
@@ -22,6 +23,7 @@ import { computeGraphAnalyticsBase } from "./graphAnalytics";
import type {
GraphDisplayMeta,
GraphDisplayStateSnapshot,
GraphFullEdgeClass,
GraphInteractionState,
GraphSelectedNodeKind,
GraphViewMode,
@@ -84,6 +86,9 @@ export type ResolvedNodeStyle = {
borderColor: string;
borderSize: number;
nodeVariant: GraphNodeShapeVariant;
entityShape: GraphEntityShapeVariant;
entityShapeKind: number;
entityAspectRatio: number;
badgeKind?: GraphBadgeKind;
badgeCount?: number;
showBadge: boolean;
@@ -597,6 +602,7 @@ function resolveNodeColor(
) {
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
const entityShapeConfig = theme.nodes.entityShapes[resolveEntityShape(attrs)];
const overviewTint = state === "neighbor"
? theme.palette.overview.nodeTintMix + 0.09
: theme.palette.overview.nodeTintMix;
@@ -628,10 +634,10 @@ function resolveNodeColor(
);
return withAlpha(
boostedCore,
isCommunityGroup ? Math.min(overviewAlpha, theme.grouped.style.fillAlpha) : overviewAlpha,
isCommunityGroup ? Math.min(overviewAlpha, theme.grouped.style.fillAlpha) : Math.min(overviewAlpha, entityShapeConfig.fillAlpha),
);
}
return isCommunityGroup ? withAlpha(semanticColor, theme.grouped.style.fillAlpha) : semanticColor;
return withAlpha(semanticColor, isCommunityGroup ? theme.grouped.style.fillAlpha : entityShapeConfig.fillAlpha);
}
}
@@ -645,39 +651,36 @@ function resolveNodeShellColor(
) {
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
const entityShapeConfig = theme.nodes.entityShapes[resolveEntityShape(attrs)];
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
const overviewShell = blendHex(
theme.palette.overview.nodeBase,
semanticColor,
(state === "neighbor" ? 0.02 : 0.012) + presenceBoost * 0.024,
);
const overviewGlyph = blendHex(semanticColor, theme.ui.text.strong, 0.34 + presenceBoost * 0.12);
if (zoomTier !== "overview") {
return withAlpha(
blendHex(theme.palette.overview.nodeBase, semanticColor, 0.26),
isCommunityGroup ? theme.grouped.style.shellAlpha : 0.95,
blendHex(semanticColor, theme.ui.text.strong, state === "default" ? 0.26 : 0.42),
isCommunityGroup ? theme.grouped.style.shellAlpha : Math.min(0.72, entityShapeConfig.shellAlpha + 0.12),
);
}
if (state === "selected") {
return withAlpha(blendHex(theme.palette.overview.nodeBase, theme.palette.accent.selected, 0.05), 0.98);
return withAlpha(blendHex(theme.palette.accent.selected, theme.ui.text.strong, 0.24), 0.92);
}
if (state === "hovered") {
return withAlpha(blendHex(theme.palette.overview.nodeBase, theme.palette.accent.hovered, 0.06), 0.98);
return withAlpha(blendHex(theme.palette.accent.hovered, theme.ui.text.strong, 0.22), 0.9);
}
if (state === "path") {
return withAlpha(blendHex(theme.palette.overview.nodeBase, theme.palette.accent.path, 0.06), 0.97);
return withAlpha(blendHex(theme.palette.accent.path, theme.ui.text.strong, 0.22), 0.9);
}
if (state === "muted" || state === "inactive") {
return withAlpha(theme.palette.overview.nodeMuted, 0.22);
return withAlpha(theme.palette.overview.nodeBorder, 0.16);
}
return withAlpha(
overviewShell,
isCommunityGroup ? theme.grouped.style.shellAlpha : theme.palette.overview.nodeShellAlpha,
overviewGlyph,
isCommunityGroup ? theme.grouped.style.shellAlpha : Math.min(0.58, entityShapeConfig.shellAlpha + 0.08),
);
}
@@ -730,12 +733,17 @@ function resolveEdgeColor(
state: GraphEdgeVisualState,
attrs: EdgeAttributes,
fallbackColor?: string,
fullEdgeClass?: GraphFullEdgeClass,
) {
const defaultInspectionColor = zoomTier === "overview"
? theme.palette.overview.edgeInspection
: theme.palette.muted.edgeInspection;
const baseColor = String(attrs.baseColor || fallbackColor || defaultInspectionColor);
if (fullEdgeClass === "bridge") {
return theme.palette.muted.edgeFocus;
}
switch (theme.edges.states[state].color) {
case "hover":
return theme.palette.accent.hovered;
@@ -760,12 +768,117 @@ function resolveEdgeColor(
case "muted":
return zoomTier === "overview"
? theme.palette.overview.edgeStructure
: String(attrs.mutedColor || theme.palette.muted.edgeOverview);
: theme.palette.muted.edgeOverview;
default:
return baseColor;
}
}
function resolveEdgeVisibilityPolicy(
theme: GraphTheme,
viewMode: GraphViewMode,
zoomTier: GraphZoomTier,
isCommunityBundle: boolean,
) {
const policyMode = isCommunityBundle ? "grouped" : viewMode;
return theme.edges.visibility[policyMode][zoomTier];
}
function isContextEdgeState(state: GraphEdgeVisualState): boolean {
return state === "path"
|| state === "selected"
|| state === "hovered"
|| state === "neighbor"
|| state === "backbone";
}
function isNonCriticalEdgeVariant(variant: GraphEdgeVariant): boolean {
return variant === "line"
|| variant === "directional"
|| variant === "parallelCurve"
|| variant === "bidirectionalCurve";
}
function shouldSampleOutBackgroundEdge(
sampleRate: number,
visualPriority: number,
edgeId?: string,
sourceId?: string,
targetId?: string,
): boolean {
if (sampleRate >= 1) {
return false;
}
const sampleKey = edgeId || `${sourceId ?? "?"}->${targetId ?? "?"}`;
const bucket = (hashString(sampleKey) % 10000) / 10000;
const priorityAdjustedSampleRate = Math.min(1, sampleRate + visualPriority * 0.08);
return bucket > priorityAdjustedSampleRate;
}
function resolveEdgeLodAlpha(
theme: GraphTheme,
viewMode: GraphViewMode,
zoomTier: GraphZoomTier,
state: GraphEdgeVisualState,
attrs: EdgeAttributes,
isCommunityBundle: boolean,
fullEdgeClass?: GraphFullEdgeClass,
): number | null {
const policy = resolveEdgeVisibilityPolicy(theme, viewMode, zoomTier, isCommunityBundle);
if (viewMode === "full") {
if (fullEdgeClass === "path" || state === "path") {
return theme.interaction.pathEdgeAlpha;
}
if (fullEdgeClass === "selected" || state === "selected") {
return theme.interaction.selectedEdgeAlpha;
}
if (fullEdgeClass === "local-context" || state === "hovered") {
return theme.interaction.localContextAlpha;
}
if (fullEdgeClass === "bridge") {
return theme.edges.fullGraphStructure.bridgeAlpha;
}
if (fullEdgeClass === "backbone") {
return theme.edges.fullGraphStructure.backboneAlpha;
}
if (state === "backbone") {
return theme.edges.fullGraphStructure.ambientBackboneAlpha;
}
if (state === "default" && zoomTier === "structure") {
return theme.edges.fullGraphStructure.structureEdgeAlpha;
}
if (state === "default" && zoomTier === "inspection") {
return theme.edges.fullGraphStructure.inspectionEdgeAlpha;
}
}
if (state === "path") {
return theme.interaction.pathEdgeAlpha;
}
if (state === "selected" || state === "hovered") {
return state === "selected" ? theme.interaction.selectedEdgeAlpha : theme.interaction.hoverContextAlpha;
}
if (state === "default") {
return policy.defaultAlpha;
}
if (state === "muted") {
return policy.mutedAlpha;
}
if (state === "inactive") {
return policy.inactiveAlpha;
}
if (state === "neighbor") {
if (attrs.bundleKind === "community") {
return Math.max(policy.neighborAlpha, theme.grouped.style.edgeAlpha);
}
return policy.neighborAlpha;
}
if (isCommunityBundle && !isContextEdgeState(state)) {
return theme.grouped.style.edgeAlpha;
}
return null;
}
function resolveNodeRingColor(
theme: GraphTheme,
state: GraphNodeVisualState,
@@ -846,7 +959,7 @@ export function resolveEdgeVisualState(
selectedEdgeId: string,
focusIds: Set<string>,
pathEdgeIds: Set<string>,
overviewBackboneEdgeIds: Set<string>,
highlightedIncidentEdgeIds: Set<string> = new Set(),
): GraphEdgeVisualState {
const primaryNodeId = hoveredNodeId || selectedNodeId;
@@ -859,17 +972,16 @@ export function resolveEdgeVisualState(
}
if (primaryNodeId && (source === primaryNodeId || target === primaryNodeId)) {
return hoveredNodeId ? "hovered" : "selected";
if (highlightedIncidentEdgeIds.has(edgeId)) {
return hoveredNodeId ? "hovered" : "selected";
}
return "muted";
}
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";
}
@@ -881,6 +993,90 @@ export function resolveEdgeVisualState(
return "default";
}
function getNodeBridgeGroup(attrs?: NodeAttributes): string {
if (!attrs) {
return "";
}
return String(attrs.communityId || attrs.semanticGroup || attrs.nodeType || "");
}
function isCuratedBridgeEdge(sourceAttrs?: NodeAttributes, targetAttrs?: NodeAttributes): boolean {
const sourceGroup = getNodeBridgeGroup(sourceAttrs);
const targetGroup = getNodeBridgeGroup(targetAttrs);
return Boolean(sourceGroup && targetGroup && sourceGroup !== targetGroup);
}
export function classifyFullGraphEdge(
edgeId: string,
source: string,
target: string,
zoomTier: GraphZoomTier,
hoveredNodeId: string | null,
selectedNodeId: string,
selectedEdgeId: string,
focusIds: Set<string>,
pathEdgeIds: Set<string>,
highlightedIncidentEdgeIds: Set<string> = new Set(),
overviewBackboneEdgeIds: Set<string> = new Set(),
sourceAttrs?: NodeAttributes,
targetAttrs?: NodeAttributes,
): GraphFullEdgeClass {
const primaryNodeId = hoveredNodeId || selectedNodeId;
if (pathEdgeIds.has(edgeId)) {
return "path";
}
if (selectedEdgeId && edgeId === selectedEdgeId) {
return "selected";
}
if (highlightedIncidentEdgeIds.has(edgeId)) {
return "local-context";
}
if (primaryNodeId && (source === primaryNodeId || target === primaryNodeId)) {
return "muted";
}
if (overviewBackboneEdgeIds.has(edgeId)) {
return isCuratedBridgeEdge(sourceAttrs, targetAttrs) ? "bridge" : "backbone";
}
if (zoomTier === "overview") {
return "hidden";
}
if (focusIds.has(source) && focusIds.has(target)) {
return "muted";
}
return "muted";
}
export function mapFullEdgeClassToVisualState(
edgeClass: GraphFullEdgeClass,
options: { hoveredNodeId: string | null; hasActiveInteraction: boolean },
): GraphEdgeVisualState {
switch (edgeClass) {
case "path":
return "path";
case "selected":
return "selected";
case "local-context":
return options.hoveredNodeId ? "hovered" : "selected";
case "backbone":
return "backbone";
case "bridge":
return "backbone";
case "hidden":
return "inactive";
case "muted":
default:
return options.hasActiveInteraction ? "muted" : "default";
}
}
export function resolveNodeVariant(state: GraphNodeVisualState, attrs: NodeAttributes): GraphNodeShapeVariant {
if (state === "selected") {
return "selected";
@@ -889,6 +1085,14 @@ export function resolveNodeVariant(state: GraphNodeVisualState, attrs: NodeAttri
return attrs.nodeShapeVariant || attrs.nodeVariant || "default";
}
export function resolveEntityShape(attrs: NodeAttributes): GraphEntityShapeVariant {
if (attrs.isCommunityGroup) {
return "community";
}
return attrs.entityShape || "entity";
}
export function resolveEdgeVariant(state: GraphEdgeVisualState, attrs: EdgeAttributes): GraphEdgeVariant {
if (state === "path") {
return "pathSignal";
@@ -1001,6 +1205,8 @@ export function resolveNodeElementStyle(
const stateConfig = theme.nodes.states[state];
const nodeVariant = resolveNodeVariant(state, attrs);
const variantConfig = theme.nodes.variants[nodeVariant];
const entityShape = resolveEntityShape(attrs);
const entityShapeConfig = theme.nodes.entityShapes[entityShape];
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
const baseSize = Number(attrs.baseSize || attrs.size || 4);
const labelPriority = Number(attrs.labelPriority ?? 0);
@@ -1028,12 +1234,17 @@ export function resolveNodeElementStyle(
: forceVisibleState
? theme.nodes.strokeHierarchy[zoomTier].emphasis
: theme.nodes.strokeHierarchy[zoomTier].base;
const rawCoreScale = resolveNodeCoreScale(zoomTier, state, cameraRatio) * entityShapeConfig.coreScale;
const shouldShowGlyphHint = zoomTier !== "overview"
|| state === "hovered"
|| state === "selected"
|| state === "path";
return {
color,
shellColor,
coreScale: resolveNodeCoreScale(zoomTier, state, cameraRatio),
size: Math.max(baseSize * sizeMultiplier * overviewPresence, stateConfig.minSize),
coreScale: shouldShowGlyphHint ? clamp(0.06, rawCoreScale, 0.5) : 0,
size: Math.max(baseSize * sizeMultiplier * overviewPresence, stateConfig.minSize, entityShapeConfig.minSize),
forceLabel,
label: forceLabel ? label : "",
zIndex: forceLabel && stateConfig.zIndex === 0 ? 1 : stateConfig.zIndex,
@@ -1045,10 +1256,14 @@ export function resolveNodeElementStyle(
+ strokeBase
+ stateConfig.borderBoost
+ variantConfig.borderBoost
+ entityShapeConfig.borderBoost
+ (isCommunityGroup ? theme.grouped.style.nodeBorderBoost : 0)
- 0.8,
),
nodeVariant,
entityShape,
entityShapeKind: entityShapeConfig.shapeKind,
entityAspectRatio: entityShapeConfig.aspectRatio,
badgeKind,
badgeCount: attrs.badgeCount,
showBadge,
@@ -1069,6 +1284,7 @@ function resolveStraightEdgeType(
state: GraphEdgeVisualState,
variant: GraphEdgeVariant,
attrs: EdgeAttributes,
viewMode: GraphViewMode,
): "line" | "arrow" {
const variantConfig = theme.edges.variants[variant];
@@ -1076,6 +1292,14 @@ function resolveStraightEdgeType(
return "arrow";
}
if ((viewMode === "full" || viewMode === "grouped") && state === "default") {
return "line";
}
if (viewMode === "full" && state === "backbone") {
return "line";
}
if (variantConfig.arrowPolicy === "contextual" && theme.zoomTiers[zoomTier].showContextualArrows) {
return "arrow";
}
@@ -1125,6 +1349,9 @@ export function resolveEdgeElementStyle(
attrs: EdgeAttributes,
sourceId?: string,
targetId?: string,
viewMode: GraphViewMode = "full",
edgeId?: string,
fullEdgeClass?: GraphFullEdgeClass,
): ResolvedEdgeStyle {
const tierConfig = theme.zoomTiers[zoomTier];
const stateConfig = theme.edges.states[state];
@@ -1133,11 +1360,29 @@ export function resolveEdgeElementStyle(
const isCommunityBundle = attrs.bundleKind === "community";
const baseSize = Number(attrs.baseSize || attrs.size || 0.9);
const visualPriority = Number(attrs.visualPriority ?? 0);
const isFullBridgeEdge = viewMode === "full" && fullEdgeClass === "bridge";
const isFullBackboneEdge = viewMode === "full" && fullEdgeClass === "backbone";
const shouldCurveBridge = isFullBridgeEdge
&& visualPriority >= theme.edges.fullGraphStructure.bridgeCurvePriorityThreshold;
const visibilityPolicy = resolveEdgeVisibilityPolicy(theme, viewMode, zoomTier, isCommunityBundle);
const isContextEdge = isContextEdgeState(state);
const isNonCriticalEdge = isNonCriticalEdgeVariant(edgeVariant);
const belowPriorityThreshold = state === "default"
&& visualPriority < tierConfig.edgePriorityThreshold
&& edgeVariant === "line";
&& visualPriority < Math.max(tierConfig.edgePriorityThreshold, visibilityPolicy.defaultPriorityThreshold)
&& isNonCriticalEdge;
const hiddenByMutedState = (state === "muted" || state === "inactive") && visibilityPolicy.hideMuted;
const sampledOut = isNonCriticalEdge
&& (
(state === "default" && !isContextEdge && shouldSampleOutBackgroundEdge(visibilityPolicy.backgroundSampleRate, visualPriority, edgeId, sourceId, targetId))
|| (
state === "backbone"
&& zoomTier === "overview"
&& viewMode !== "full"
&& shouldSampleOutBackgroundEdge(0.15, visualPriority, edgeId, sourceId, targetId)
)
);
if (stateConfig.hide || belowPriorityThreshold) {
if (stateConfig.hide || belowPriorityThreshold || hiddenByMutedState || sampledOut) {
return {
hidden: true,
zIndex: 0,
@@ -1148,31 +1393,55 @@ export function resolveEdgeElementStyle(
};
}
const sizeMultiplier = (state === "default" ? tierConfig.edgeSizeScale : stateConfig.sizeMultiplier) * variantConfig.sizeMultiplier;
const straightType = resolveStraightEdgeType(theme, zoomTier, state, edgeVariant, attrs);
const lodSizeMultiplier = isContextEdge ? 1 : visibilityPolicy.sizeMultiplier;
const sizeMultiplier = (state === "default" ? tierConfig.edgeSizeScale : stateConfig.sizeMultiplier)
* variantConfig.sizeMultiplier
* lodSizeMultiplier;
const straightType = resolveStraightEdgeType(theme, zoomTier, state, edgeVariant, attrs, viewMode);
const useCurvedRenderer = tierConfig.showCurves
&& zoomTier !== "overview"
&& (
edgeVariant === "pathSignal"
|| state === "selected"
|| ((state === "neighbor" || state === "hovered") && (edgeVariant === "bidirectionalCurve" || edgeVariant === "parallelCurve"))
|| shouldCurveBridge
|| edgeVariant === "bidirectionalCurve"
|| edgeVariant === "parallelCurve"
);
const curvature = useCurvedRenderer
? resolveEdgeCurvature(theme, state, edgeVariant, attrs, sourceId, targetId)
: 0;
const curvature = shouldCurveBridge
? (sourceId && targetId && sourceId.localeCompare(targetId) > 0
? -theme.edges.fullGraphStructure.bridgeCurveStrength
: theme.edges.fullGraphStructure.bridgeCurveStrength)
: useCurvedRenderer
? resolveEdgeCurvature(theme, state, edgeVariant, attrs, sourceId, targetId)
: 0;
const baseColor = resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color, fullEdgeClass);
const lodAlpha = resolveEdgeLodAlpha(theme, viewMode, zoomTier, state, attrs, isCommunityBundle, fullEdgeClass);
const color = lodAlpha === null ? baseColor : withAlpha(baseColor, lodAlpha);
const rawSize = Math.max(
baseSize * sizeMultiplier * (isCommunityBundle ? theme.grouped.style.edgeSizeScale : 1),
stateConfig.minSize,
);
const interactionMaxSize = (fullEdgeClass === "path" || state === "path")
? theme.interaction.pathEdgeMaxSize
: (fullEdgeClass === "selected" || state === "selected")
? theme.interaction.selectedEdgeMaxSize
: (fullEdgeClass === "local-context" || state === "hovered" || state === "neighbor")
? theme.interaction.localContextMaxSize
: Number.POSITIVE_INFINITY;
const size = isFullBridgeEdge
? Math.min(rawSize, theme.edges.fullGraphStructure.bridgeMaxSize)
: isFullBackboneEdge
? Math.min(rawSize, theme.edges.fullGraphStructure.backboneMaxSize)
: Math.min(rawSize, interactionMaxSize);
return {
hidden: false,
type: useCurvedRenderer
? (straightType === "arrow" ? "curvedArrow" : "curve")
: straightType,
color: isCommunityBundle
? withAlpha(resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color), theme.grouped.style.edgeAlpha)
: resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color),
size: Math.max(
baseSize * sizeMultiplier * (isCommunityBundle ? theme.grouped.style.edgeSizeScale : 1),
stateConfig.minSize,
),
color: color,
size,
zIndex: stateConfig.zIndex,
edgeVariant,
arrowVisibilityPolicy: variantConfig.arrowPolicy,
@@ -1675,6 +1944,7 @@ function buildCommunityGroupedGraph(): GraphDisplayResult {
mutedColor: withAlpha(summary.color, 0.22),
glowColor: withAlpha(summary.color, GRAPH_THEME.grouped.style.glowAlpha),
borderColor: withAlpha(summary.color, 0.74),
entityShape: "community",
nodeType: "community",
semanticGroup: summary.dominantSemanticGroup,
labelVisibilityPolicy: position.labelPriority > 0.9 ? "priority" : "none",
@@ -1901,30 +2171,42 @@ export function createFocusedGraph(
});
});
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,
});
});
collectFocusEdgeIds(graph, focusIds).forEach((edgeId) => {
if (!graph.hasEdge(edgeId)) {
return;
}
}
const [source, target] = graph.extremities(edgeId);
if (!focusIds.has(source) || !focusIds.has(target) || source === target) {
if (DEBUG_GRAPH_SCENE_STATE) {
console.debug("[graphSceneState]", "focused-edge-skipped-invalid-endpoints", {
edgeId,
source,
target,
selectedNodeId: nodeId,
});
}
return;
}
const attrs = graph.getEdgeAttributes(edgeId) as EdgeAttributes;
const state: GraphEdgeVisualState = pathEdgeIds.has(edgeId)
? "path"
: source === nodeId || target === nodeId
? "selected"
: "neighbor";
const style = resolveEdgeElementStyle(GRAPH_THEME, "inspection", state, attrs, source, target, "focused", edgeId);
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 aggregateDisplayGraph(focused);
}
@@ -0,0 +1,335 @@
import type Graph from "graphology";
import type Sigma from "sigma";
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
import { GRAPH_THEME, withAlpha } from "./graphTheme";
import type {
GraphFullEdgeClass,
GraphFullEdgeClassDiagnostics,
GraphInteractionState,
GraphStructureLayerDiagnostics,
GraphStructureLayerDisabledReason,
GraphViewMode,
} from "./types";
type GraphRef = Graph;
type StructureLayerMode = typeof GRAPH_THEME.edges.fullGraphStructureLayer.mode;
export type GraphStructureCurve = {
edgeId: string;
sourceId: string;
targetId: string;
source: { x: number; y: number };
target: { x: number; y: number };
edgeClass: Extract<GraphFullEdgeClass, "backbone" | "bridge">;
priority: number;
curvature: number;
};
export type GraphStructureCurveCache = {
cacheKey: string;
curves: GraphStructureCurve[];
bridgeCurveCount: number;
backboneCurveCount: number;
};
export type GraphStructureLayerGateInput = {
mode: StructureLayerMode;
viewMode: GraphViewMode;
isLayoutRunning: boolean;
edgeDiagnostics?: GraphFullEdgeClassDiagnostics;
minimumLiteralEdges: number;
};
export type GraphStructureLayerGate = {
enabled: boolean;
disabledReason: GraphStructureLayerDisabledReason | null;
};
export function evaluateGraphStructureLayerGate({
mode,
viewMode,
isLayoutRunning,
edgeDiagnostics,
minimumLiteralEdges,
}: GraphStructureLayerGateInput): GraphStructureLayerGate {
if (mode === "off") {
return { enabled: false, disabledReason: "disabled" };
}
if (viewMode !== "full") {
return { enabled: false, disabledReason: "non-full-mode" };
}
if (isLayoutRunning) {
return { enabled: false, disabledReason: "layout-running" };
}
if (mode === "auto") {
const literalEdges = (edgeDiagnostics?.counts.backbone ?? 0) + (edgeDiagnostics?.counts.bridge ?? 0);
if (literalEdges >= minimumLiteralEdges) {
return { enabled: false, disabledReason: "enough-literal-edges" };
}
}
return { enabled: true, disabledReason: null };
}
function isFinitePoint(attrs: NodeAttributes) {
return Number.isFinite(Number(attrs.x)) && Number.isFinite(Number(attrs.y));
}
function getEdgePriority(attrs: EdgeAttributes) {
return Math.max(0, Math.min(1, Number(attrs.visualPriority ?? attrs.weight ?? 0)));
}
function getCurveSortRank(edgeClass: GraphFullEdgeClass, priority: number) {
return (edgeClass === "bridge" ? 2 : 1) + priority;
}
function getDeterministicCurveSign(sourceId: string, targetId: string, edgeId: string) {
const seed = `${sourceId}|${targetId}|${edgeId}`;
let hash = 0;
for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) | 0;
}
return hash % 2 === 0 ? 1 : -1;
}
export function createGraphStructureCacheKey({
graphVersion,
zoomTier,
layoutSettledEpoch,
overviewBackboneEdgeIds,
}: {
graphVersion: number;
zoomTier: GraphInteractionState["zoomTier"];
layoutSettledEpoch: number;
overviewBackboneEdgeIds: Set<string>;
}) {
return [
graphVersion,
zoomTier,
layoutSettledEpoch,
Array.from(overviewBackboneEdgeIds).sort().join(","),
].join("|");
}
export function buildGraphStructureCurveCache({
graphRef,
cacheKey,
classifyEdge,
maxCurves,
curveStrength,
}: {
graphRef: GraphRef;
cacheKey: string;
classifyEdge: (edgeId: string) => GraphFullEdgeClass;
maxCurves: number;
curveStrength: number;
}): GraphStructureCurveCache {
const candidates: Array<GraphStructureCurve & { rank: number }> = [];
graphRef.forEachEdge((edgeId, attrs, source, target) => {
const stableEdgeId = String(edgeId);
const edgeClass = classifyEdge(stableEdgeId);
if (edgeClass !== "bridge" && edgeClass !== "backbone") {
return;
}
const sourceId = String(source);
const targetId = String(target);
if (!graphRef.hasNode(sourceId) || !graphRef.hasNode(targetId)) {
return;
}
const sourceAttrs = graphRef.getNodeAttributes(sourceId) as NodeAttributes;
const targetAttrs = graphRef.getNodeAttributes(targetId) as NodeAttributes;
if (!isFinitePoint(sourceAttrs) || !isFinitePoint(targetAttrs)) {
return;
}
const priority = getEdgePriority(attrs as EdgeAttributes);
candidates.push({
edgeId: stableEdgeId,
sourceId,
targetId,
source: { x: Number(sourceAttrs.x), y: Number(sourceAttrs.y) },
target: { x: Number(targetAttrs.x), y: Number(targetAttrs.y) },
edgeClass,
priority,
curvature: getDeterministicCurveSign(sourceId, targetId, stableEdgeId) * curveStrength,
rank: getCurveSortRank(edgeClass, priority),
});
});
candidates.sort((left, right) => {
if (right.rank !== left.rank) {
return right.rank - left.rank;
}
return left.edgeId.localeCompare(right.edgeId);
});
const curves = candidates.slice(0, maxCurves).map(({ rank: _rank, ...curve }) => curve);
return {
cacheKey,
curves,
bridgeCurveCount: curves.filter((curve) => curve.edgeClass === "bridge").length,
backboneCurveCount: curves.filter((curve) => curve.edgeClass === "backbone").length,
};
}
export function getGraphStructureLayerDiagnostics({
gate,
cache,
minimumCurves,
canvasAvailable,
lastDrawAt,
}: {
gate: GraphStructureLayerGate;
cache: GraphStructureCurveCache | null;
minimumCurves: number;
canvasAvailable: boolean;
lastDrawAt: number | null;
}): GraphStructureLayerDiagnostics {
if (!gate.enabled) {
return {
enabled: false,
disabledReason: gate.disabledReason,
curveCount: 0,
bridgeCurveCount: 0,
backboneCurveCount: 0,
cacheKey: cache?.cacheKey ?? "",
lastDrawAt,
};
}
if (!canvasAvailable) {
return {
enabled: false,
disabledReason: "invalid-layer",
curveCount: 0,
bridgeCurveCount: 0,
backboneCurveCount: 0,
cacheKey: cache?.cacheKey ?? "",
lastDrawAt,
};
}
if (!cache || cache.curves.length === 0) {
return {
enabled: false,
disabledReason: "no-eligible-edges",
curveCount: 0,
bridgeCurveCount: 0,
backboneCurveCount: 0,
cacheKey: cache?.cacheKey ?? "",
lastDrawAt,
};
}
if (cache.curves.length < minimumCurves) {
return {
enabled: false,
disabledReason: "cache-empty",
curveCount: cache.curves.length,
bridgeCurveCount: cache.bridgeCurveCount,
backboneCurveCount: cache.backboneCurveCount,
cacheKey: cache.cacheKey,
lastDrawAt,
};
}
return {
enabled: true,
disabledReason: null,
curveCount: cache.curves.length,
bridgeCurveCount: cache.bridgeCurveCount,
backboneCurveCount: cache.backboneCurveCount,
cacheKey: cache.cacheKey,
lastDrawAt,
};
}
export function clearGraphStructureLayer(canvas: HTMLCanvasElement | null) {
if (!canvas) {
return;
}
const context = canvas.getContext("2d");
if (!context) {
return;
}
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
}
export function drawGraphStructureLayer({
sigma,
canvas,
cache,
}: {
sigma: Sigma;
canvas: HTMLCanvasElement;
cache: GraphStructureCurveCache;
}) {
const context = canvas.getContext("2d");
if (!context) {
return false;
}
const { width, height } = sigma.getDimensions();
const pixelRatio = window.devicePixelRatio || 1;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, width, height);
context.lineCap = "round";
context.lineJoin = "round";
let drawn = 0;
for (const curve of cache.curves) {
const sourceData = sigma.getNodeDisplayData(curve.sourceId);
const targetData = sigma.getNodeDisplayData(curve.targetId);
if (!sourceData || !targetData || sourceData.hidden || targetData.hidden) {
continue;
}
const sourcePoint = sigma.graphToViewport(curve.source);
const targetPoint = sigma.graphToViewport(curve.target);
if (
!Number.isFinite(sourcePoint.x)
|| !Number.isFinite(sourcePoint.y)
|| !Number.isFinite(targetPoint.x)
|| !Number.isFinite(targetPoint.y)
) {
continue;
}
const dx = targetPoint.x - sourcePoint.x;
const dy = targetPoint.y - sourcePoint.y;
const distance = Math.hypot(dx, dy);
if (distance <= 0) {
continue;
}
const nx = -dy / distance;
const ny = dx / distance;
const offset = distance * curve.curvature;
const controlX = (sourcePoint.x + targetPoint.x) / 2 + nx * offset;
const controlY = (sourcePoint.y + targetPoint.y) / 2 + ny * offset;
const layerTheme = GRAPH_THEME.edges.fullGraphStructureLayer;
context.beginPath();
context.strokeStyle = curve.edgeClass === "bridge"
? withAlpha(GRAPH_THEME.palette.muted.edgeFocus, layerTheme.bridgeAlpha)
: withAlpha(GRAPH_THEME.palette.muted.edgeStructure, layerTheme.backboneAlpha);
context.lineWidth = curve.edgeClass === "bridge"
? layerTheme.bridgeLineWidth
: layerTheme.backboneLineWidth;
context.moveTo(sourcePoint.x, sourcePoint.y);
context.quadraticCurveTo(controlX, controlY, targetPoint.x, targetPoint.y);
context.stroke();
drawn += 1;
}
return drawn > 0;
}
@@ -2,6 +2,7 @@ export type GraphZoomTier = "overview" | "structure" | "inspection";
export type GraphNodeVisualState = "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 GraphEntityShapeVariant = "entity" | "biomolecule" | "condition" | "compound" | "process" | "community";
export type GraphEdgeVariant = "line" | "directional" | "bidirectionalCurve" | "parallelCurve" | "pathSignal";
export type GraphArrowVisibilityPolicy = "hidden" | "contextual" | "always";
export type GraphLabelVisibilityPolicy = "none" | "priority" | "local" | "always";
@@ -53,6 +54,60 @@ export interface GraphTheme {
nodeBorder: string;
};
};
ui: {
text: {
strong: string;
body: string;
muted: string;
subtle: string;
inverse: string;
};
surface: {
app: string;
stage: string;
card: string;
cardSubtle: string;
cardStrong: string;
panel: string;
panelBorder: string;
divider: string;
shadow: string;
};
scene: {
background: string;
radialGlow: string;
grid: string;
gridStrong: string;
vignette: string;
};
control: {
defaultBg: string;
defaultBorder: string;
defaultText: string;
hoverBg: string;
activeBg: string;
activeBorder: string;
activeText: string;
primaryBg: string;
primaryBorder: string;
primaryText: string;
disabledText: string;
inputBg: string;
inputBorder: string;
focusRing: string;
dangerText: string;
};
timeline: {
background: string;
border: string;
gridMinor: string;
gridMajor: string;
text: string;
textStrong: string;
playhead: string;
playheadSoft: string;
};
};
zoomTiers: Record<GraphZoomTier, {
maxRatio: number;
nodeScale: number;
@@ -134,6 +189,16 @@ export interface GraphTheme {
badgeKind?: GraphBadgeKind;
badgeVisibleFrom: GraphZoomTier;
}>;
entityShapes: Record<GraphEntityShapeVariant, {
label: string;
shapeKind: number;
aspectRatio: number;
fillAlpha: number;
shellAlpha: number;
coreScale: number;
borderBoost: number;
minSize: number;
}>;
selectedRing: {
color: string;
width: number;
@@ -171,6 +236,49 @@ export interface GraphTheme {
sizeMultiplier: number;
glowAlpha: number;
}>;
visibility: Record<"full" | "grouped" | "focused", Record<GraphZoomTier, {
defaultPriorityThreshold: number;
backgroundSampleRate: number;
defaultAlpha: number;
mutedAlpha: number;
inactiveAlpha: number;
neighborAlpha: number;
sizeMultiplier: number;
hideMuted: boolean;
}>>;
contextCaps: Record<"full" | "grouped" | "focused", Record<GraphZoomTier, number>>;
fullGraphStructure: {
ambientBackboneAlpha: number;
backboneAlpha: number;
bridgeAlpha: number;
bridgeCurvePriorityThreshold: number;
bridgeCurveStrength: number;
backboneMaxSize: number;
bridgeMaxSize: number;
structureEdgeAlpha: number;
inspectionEdgeAlpha: number;
};
fullGraphStructureLayer: {
mode: "off" | "auto" | "always";
minimumLiteralEdges: number;
minimumCurves: number;
maxCurves: number;
bridgeAlpha: number;
backboneAlpha: number;
bridgeLineWidth: number;
backboneLineWidth: number;
curveStrength: number;
};
};
interaction: {
localContextAlpha: number;
hoverContextAlpha: number;
selectedEdgeAlpha: number;
pathEdgeAlpha: number;
localContextMaxSize: number;
selectedEdgeMaxSize: number;
pathEdgeMaxSize: number;
pathOverlayAlpha: number;
};
overlays: {
hoverGlowAlpha: number;
@@ -302,9 +410,9 @@ export const GRAPH_THEME: GraphTheme = {
nodeCoreMix: 0.72,
nodeShellAlpha: 0.97,
nodeCoreAlpha: 1,
edgeBackbone: "rgba(100, 148, 210, 0.38)",
edgeStructure: "rgba(88, 140, 200, 0.28)",
edgeInspection: "rgba(110, 165, 230, 0.48)",
edgeBackbone: "rgba(84, 123, 145, 0.24)",
edgeStructure: "rgba(49, 63, 78, 0.08)",
edgeInspection: "rgba(76, 102, 128, 0.12)",
},
accent: {
selected: "#F2D288",
@@ -317,44 +425,98 @@ export const GRAPH_THEME: GraphTheme = {
muted: {
fallback: "rgba(96, 112, 136, 0.18)",
nodeAlpha: 0.12,
edgeOverview: "rgba(82, 100, 124, 0.12)",
edgeStructure: "rgba(92, 112, 138, 0.18)",
edgeInspection: "rgba(124, 148, 176, 0.26)",
edgeFocus: "rgba(160, 186, 218, 0.42)",
edgeOverview: "rgba(32, 45, 55, 0.035)",
edgeStructure: "rgba(42, 58, 72, 0.055)",
edgeInspection: "rgba(62, 84, 104, 0.075)",
edgeFocus: "rgba(132, 178, 202, 0.26)",
},
background: {
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",
canvas: "#0A0D11",
shell: "rgba(17, 21, 27, 0.82)",
shellBorder: "rgba(170, 184, 205, 0.14)",
shellGlow: "rgba(0, 0, 0, 0.28)",
grid: "rgba(170, 184, 205, 0.026)",
vignette: "rgba(3, 4, 7, 0.76)",
nodeBorder: "#0B0F15",
},
},
ui: {
text: {
strong: "#F3F0E8",
body: "#D5D9DD",
muted: "#9AA3AE",
subtle: "#6F7A86",
inverse: "#0B0D10",
},
surface: {
app: "#08090B",
stage: "#0B0E12",
card: "linear-gradient(180deg, rgba(28, 31, 36, 0.88), rgba(16, 18, 23, 0.78))",
cardSubtle: "linear-gradient(180deg, rgba(23, 26, 31, 0.72), rgba(13, 15, 19, 0.64))",
cardStrong: "linear-gradient(180deg, rgba(34, 37, 43, 0.94), rgba(18, 21, 26, 0.9))",
panel: "linear-gradient(180deg, rgba(21, 24, 30, 0.92), rgba(12, 14, 18, 0.9))",
panelBorder: "rgba(211, 205, 190, 0.13)",
divider: "rgba(211, 205, 190, 0.1)",
shadow: "0 22px 60px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.045)",
},
scene: {
background: "linear-gradient(180deg, #0B0E12 0%, #07080B 100%)",
radialGlow: "radial-gradient(circle at 50% 18%, rgba(88, 224, 204, 0.07), transparent 30%), radial-gradient(circle at 78% 0%, rgba(217, 168, 92, 0.055), transparent 26%)",
grid: "rgba(210, 206, 196, 0.024)",
gridStrong: "rgba(210, 206, 196, 0.052)",
vignette: "radial-gradient(ellipse at center, transparent 42%, rgba(2, 3, 5, 0.82) 100%)",
},
control: {
defaultBg: "rgba(255, 255, 255, 0.035)",
defaultBorder: "rgba(211, 205, 190, 0.11)",
defaultText: "#D7D1C4",
hoverBg: "rgba(255, 255, 255, 0.065)",
activeBg: "linear-gradient(180deg, rgba(74, 181, 166, 0.24), rgba(38, 118, 116, 0.18))",
activeBorder: "rgba(98, 226, 205, 0.42)",
activeText: "#E8FFFA",
primaryBg: "linear-gradient(180deg, rgba(55, 145, 132, 0.42), rgba(24, 86, 88, 0.28))",
primaryBorder: "rgba(99, 228, 206, 0.34)",
primaryText: "#F2FFFB",
disabledText: "rgba(154, 163, 174, 0.42)",
inputBg: "rgba(5, 7, 10, 0.52)",
inputBorder: "rgba(211, 205, 190, 0.13)",
focusRing: "rgba(98, 226, 205, 0.16)",
dangerText: "#FF9A8D",
},
timeline: {
background: "linear-gradient(180deg, rgba(14, 18, 24, 0.86), rgba(8, 11, 15, 0.92))",
border: "rgba(170, 184, 205, 0.12)",
gridMinor: "rgba(170, 184, 205, 0.04)",
gridMajor: "rgba(170, 184, 205, 0.09)",
text: "#7A92AE",
textStrong: "#A5B7CD",
playhead: "#8FE7FF",
playheadSoft: "rgba(143, 231, 255, 0.12)",
},
},
zoomTiers: {
overview: {
maxRatio: Number.POSITIVE_INFINITY,
nodeScale: 0.72,
labelThreshold: 0.995,
labelBudget: 4,
labelThreshold: 0.998,
labelBudget: 2,
edgePriorityThreshold: 0.72,
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
edgeSizeScale: 0.62,
showBadges: false,
showCurves: false,
showCurves: true,
showContextualArrows: false,
},
structure: {
maxRatio: 1.2,
nodeScale: 0.94,
labelThreshold: 0.93,
labelBudget: 18,
labelThreshold: 0.95,
labelBudget: 12,
edgePriorityThreshold: 0.4,
arrowPriorityThreshold: 0.75,
edgeSizeScale: 0.92,
showBadges: false,
showCurves: false,
showCurves: true,
showContextualArrows: false,
},
inspection: {
@@ -428,11 +590,11 @@ export const GRAPH_THEME: GraphTheme = {
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
},
states: {
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
hovered: { color: "hovered", sizeMultiplier: 1.1, minSize: 10.8, forceLabel: true, zIndex: 4, borderBoost: 0.18 },
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.4, forceLabel: true, zIndex: 3, borderBoost: 0.18 },
neighbor: { color: "base", sizeMultiplier: 0.78, minSize: 4.2, forceLabel: false, zIndex: 2, borderBoost: -0.12 },
path: { color: "path", sizeMultiplier: 0.97, minSize: 5.8, forceLabel: true, zIndex: 2, borderBoost: 0.06 },
default: { color: "base", sizeMultiplier: 0.7, minSize: 0.64, forceLabel: false, zIndex: 0, borderBoost: -0.46 },
hovered: { color: "hovered", sizeMultiplier: 1.08, minSize: 10.4, forceLabel: true, zIndex: 4, borderBoost: 0.2 },
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.2, forceLabel: true, zIndex: 3, borderBoost: 0.22 },
neighbor: { color: "base", sizeMultiplier: 0.76, minSize: 4, forceLabel: false, zIndex: 2, borderBoost: -0.08 },
path: { color: "path", sizeMultiplier: 0.96, minSize: 5.6, forceLabel: true, zIndex: 2, borderBoost: 0.08 },
inactive: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
muted: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
},
@@ -443,6 +605,68 @@ export const GRAPH_THEME: GraphTheme = {
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "inspection" },
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
},
entityShapes: {
entity: {
label: "Entity",
shapeKind: 0,
aspectRatio: 1,
fillAlpha: 0.9,
shellAlpha: 0.14,
coreScale: 0,
borderBoost: 0.08,
minSize: 0,
},
biomolecule: {
label: "Biomolecule",
shapeKind: 1,
aspectRatio: 1,
fillAlpha: 0.9,
shellAlpha: 0.16,
coreScale: 0.18,
borderBoost: 0.16,
minSize: 1.2,
},
condition: {
label: "Condition",
shapeKind: 2,
aspectRatio: 1.04,
fillAlpha: 0.88,
shellAlpha: 0.15,
coreScale: 0.16,
borderBoost: 0.18,
minSize: 1.6,
},
compound: {
label: "Compound",
shapeKind: 3,
aspectRatio: 1.48,
fillAlpha: 0.88,
shellAlpha: 0.15,
coreScale: 0.14,
borderBoost: 0.14,
minSize: 1.4,
},
process: {
label: "Process",
shapeKind: 4,
aspectRatio: 1.1,
fillAlpha: 0.87,
shellAlpha: 0.14,
coreScale: 0.14,
borderBoost: 0.16,
minSize: 1.4,
},
community: {
label: "Community",
shapeKind: 5,
aspectRatio: 1,
fillAlpha: 0.68,
shellAlpha: 0.28,
coreScale: 0.78,
borderBoost: 0.34,
minSize: 2,
},
},
selectedRing: {
color: "#E7C57C",
width: 1.9,
@@ -467,14 +691,14 @@ export const GRAPH_THEME: GraphTheme = {
},
edges: {
states: {
default: { color: "structure", sizeMultiplier: 0.96, minSize: 0.9, zIndex: 0, forceArrow: false, hide: false },
backbone: { color: "backbone", sizeMultiplier: 1.0, minSize: 1.0, zIndex: 1, forceArrow: false, hide: false },
hovered: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
selected: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
neighbor: { color: "focus", sizeMultiplier: 1.1, minSize: 1.2, zIndex: 1, forceArrow: false, hide: false },
path: { color: "path", sizeMultiplier: 1.7, minSize: 2.4, zIndex: 4, forceArrow: true, hide: false },
inactive: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
muted: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
default: { color: "structure", sizeMultiplier: 0.48, minSize: 0.2, zIndex: 0, forceArrow: false, hide: false },
backbone: { color: "backbone", sizeMultiplier: 0.62, minSize: 0.36, zIndex: 1, forceArrow: false, hide: false },
hovered: { color: "hover", sizeMultiplier: 1.42, minSize: 1.85, zIndex: 5, forceArrow: true, hide: false },
selected: { color: "hover", sizeMultiplier: 1.42, minSize: 1.85, zIndex: 5, forceArrow: true, hide: false },
neighbor: { color: "focus", sizeMultiplier: 0.96, minSize: 0.86, zIndex: 1, forceArrow: false, hide: false },
path: { color: "path", sizeMultiplier: 1.82, minSize: 2.55, zIndex: 6, forceArrow: true, hide: false },
inactive: { color: "muted", sizeMultiplier: 0.24, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
muted: { color: "muted", sizeMultiplier: 0.24, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
},
variants: {
line: { baseType: "line", arrowPolicy: "hidden", curveStrength: 0, sizeMultiplier: 1, glowAlpha: 0 },
@@ -483,8 +707,155 @@ export const GRAPH_THEME: GraphTheme = {
parallelCurve: { baseType: "line", arrowPolicy: "contextual", curveStrength: 0.24, sizeMultiplier: 1.1, glowAlpha: 0.12 },
pathSignal: { baseType: "arrow", arrowPolicy: "always", curveStrength: 0.16, sizeMultiplier: 1.18, glowAlpha: 0.2 },
},
},
overlays: {
visibility: {
full: {
overview: {
defaultPriorityThreshold: 0.96,
backgroundSampleRate: 0.035,
defaultAlpha: 0.026,
mutedAlpha: 0.012,
inactiveAlpha: 0.01,
neighborAlpha: 0.26,
sizeMultiplier: 0.5,
hideMuted: true,
},
structure: {
defaultPriorityThreshold: 0.82,
backgroundSampleRate: 0.16,
defaultAlpha: 0.04,
mutedAlpha: 0.014,
inactiveAlpha: 0.012,
neighborAlpha: 0.32,
sizeMultiplier: 0.62,
hideMuted: true,
},
inspection: {
defaultPriorityThreshold: 0.72,
backgroundSampleRate: 0.28,
defaultAlpha: 0.052,
mutedAlpha: 0.012,
inactiveAlpha: 0.01,
neighborAlpha: 0.38,
sizeMultiplier: 0.64,
hideMuted: true,
},
},
grouped: {
overview: {
defaultPriorityThreshold: 0.42,
backgroundSampleRate: 1,
defaultAlpha: 0.18,
mutedAlpha: 0.06,
inactiveAlpha: 0.04,
neighborAlpha: 0.36,
sizeMultiplier: 0.72,
hideMuted: false,
},
structure: {
defaultPriorityThreshold: 0.34,
backgroundSampleRate: 1,
defaultAlpha: 0.2,
mutedAlpha: 0.07,
inactiveAlpha: 0.05,
neighborAlpha: 0.42,
sizeMultiplier: 0.78,
hideMuted: false,
},
inspection: {
defaultPriorityThreshold: 0.28,
backgroundSampleRate: 1,
defaultAlpha: 0.22,
mutedAlpha: 0.08,
inactiveAlpha: 0.06,
neighborAlpha: 0.46,
sizeMultiplier: 0.82,
hideMuted: false,
},
},
focused: {
overview: {
defaultPriorityThreshold: 0.72,
backgroundSampleRate: 0.7,
defaultAlpha: 0.1,
mutedAlpha: 0.03,
inactiveAlpha: 0.02,
neighborAlpha: 0.06,
sizeMultiplier: 0.72,
hideMuted: true,
},
structure: {
defaultPriorityThreshold: 0.6,
backgroundSampleRate: 0.8,
defaultAlpha: 0.12,
mutedAlpha: 0.035,
inactiveAlpha: 0.025,
neighborAlpha: 0.08,
sizeMultiplier: 0.8,
hideMuted: true,
},
inspection: {
defaultPriorityThreshold: 0.52,
backgroundSampleRate: 0.9,
defaultAlpha: 0.14,
mutedAlpha: 0.04,
inactiveAlpha: 0.03,
neighborAlpha: 0.1,
sizeMultiplier: 0.88,
hideMuted: true,
},
},
},
contextCaps: {
full: {
overview: 0,
structure: 12,
inspection: 24,
},
grouped: {
overview: 6,
structure: 8,
inspection: 10,
},
focused: {
overview: 24,
structure: 36,
inspection: 48,
},
},
fullGraphStructure: {
ambientBackboneAlpha: 0.12,
backboneAlpha: 0.08,
bridgeAlpha: 0.14,
bridgeCurvePriorityThreshold: 0.78,
bridgeCurveStrength: 0.1,
backboneMaxSize: 0.5,
bridgeMaxSize: 0.7,
structureEdgeAlpha: 0.12,
inspectionEdgeAlpha: 0.1,
},
fullGraphStructureLayer: {
mode: "off",
minimumLiteralEdges: 24,
minimumCurves: 8,
maxCurves: 64,
bridgeAlpha: 0.16,
backboneAlpha: 0.1,
bridgeLineWidth: 0.9,
backboneLineWidth: 0.62,
curveStrength: 0.12,
},
},
interaction: {
localContextAlpha: 0.32,
hoverContextAlpha: 0.32,
selectedEdgeAlpha: 0.6,
pathEdgeAlpha: 0.76,
localContextMaxSize: 0.6,
selectedEdgeMaxSize: 1.0,
pathEdgeMaxSize: 1.4,
pathOverlayAlpha: 0.16,
},
overlays: {
hoverGlowAlpha: 0.18,
pathGlowAlpha: 0.16,
glowRadiusMultiplier: 4.8,
@@ -629,7 +1000,7 @@ export function withAlpha(color: string | undefined, alpha: number): string {
}
if (color.startsWith("rgba(")) {
return color.replace(/rgba\(([^)]+),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
return color.replace(/rgba\((.*?),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
}
if (color.startsWith("rgb(")) {
@@ -7,11 +7,11 @@ import type {
GraphCameraState,
GraphDisplayMeta,
GraphDisplayStateSnapshot,
GraphDiagnosticsSnapshot,
GraphEffectsState,
GraphInteractionState,
GraphLayoutSource,
GraphLayoutStatus,
GraphRuntimeDiagnosticsSnapshot,
GraphTemporalState,
GraphViewMode,
} from "./types";
@@ -35,7 +35,7 @@ export interface GraphSceneEventMap {
onEdgeSelect?: (edgeId: string) => void;
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
onCameraStateChange?: (cameraState: GraphCameraState) => void;
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
onDiagnosticsChange?: (diagnostics: GraphRuntimeDiagnosticsSnapshot) => void;
onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
onRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
}
@@ -5,7 +5,7 @@ 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";
import { GRAPH_THEME, type GraphEntityShapeVariant, withAlpha } from "./graphTheme";
type SemanticaNodeDrawData = {
x: number;
@@ -16,39 +16,106 @@ type SemanticaNodeDrawData = {
shellColor?: string;
coreScale?: number;
borderColor?: string;
borderSize?: number;
ringColor?: string;
ringSize?: number;
entityShape?: GraphEntityShapeVariant;
entityShapeKind?: number;
entityAspectRatio?: number;
nodeType?: string;
};
const MINERAL_DISC_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
const ENTITY_TOKEN_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
const MINERAL_DISC_FRAGMENT_SHADER = /* glsl */ `
const ENTITY_TOKEN_FRAGMENT_SHADER = /* glsl */ `
precision highp float;
varying vec4 v_coreColor;
varying vec4 v_shellColor;
varying vec4 v_ringColor;
varying vec4 v_bodyColor;
varying vec4 v_glyphColor;
varying vec4 v_outlineColor;
varying vec4 v_color;
varying vec2 v_diffVector;
varying float v_radius;
varying float v_ringSize;
varying float v_coreScale;
varying float v_outlineSize;
varying float v_glyphScale;
varying float v_shapeKind;
varying float v_aspectRatio;
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);
float hexMetric(vec2 point) {
vec2 q = abs(point);
return max(q.y, q.x * 0.8660254 + q.y * 0.5);
}
vec2 rotate45(vec2 point) {
const float invSqrt2 = 0.70710678;
return vec2(
(point.x - point.y) * invSqrt2,
(point.x + point.y) * invSqrt2
);
}
float roundedBoxDistance(vec2 point, vec2 halfSize, float radius) {
vec2 q = abs(point) - halfSize + vec2(radius);
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
}
float capsuleDistance(vec2 point) {
vec2 q = vec2(max(abs(point.x) - 0.44, 0.0), point.y);
return length(q) - 0.56;
}
float shapeDistance(vec2 point, float shapeKind) {
if (shapeKind < 0.5) {
return length(point) - 1.0;
}
if (shapeKind < 1.5) {
return hexMetric(point) - 0.92;
}
if (shapeKind < 2.5) {
return roundedBoxDistance(rotate45(point), vec2(0.58, 0.58), 0.18);
}
if (shapeKind < 3.5) {
return capsuleDistance(point);
}
if (shapeKind < 4.5) {
return roundedBoxDistance(point, vec2(0.78, 0.78), 0.24);
}
return length(point) - 1.0;
}
float glyphDistance(vec2 point, float shapeKind, float scale) {
vec2 scaled = point / max(scale, 0.08);
if (shapeKind < 0.5) {
return 1.0;
}
if (shapeKind < 1.5) {
return abs(hexMetric(scaled) - 0.74) - 0.055;
}
if (shapeKind < 2.5) {
return abs(abs(scaled.x) + abs(scaled.y) - 0.78) - 0.045;
}
if (shapeKind < 3.5) {
return roundedBoxDistance(scaled, vec2(0.56, 0.07), 0.07);
}
if (shapeKind < 4.5) {
return abs(roundedBoxDistance(scaled, vec2(0.48, 0.48), 0.18)) - 0.045;
}
return 1.0;
}
void main(void) {
vec2 unit = v_diffVector / max(v_radius, 0.0001);
float metric = discMetric(unit);
vec2 unit = vec2(
v_diffVector.x / max(v_radius * v_aspectRatio, 0.0001),
v_diffVector.y / max(v_radius, 0.0001)
);
float aa = (2.4 * u_correctionRatio) / max(v_radius, 1.0);
float alpha = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, metric);
float distance = shapeDistance(unit, v_shapeKind);
float alpha = 1.0 - smoothstep(-aa, aa, distance);
#ifdef PICKING_MODE
if (alpha <= 0.0) {
@@ -63,52 +130,63 @@ void main(void) {
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);
float outlineNorm = clamp(v_outlineSize / max(v_radius, 1.0), 0.035, 0.28);
float outlineBlend = 1.0 - smoothstep(-outlineNorm - aa, -outlineNorm + aa, distance);
float isOutline = 1.0 - outlineBlend;
float topLight = clamp((-unit.y + 0.85) * 0.5, 0.0, 1.0);
vec4 color = v_bodyColor;
color.rgb += vec3(0.014) * pow(topLight, 2.2);
if (ringNorm > 0.0 && metric >= ringStart) {
color = v_ringColor;
if (isOutline > 0.0) {
color = mix(color, v_outlineColor, isOutline);
}
float glyphVisible = step(7.25, v_radius) * step(0.13, v_glyphScale) * step(0.5, v_shapeKind) * (1.0 - step(4.5, v_shapeKind));
float glyph = (1.0 - smoothstep(-aa * 1.4, aa * 1.4, glyphDistance(unit, v_shapeKind, clamp(v_glyphScale, 0.16, 0.52)))) * glyphVisible;
if (glyph > 0.0 && distance < -outlineNorm) {
color = mix(color, v_glyphColor, glyph * 0.38);
}
color.a *= alpha;
gl_FragColor = color;
#endif
}
`;
const MINERAL_DISC_VERTEX_SHADER = /* glsl */ `
const ENTITY_TOKEN_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;
attribute vec4 a_bodyColor;
attribute vec4 a_glyphColor;
attribute vec4 a_outlineColor;
attribute float a_outlineSize;
attribute float a_glyphScale;
attribute float a_shapeKind;
attribute float a_aspectRatio;
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_bodyColor;
varying vec4 v_glyphColor;
varying vec4 v_outlineColor;
varying vec4 v_color;
varying vec2 v_diffVector;
varying float v_radius;
varying float v_ringSize;
varying float v_coreScale;
varying float v_outlineSize;
varying float v_glyphScale;
varying float v_shapeKind;
varying float v_aspectRatio;
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));
float aspect = max(a_aspectRatio, 1.0);
vec2 diffVector = size * vec2(cos(a_angle) * aspect, sin(a_angle));
vec2 position = a_position + diffVector;
gl_Position = vec4(
@@ -119,22 +197,24 @@ void main() {
v_diffVector = diffVector;
v_radius = size / 2.0;
v_ringSize = a_ringSize;
v_coreScale = a_coreScale;
v_outlineSize = a_outlineSize;
v_glyphScale = a_glyphScale;
v_shapeKind = a_shapeKind;
v_aspectRatio = aspect;
#ifdef PICKING_MODE
v_color = a_id;
#else
v_coreColor = a_coreColor;
v_shellColor = a_shellColor;
v_ringColor = a_ringColor;
v_bodyColor = a_bodyColor;
v_glyphColor = a_glyphColor;
v_outlineColor = a_outlineColor;
#endif
v_color.a *= bias;
}
`;
class MineralDiscNodeProgram extends NodeProgram<(typeof MINERAL_DISC_UNIFORMS)[number]> {
class EntityTokenNodeProgram extends NodeProgram<(typeof ENTITY_TOKEN_UNIFORMS)[number]> {
static readonly ANGLE_1 = 0;
static readonly ANGLE_2 = (2 * Math.PI) / 3;
static readonly ANGLE_3 = (4 * Math.PI) / 3;
@@ -146,47 +226,52 @@ class MineralDiscNodeProgram extends NodeProgram<(typeof MINERAL_DISC_UNIFORMS)[
getDefinition() {
return {
VERTICES: 3,
VERTEX_SHADER_SOURCE: MINERAL_DISC_VERTEX_SHADER,
FRAGMENT_SHADER_SOURCE: MINERAL_DISC_FRAGMENT_SHADER,
VERTEX_SHADER_SOURCE: ENTITY_TOKEN_VERTEX_SHADER,
FRAGMENT_SHADER_SOURCE: ENTITY_TOKEN_FRAGMENT_SHADER,
METHOD: WebGLRenderingContext.TRIANGLES,
UNIFORMS: MINERAL_DISC_UNIFORMS,
UNIFORMS: ENTITY_TOKEN_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_bodyColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
{ name: "a_glyphColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
{ name: "a_outlineColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
{ name: "a_outlineSize", size: 1, type: WebGLRenderingContext.FLOAT },
{ name: "a_glyphScale", size: 1, type: WebGLRenderingContext.FLOAT },
{ name: "a_shapeKind", size: 1, type: WebGLRenderingContext.FLOAT },
{ name: "a_aspectRatio", 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],
[EntityTokenNodeProgram.ANGLE_1],
[EntityTokenNodeProgram.ANGLE_2],
[EntityTokenNodeProgram.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);
const outlineColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, GRAPH_THEME.nodes.selectedRing.color);
const outlineSize = Math.max(data.ringSize || 0, data.borderSize || 0.7);
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++] = floatColor(data.shellColor || withAlpha(GRAPH_THEME.palette.overview.nodeBase, 0.58));
array[startIndex++] = floatColor(outlineColor);
array[startIndex++] = outlineSize;
array[startIndex++] = data.coreScale ?? 0.22;
array[startIndex++] = data.entityShapeKind ?? GRAPH_THEME.nodes.entityShapes[data.entityShape || "entity"].shapeKind;
array[startIndex++] = data.entityAspectRatio ?? GRAPH_THEME.nodes.entityShapes[data.entityShape || "entity"].aspectRatio;
array[startIndex++] = nodeIndex;
}
setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo<(typeof MINERAL_DISC_UNIFORMS)[number]>): void {
setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo<(typeof ENTITY_TOKEN_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);
@@ -326,7 +411,7 @@ export const drawSemanticaNodeHover: NodeHoverDrawingFunction = (context, rawDat
export const SEMANTICA_NODE_PROGRAM_CLASSES = {
...DEFAULT_NODE_PROGRAM_CLASSES,
circle: MineralDiscNodeProgram,
circle: EntityTokenNodeProgram,
};
export const SEMANTICA_EDGE_PROGRAM_CLASSES = {
@@ -12,6 +12,7 @@ export type GraphLoadPhase =
export type GraphLoadProgressKind = "determinate" | "indeterminate";
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphEdgeInteractionState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphFullEdgeClass = "hidden" | "backbone" | "bridge" | "local-context" | "selected" | "path" | "muted";
export type GraphSelectedNodeKind = "none" | "base" | "grouped" | "unavailable";
export interface GraphCameraState {
@@ -92,11 +93,49 @@ export interface GraphEffectAvailability {
segmentCap?: number;
}
export type GraphFullEdgeClassCounts = Record<GraphFullEdgeClass, number>;
export interface GraphFullEdgeClassDiagnostics {
mode: GraphViewMode;
zoomTier: GraphInteractionState["zoomTier"];
totalEdges: number;
visibleEdges: number;
counts: GraphFullEdgeClassCounts;
updatedAt: number;
}
export type GraphStructureLayerDisabledReason =
| "non-full-mode"
| "layout-running"
| "enough-literal-edges"
| "no-eligible-edges"
| "invalid-layer"
| "cache-empty"
| "disabled";
export interface GraphStructureLayerDiagnostics {
enabled: boolean;
disabledReason: GraphStructureLayerDisabledReason | null;
curveCount: number;
bridgeCurveCount: number;
backboneCurveCount: number;
cacheKey: string;
lastDrawAt: number | null;
}
export interface GraphRuntimeDiagnosticsSnapshot {
effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"];
edgeClasses?: GraphFullEdgeClassDiagnostics;
structureLayer?: GraphStructureLayerDiagnostics;
}
export interface GraphDiagnosticsSnapshot {
interactionState: GraphInteractionState;
activePluginIds: string[];
openPanelIds: string[];
effectsState: GraphEffectsState;
edgeClasses?: GraphFullEdgeClassDiagnostics;
structureLayer?: GraphStructureLayerDiagnostics;
effectAvailability: {
pathPulse: GraphEffectAvailability;
pathFlow: GraphEffectAvailability;
@@ -10,6 +10,7 @@ import {
withAlpha,
type GraphBadgeKind,
type GraphEdgeVariant,
type GraphEntityShapeVariant,
type GraphLabelVisibilityPolicy,
type GraphNodeShapeVariant,
} from "./graphTheme";
@@ -29,6 +30,12 @@ const SEMANTIC_COLOR_FIELDS = [
] as const;
const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
const ENTITY_SHAPE_ALIASES: Array<[GraphEntityShapeVariant, RegExp]> = [
["biomolecule", /\b(gene|protein|enzyme|receptor|target|transcript|rna|dna|mirna|biomolecule|peptide)\b/i],
["condition", /\b(disease|condition|phenotype|symptom|disorder|syndrome|diagnosis|pathology|trait)\b/i],
["compound", /\b(drug|chemical|compound|metabolite|molecule|small[_\s-]?molecule|ligand|therapeutic|medication|substance)\b/i],
["process", /\b(pathway|process|mechanism|function|ontology|biological[_\s-]?process|cellular[_\s-]?process|program|module)\b/i],
];
function getSemanticFieldValue(attributes: NodeAttributes, field: (typeof SEMANTIC_COLOR_FIELDS)[number]): string | null {
if (field === "nodeType") {
@@ -196,6 +203,27 @@ function getProvenanceCount(properties: Record<string, unknown>): number {
);
}
function resolveEntityShape(attributes: NodeAttributes, semanticGroup: string): GraphEntityShapeVariant {
const values = [
attributes.nodeType,
semanticGroup,
attributes.content,
String(attributes.properties?.type ?? ""),
String(attributes.properties?.category ?? ""),
String(attributes.properties?.label ?? ""),
]
.filter((value) => typeof value === "string" && value.trim().length > 0)
.join(" ");
for (const [shape, pattern] of ENTITY_SHAPE_ALIASES) {
if (pattern.test(values)) {
return shape;
}
}
return "entity";
}
function resolveNodeVariantMetadata(
baseColor: string,
sizeRatio: number,
@@ -548,6 +576,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
const hasTemporalBounds = Boolean(attributes.valid_from || attributes.valid_until);
const provenanceCount = getProvenanceCount(attributes.properties ?? {});
const properties = attributes.properties as Record<string, unknown>;
const entityShape = resolveEntityShape(attributes, semanticGroup);
const providedX = readFiniteCoordinate(properties?.x);
const providedY = readFiniteCoordinate(properties?.y);
const seededPosition = seededPositions?.get(id);
@@ -575,6 +604,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
strokeColor: darkenHex(baseColor, 112),
borderColor: darkenHex(baseColor, 112),
borderSize: 0.72,
entityShape,
...resolveNodeVariantMetadata(baseColor, sizeRatio, hasTemporalBounds, provenanceCount),
} as NodeAttributes,
};
@@ -598,6 +628,12 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
const parallelIndex = parallelOffsets.get(pairKey) ?? 0;
parallelOffsets.set(pairKey, parallelIndex + 1);
const parallelCount = parallelCounts.get(pairKey) ?? 1;
const normalizedWeight = clamp(0, Math.log1p(Math.max(Number(edge.weight) || 1, 1)) / 6, 1);
const edgeVisualPriority = clamp(
0,
Math.sqrt(Math.max(sourcePriority, 0) * Math.max(targetPriority, 0)) * 0.72 + normalizedWeight * 0.28,
1,
);
return {
id: edge.id,
@@ -617,7 +653,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
color: GRAPH_THEME.palette.muted.edgeStructure,
baseColor: GRAPH_THEME.palette.muted.edgeStructure,
mutedColor: GRAPH_THEME.palette.muted.edgeOverview,
visualPriority: Math.max(sourcePriority, targetPriority),
visualPriority: edgeVisualPriority,
isBidirectional,
edgeFamily: isBidirectional ? "bidirectional" : "line",
curveGroup: curveGroupForPair(edge.source, edge.target),
@@ -5,13 +5,28 @@ import {
batchMergeEdges,
batchMergeNodes,
clearGraph,
graph,
} from "../src/store/graphStore.ts";
import {
buildGraphAnalyticsSnapshot,
computeGraphAnalyticsBase,
} from "../src/workspaces/GraphWorkspace/graphAnalytics.ts";
import {
classifyFullGraphEdge,
checkGroupedViewAvailability,
mapFullEdgeClassToVisualState,
resolveEdgeElementStyle,
resolveEdgeVisualState,
resolveDisplayGraph,
resolveGroupedDisplayNodeId,
resolveGroupedDisplayStateSnapshot,
} from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
import {
buildGraphStructureCurveCache,
evaluateGraphStructureLayerGate,
} from "../src/workspaces/GraphWorkspace/graphStructureLayer.ts";
import { GRAPH_THEME } from "../src/workspaces/GraphWorkspace/graphTheme.ts";
import type { GraphFullEdgeClass, GraphFullEdgeClassCounts } from "../src/workspaces/GraphWorkspace/types.ts";
function addNode(id: string, semanticGroup = "entity") {
batchMergeNodes([
@@ -33,6 +48,10 @@ function addNode(id: string, semanticGroup = "entity") {
]);
}
function setNodePosition(id: string, x: number, y: number) {
graph.mergeNodeAttributes(id, { x, y });
}
function addEdge(id: string, source: string, target: string, weight = 1) {
batchMergeEdges([
{
@@ -56,6 +75,510 @@ test.after(() => {
clearGraph();
});
test("resolveEdgeVisualState caps selected-node incident edge promotion", () => {
const uncappedState = resolveEdgeVisualState(
"edge-1",
"hub",
"leaf",
"inspection",
null,
"hub",
"",
new Set(["hub", "leaf"]),
new Set(),
new Set(),
);
assert.equal(uncappedState, "muted");
const cappedState = resolveEdgeVisualState(
"edge-1",
"hub",
"leaf",
"inspection",
null,
"hub",
"",
new Set(["hub", "leaf"]),
new Set(),
new Set(["edge-1"]),
);
assert.equal(cappedState, "selected");
});
test("resolveEdgeElementStyle applies full-graph LOD to directional background edges", () => {
const style = resolveEdgeElementStyle(
GRAPH_THEME,
"overview",
"default",
{
edgeType: "related_to",
weight: 1,
properties: {},
edgeVariant: "directional",
visualPriority: 0.1,
baseSize: 0.5,
},
"source",
"target",
"full",
"directional-low-priority",
);
assert.equal(style.hidden, true);
});
test("classifyFullGraphEdge applies deterministic priority order", () => {
const edgeClass = classifyFullGraphEdge(
"edge-priority",
"source",
"target",
"inspection",
"source",
"source",
"edge-priority",
new Set(["source", "target"]),
new Set(["edge-priority"]),
new Set(["edge-priority"]),
new Set(["edge-priority"]),
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "disease", content: "target", semanticGroup: "disease", properties: {} },
);
assert.equal(edgeClass, "path");
const selectedClass = classifyFullGraphEdge(
"edge-priority",
"source",
"target",
"inspection",
"source",
"source",
"edge-priority",
new Set(["source", "target"]),
new Set(),
new Set(["edge-priority"]),
);
assert.equal(selectedClass, "selected");
});
test("classifyFullGraphEdge separates capped local context from muted hub edges", () => {
const mutedClass = classifyFullGraphEdge(
"hub-edge",
"hub",
"leaf",
"inspection",
null,
"hub",
"",
new Set(["hub", "leaf"]),
new Set(),
new Set(),
);
assert.equal(mutedClass, "muted");
const localContextClass = classifyFullGraphEdge(
"hub-edge",
"hub",
"leaf",
"inspection",
null,
"hub",
"",
new Set(["hub", "leaf"]),
new Set(),
new Set(["hub-edge"]),
);
assert.equal(localContextClass, "local-context");
});
test("classifyFullGraphEdge marks curated bridge and backbone candidates", () => {
const bridgeClass = classifyFullGraphEdge(
"curated-bridge",
"source",
"target",
"overview",
null,
"",
"",
new Set(),
new Set(),
new Set(),
new Set(["curated-bridge"]),
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "disease", content: "target", semanticGroup: "disease", properties: {} },
);
assert.equal(bridgeClass, "bridge");
const backboneClass = classifyFullGraphEdge(
"curated-backbone",
"source",
"target",
"overview",
null,
"",
"",
new Set(),
new Set(),
new Set(),
new Set(["curated-backbone"]),
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "target", semanticGroup: "gene", properties: {} },
);
assert.equal(backboneClass, "backbone");
});
test("classifyFullGraphEdge hides ordinary full-graph overview edges", () => {
const edgeClass = classifyFullGraphEdge(
"ordinary-edge",
"source",
"target",
"overview",
null,
"",
"",
new Set(),
new Set(),
new Set(),
);
assert.equal(edgeClass, "hidden");
});
test("mapFullEdgeClassToVisualState renders curated backbone and bridge as backbone", () => {
assert.equal(
mapFullEdgeClassToVisualState("backbone", { hoveredNodeId: null, hasActiveInteraction: false }),
"backbone",
);
assert.equal(
mapFullEdgeClassToVisualState("bridge", { hoveredNodeId: null, hasActiveInteraction: false }),
"backbone",
);
assert.equal(
mapFullEdgeClassToVisualState("hidden", { hoveredNodeId: null, hasActiveInteraction: false }),
"inactive",
);
});
test("resolveEdgeElementStyle renders curated full-graph backbone quietly", () => {
const style = resolveEdgeElementStyle(
GRAPH_THEME,
"overview",
"backbone",
{
edgeType: "related_to",
weight: 2,
properties: {},
visualPriority: 0.9,
baseSize: 0.8,
},
"source",
"target",
"full",
"curated-backbone-edge",
"backbone",
);
assert.equal(style.hidden, false);
assert.equal(style.type, "line");
assert.match(style.color ?? "", /rgba\(.+,\s*0\.08\)/);
assert.ok(Number(style.size ?? 0) <= GRAPH_THEME.edges.fullGraphStructure.backboneMaxSize);
});
test("resolveEdgeElementStyle renders high-value bridge as a calm curved teal edge", () => {
const style = resolveEdgeElementStyle(
GRAPH_THEME,
"overview",
"backbone",
{
edgeType: "related_to",
weight: 4,
properties: {},
visualPriority: 0.95,
baseSize: 0.9,
edgeVariant: "line",
},
"source",
"target",
"full",
"curated-bridge-edge",
"bridge",
);
assert.equal(style.hidden, false);
assert.equal(style.type, "curve");
assert.notEqual(style.type, "arrow");
assert.match(style.color ?? "", /rgba\(.+,\s*0\.14\)/);
assert.equal(style.curvature, GRAPH_THEME.edges.fullGraphStructure.bridgeCurveStrength);
assert.ok(Number(style.size ?? 0) <= GRAPH_THEME.edges.fullGraphStructure.bridgeMaxSize);
});
test("resolveEdgeElementStyle keeps low-priority bridge straight", () => {
const style = resolveEdgeElementStyle(
GRAPH_THEME,
"overview",
"backbone",
{
edgeType: "related_to",
weight: 1,
properties: {},
visualPriority: 0.2,
baseSize: 0.9,
edgeVariant: "line",
},
"source",
"target",
"full",
"low-value-bridge-edge",
"bridge",
);
assert.equal(style.hidden, false);
assert.equal(style.type, "line");
assert.equal(style.curvature, 0);
});
test("evaluateGraphStructureLayerGate enables only sparse settled full-graph structure", () => {
const counts: GraphFullEdgeClassCounts = {
hidden: 20,
backbone: 6,
bridge: 4,
"local-context": 0,
selected: 0,
path: 0,
muted: 0,
};
assert.deepEqual(
evaluateGraphStructureLayerGate({
mode: "auto",
viewMode: "grouped",
isLayoutRunning: false,
edgeDiagnostics: {
mode: "grouped",
zoomTier: "overview",
totalEdges: 30,
visibleEdges: 10,
counts,
updatedAt: 1,
},
minimumLiteralEdges: 24,
}),
{ enabled: false, disabledReason: "non-full-mode" },
);
assert.deepEqual(
evaluateGraphStructureLayerGate({
mode: "auto",
viewMode: "full",
isLayoutRunning: true,
edgeDiagnostics: {
mode: "full",
zoomTier: "overview",
totalEdges: 30,
visibleEdges: 10,
counts,
updatedAt: 1,
},
minimumLiteralEdges: 24,
}),
{ enabled: false, disabledReason: "layout-running" },
);
assert.deepEqual(
evaluateGraphStructureLayerGate({
mode: "auto",
viewMode: "full",
isLayoutRunning: false,
edgeDiagnostics: {
mode: "full",
zoomTier: "overview",
totalEdges: 30,
visibleEdges: 24,
counts: { ...counts, backbone: 18, bridge: 6 },
updatedAt: 1,
},
minimumLiteralEdges: 24,
}),
{ enabled: false, disabledReason: "enough-literal-edges" },
);
assert.deepEqual(
evaluateGraphStructureLayerGate({
mode: "auto",
viewMode: "full",
isLayoutRunning: false,
edgeDiagnostics: {
mode: "full",
zoomTier: "overview",
totalEdges: 30,
visibleEdges: 10,
counts,
updatedAt: 1,
},
minimumLiteralEdges: 24,
}),
{ enabled: true, disabledReason: null },
);
});
test("buildGraphStructureCurveCache prefers bridges, caps curves, and skips invalid endpoints", () => {
addNode("a", "gene");
addNode("b", "disease");
addNode("c", "gene");
addNode("d", "compound");
setNodePosition("a", 0, 0);
setNodePosition("b", 100, 0);
setNodePosition("c", 0, 100);
setNodePosition("d", Number.NaN, 100);
addEdge("backbone-1", "a", "c", 1);
graph.mergeEdgeAttributes("backbone-1", { visualPriority: 1 });
addEdge("bridge-1", "a", "b", 0.2);
graph.mergeEdgeAttributes("bridge-1", { visualPriority: 0.1 });
addEdge("selected-1", "b", "c", 1);
graph.mergeEdgeAttributes("selected-1", { visualPriority: 1 });
addEdge("invalid-bridge", "a", "d", 1);
graph.mergeEdgeAttributes("invalid-bridge", { visualPriority: 1 });
const edgeClasses = new Map<string, GraphFullEdgeClass>([
["backbone-1", "backbone"],
["bridge-1", "bridge"],
["selected-1", "selected"],
["invalid-bridge", "bridge"],
]);
const capped = buildGraphStructureCurveCache({
graphRef: graph,
cacheKey: "test-cache",
classifyEdge: (edgeId) => edgeClasses.get(edgeId) ?? "hidden",
maxCurves: 1,
curveStrength: 0.12,
});
assert.equal(capped.curves.length, 1);
assert.equal(capped.curves[0].edgeId, "bridge-1");
assert.equal(capped.bridgeCurveCount, 1);
assert.equal(capped.backboneCurveCount, 0);
const uncapped = buildGraphStructureCurveCache({
graphRef: graph,
cacheKey: "test-cache-all",
classifyEdge: (edgeId) => edgeClasses.get(edgeId) ?? "hidden",
maxCurves: 10,
curveStrength: 0.12,
});
assert.deepEqual(
uncapped.curves.map((curve) => curve.edgeId).sort(),
["backbone-1", "bridge-1"],
);
});
test("resolveEdgeVisualState suppresses automatic overview backbone in clean baseline", () => {
const state = resolveEdgeVisualState(
"overview-backbone-high-priority",
"source",
"target",
"overview",
null,
"",
"",
new Set(),
new Set(),
);
assert.equal(state, "inactive");
});
test("resolveEdgeElementStyle keeps full-graph selected and path edges controlled", () => {
const selectedStyle = resolveEdgeElementStyle(
GRAPH_THEME,
"inspection",
"selected",
{
edgeType: "related_to",
weight: 2,
properties: {},
visualPriority: 0.9,
baseSize: 0.8,
},
"source",
"target",
"full",
"selected-context-edge",
);
const pathStyle = resolveEdgeElementStyle(
GRAPH_THEME,
"inspection",
"path",
{
edgeType: "causes",
weight: 2,
properties: {},
visualPriority: 0.9,
baseSize: 0.8,
},
"source",
"target",
"full",
"path-context-edge",
);
assert.equal(selectedStyle.hidden, false);
assert.match(selectedStyle.color ?? "", /rgba\(.+,\s*0\.6\)/);
assert.equal(pathStyle.hidden, false);
assert.match(pathStyle.color ?? "", /rgba\(.+,\s*0\.76\)/);
});
test("buildGraphAnalyticsSnapshot emits a readable capped overview backbone", () => {
const semanticGroups = ["gene/protein", "disease", "drug", "pathway"];
for (let index = 0; index < 16; index += 1) {
addNode(`n${index}`, semanticGroups[index % semanticGroups.length]);
}
let edgeIndex = 0;
for (let sourceIndex = 0; sourceIndex < 16; sourceIndex += 1) {
for (let offset = 1; offset <= 3; offset += 1) {
const targetIndex = (sourceIndex + offset * 3) % 16;
if (sourceIndex === targetIndex) {
continue;
}
addEdge(`ambient-edge-${edgeIndex}`, `n${sourceIndex}`, `n${targetIndex}`, 1 + (edgeIndex % 5));
edgeIndex += 1;
}
}
const base = computeGraphAnalyticsBase(graph, {
computeCommunities: false,
computeCentrality: true,
});
const analytics = buildGraphAnalyticsSnapshot({
graphRef: graph,
interactionState: {
hoveredNodeId: null,
selectedNodeId: "",
selectedEdgeId: "",
focusedNodeId: "",
activePath: [],
activePathEdgeIds: [],
viewMode: "full",
zoomTier: "overview",
isLayoutRunning: false,
},
base,
visibleNodeIds: graph.nodes(),
});
assert.equal(analytics.overviewBackbone.ready, true);
assert.ok(analytics.overviewBackbone.edgeIds.length > 6);
assert.ok(analytics.overviewBackbone.edgeIds.length <= 128);
});
test("resolveDisplayGraph bundles parallel edges in full view", () => {
addNode("a");
addNode("b");
@@ -257,3 +780,4 @@ test("checkGroupedViewAvailability returns available when communities exist", ()
assert.equal(result.available, true);
assert.equal(result.reason, null);
});