Files
semantica/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx
T

1927 lines
63 KiB
TypeScript

import { useEffect, useMemo, useRef, useCallback, forwardRef, useImperativeHandle, useState, type ReactNode } from "react";
import type Graph from "graphology";
import Sigma from "sigma";
import FA2Layout from "graphology-layout-forceatlas2/worker";
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
import type { GraphBehavior, GraphBehaviorContext, GraphBehaviorActionRequest } from "./behaviors/types";
import { hoverActivationBehavior } from "./behaviors/hoverActivationBehavior";
import { clickSelectionBehavior } from "./behaviors/clickSelectionBehavior";
import { focusCameraBehavior } from "./behaviors/focusCameraBehavior";
import { createSearchFocusBehavior } from "./behaviors/searchFocusBehavior";
import { createPathHighlightBehavior } from "./behaviors/pathHighlightBehavior";
import { fitViewBehavior } from "./behaviors/fitViewBehavior";
import { createViewModeSwitchBehavior } from "./behaviors/viewModeSwitchBehavior";
import {
GRAPH_THEME,
getZoomTier,
type GraphBadgeKind,
type GraphZoomTier,
withAlpha,
zoomTierAtLeast,
} from "./graphTheme";
import {
buildFocusSet,
buildEdgeEndpointSet,
buildPathEdgeSet,
collectInteractionRefreshTargets,
createInteractionState,
isEdgeInteractable,
resolveEdgeElementStyle,
resolveEdgeVisualState,
resolveNodeElementStyle,
resolveNodeVisualState,
} from "./graphSceneState";
import { buildGraphAnalyticsSnapshot, computeGraphAnalyticsBase } from "./graphAnalytics";
import {
collectVisibleNodeSamples,
drawContourLayer,
drawLensLayer,
drawPathEffectsLayer,
drawSemanticRegionsLayer,
drawTemporalEmphasisLayer,
type PathSegmentOverlay,
type ViewportPoint,
} from "./graphSceneLayers";
import {
SEMANTICA_EDGE_PROGRAM_CLASSES,
SEMANTICA_NODE_PROGRAM_CLASSES,
drawSemanticaNodeHover,
drawSemanticaNodeLabel,
} from "./sigmaNativeRendering";
import type {
GraphAnalyticsSnapshot,
GraphCameraState,
GraphDisplayMeta,
GraphDisplayStateSnapshot,
GraphDiagnosticsSnapshot,
GraphEffectsState,
GraphInteractionState,
GraphLayoutStatus,
GraphTemporalState,
GraphViewMode,
} from "./types";
import type { GraphSceneGraph, GraphSceneRuntime } from "./scene";
export type { GraphViewMode } from "./types";
export interface GraphCanvasHandle {
fitView: () => void;
focusNode: (nodeId: string) => void;
zoomIn: () => void;
zoomOut: () => void;
requestRender: () => void;
getCameraState: () => GraphCameraState | null;
}
export interface GraphCanvasProps {
graphVersion: number;
graphReady: boolean;
displayGraph: GraphSceneGraph;
displayMeta: GraphDisplayMeta;
displayState?: GraphDisplayStateSnapshot;
onNodeClick: (nodeId: string) => void;
onEdgeClick?: (edgeId: string) => void;
selectedNodeId: string;
selectedEdgeId: string;
activePath?: string[];
activePathEdgeIds?: string[];
effectsState: GraphEffectsState;
temporalState?: GraphTemporalState | null;
isLayoutRunning: boolean;
onLayoutRunningChange?: (running: boolean) => void;
layoutSource?: string;
onLayoutStatusChange?: (status: GraphLayoutStatus) => void;
viewMode: GraphViewMode;
className?: string;
showFitViewButton?: boolean;
pluginOverlays?: ReactNode[];
onSceneRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
onCameraStateChange?: (cameraState: GraphCameraState) => void;
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
}
const FA2_SETTINGS = {
iterations: 50,
settings: {
barnesHutOptimize: true,
barnesHutTheta: 0.5,
adjustSizes: false,
gravity: 0.16,
scalingRatio: 7.5,
edgeWeightInfluence: 0.3,
linLogMode: true,
strongGravityMode: false,
slowDown: 18,
},
};
const SIGMA_SETTINGS = {
allowInvalidContainer: true,
labelRenderedSizeThreshold: 6,
defaultNodeType: "circle",
defaultEdgeType: "line",
hideLabelsOnMove: true,
hideEdgesOnMove: true,
enableEdgeEvents: true,
renderEdgeLabels: false,
labelDensity: 0.7,
labelGridCellSize: 140,
zIndex: true,
minCameraRatio: 0.04,
maxCameraRatio: 8,
webGLTarget: "webgl2" as const,
nodeProgramClasses: SEMANTICA_NODE_PROGRAM_CLASSES,
edgeProgramClasses: SEMANTICA_EDGE_PROGRAM_CLASSES,
defaultDrawNodeLabel: drawSemanticaNodeLabel,
defaultDrawNodeHover: drawSemanticaNodeHover,
};
const DEBUG_GRAPH_RUNTIME = import.meta.env.DEV;
function debugGraphRuntime(message: string, payload?: Record<string, unknown>) {
if (!DEBUG_GRAPH_RUNTIME) {
return;
}
console.debug(`[GraphCanvas] ${message}`, payload ?? {});
}
function syncDisplayNodePositionsFromStore(
targetGraph: GraphSceneGraph,
storeGraph: typeof graph,
): number {
let changed = 0;
targetGraph.forEachNode((nodeId, attrs) => {
if (!storeGraph.hasNode(nodeId)) {
return;
}
const sourceAttrs = storeGraph.getNodeAttributes(nodeId) as NodeAttributes;
const nextX = Number(sourceAttrs.x);
const nextY = Number(sourceAttrs.y);
const currentAttrs = attrs as NodeAttributes;
if (!Number.isFinite(nextX) || !Number.isFinite(nextY)) {
return;
}
const currentX = Number(currentAttrs.x);
const currentY = Number(currentAttrs.y);
if (Math.abs(currentX - nextX) <= 0.001 && Math.abs(currentY - nextY) <= 0.001) {
return;
}
targetGraph.mergeNodeAttributes(nodeId, {
x: nextX,
y: nextY,
});
changed += 1;
});
return changed;
}
function resolveGroupedSelectionNodeId(
displayGraph: GraphSceneGraph,
nodeId: string,
): string | null {
if (!nodeId) {
return null;
}
if (displayGraph.hasNode(nodeId)) {
return nodeId;
}
let resolvedNodeId: string | null = null;
displayGraph.forEachNode((candidateId, attrs) => {
if (resolvedNodeId) {
return;
}
const communityGroup = (attrs as NodeAttributes).properties?.__communityGroup as
| {
anchorNodeId?: string | null;
memberNodeIds?: string[];
sampleNodeIds?: string[];
}
| undefined;
if (!communityGroup) {
return;
}
if (communityGroup.anchorNodeId === nodeId) {
resolvedNodeId = candidateId;
return;
}
if (communityGroup.sampleNodeIds?.includes(nodeId) || communityGroup.memberNodeIds?.includes(nodeId)) {
resolvedNodeId = candidateId;
}
});
return resolvedNodeId;
}
function collectSelectionContextNodeIds(
displayGraph: GraphSceneGraph,
displayState: GraphDisplayStateSnapshot | undefined,
nodeId: string,
): string[] {
if (!nodeId) {
return [];
}
const resolvedNodeId = resolveGroupedSelectionNodeId(displayGraph, nodeId);
if (!resolvedNodeId) {
return [];
}
const neighborIds = (displayState?.selectedVisibleNeighborIds ?? [])
.filter((neighborId) => displayGraph.hasNode(neighborId))
.slice(0, GRAPH_THEME.focus.maxNeighbors);
return Array.from(new Set([resolvedNodeId, ...neighborIds]));
}
function computeGraphSpaceBounds(
displayGraph: GraphSceneGraph,
nodeIds: string[],
): { minX: number; maxX: number; minY: number; maxY: number; count: number } | null {
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
let count = 0;
nodeIds.forEach((nodeId) => {
if (!displayGraph.hasNode(nodeId)) {
return;
}
const attrs = displayGraph.getNodeAttributes(nodeId) as NodeAttributes;
const x = Number(attrs.x);
const y = Number(attrs.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return;
}
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
count += 1;
});
if (count === 0) {
return null;
}
return { minX, maxX, minY, maxY, count };
}
type GraphSpaceBounds = NonNullable<ReturnType<typeof computeGraphSpaceBounds>>;
type SigmaCustomBBox = {
x: [number, number];
y: [number, number];
};
type DisplayFitSignature = {
graphVersion: number;
viewMode: GraphViewMode;
layoutMode: GraphDisplayMeta["layoutMode"];
displayGraph: GraphSceneGraph;
};
function isSameDisplayFitSignature(
left: DisplayFitSignature | null,
right: DisplayFitSignature,
): boolean {
if (!left) {
return false;
}
return left.graphVersion === right.graphVersion
&& left.viewMode === right.viewMode
&& left.layoutMode === right.layoutMode
&& left.displayGraph === right.displayGraph;
}
function computeDisplayedNodeBounds(
sigma: Sigma,
nodeIds: string[],
): GraphSpaceBounds | null {
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
let count = 0;
nodeIds.forEach((nodeId) => {
const displayData = sigma.getNodeDisplayData(nodeId);
if (!displayData) {
return;
}
const x = Number(displayData.x);
const y = Number(displayData.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return;
}
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
count += 1;
});
if (count === 0) {
return null;
}
return { minX, maxX, minY, maxY, count };
}
function computeDisplayedGraphBounds(
sigma: Sigma,
displayGraph: GraphSceneGraph,
): GraphSpaceBounds | null {
const nodeIds: string[] = [];
displayGraph.forEachNode((nodeId) => {
nodeIds.push(nodeId);
});
return computeDisplayedNodeBounds(sigma, nodeIds);
}
function computeCameraTargetFromBounds(
sigma: Sigma,
bounds: SigmaCustomBBox,
): { x: number; y: number; ratio: number; angle: number } | null {
const camera = sigma.getCamera();
const cameraState = camera.getState();
const viewRect = sigma.viewRectangle();
const currentWidth = Math.abs(viewRect.x2 - viewRect.x1);
const currentHeight = Math.abs(viewRect.height);
const targetWidth = Math.abs(bounds.x[1] - bounds.x[0]);
const targetHeight = Math.abs(bounds.y[1] - bounds.y[0]);
const centerX = (bounds.x[0] + bounds.x[1]) / 2;
const centerY = (bounds.y[0] + bounds.y[1]) / 2;
if (
![currentWidth, currentHeight, targetWidth, targetHeight, centerX, centerY].every(Number.isFinite)
|| currentWidth <= 0
|| currentHeight <= 0
|| targetWidth <= 0
|| targetHeight <= 0
) {
return null;
}
const fitScale = Math.max(targetWidth / currentWidth, targetHeight / currentHeight);
const nextRatio = camera.getBoundedRatio(cameraState.ratio * fitScale);
if (!Number.isFinite(nextRatio) || nextRatio <= 0) {
return null;
}
return {
x: centerX,
y: centerY,
ratio: nextRatio,
angle: cameraState.angle,
};
}
function expandGraphSpaceBounds(
bounds: GraphSpaceBounds,
referenceBounds: GraphSpaceBounds | null,
options?: {
paddingRatio?: number;
minSpanRatio?: number;
minSpanFloor?: number;
},
): SigmaCustomBBox | null {
const paddingRatio = options?.paddingRatio ?? 0.12;
const minSpanRatio = options?.minSpanRatio ?? 0.03;
const minSpanFloor = options?.minSpanFloor ?? 1;
const referenceSpanX = Math.max(
referenceBounds ? referenceBounds.maxX - referenceBounds.minX : 0,
minSpanFloor,
);
const referenceSpanY = Math.max(
referenceBounds ? referenceBounds.maxY - referenceBounds.minY : 0,
minSpanFloor,
);
const spanX = Math.max(bounds.maxX - bounds.minX, 0);
const spanY = Math.max(bounds.maxY - bounds.minY, 0);
const minSpanX = Math.max(referenceSpanX * minSpanRatio, minSpanFloor);
const minSpanY = Math.max(referenceSpanY * minSpanRatio, minSpanFloor);
const targetSpanX = Math.max(spanX, minSpanX);
const targetSpanY = Math.max(spanY, minSpanY);
const centerX = (bounds.minX + bounds.maxX) / 2;
const centerY = (bounds.minY + bounds.maxY) / 2;
if (![targetSpanX, targetSpanY, centerX, centerY].every(Number.isFinite)) {
return null;
}
const paddedSpanX = targetSpanX * (1 + paddingRatio * 2);
const paddedSpanY = targetSpanY * (1 + paddingRatio * 2);
if (paddedSpanX <= 0 || paddedSpanY <= 0) {
return null;
}
return {
x: [centerX - paddedSpanX / 2, centerX + paddedSpanX / 2],
y: [centerY - paddedSpanY / 2, centerY + paddedSpanY / 2],
};
}
function isPointNearViewport(point: ViewportPoint, width: number, height: number, padding = 96) {
return point.x >= -padding
&& point.y >= -padding
&& point.x <= width + padding
&& point.y <= height + padding;
}
function collectPathSegments(
sigma: Sigma,
path: string[],
pathEdgeIds: string[],
zoomTier: GraphZoomTier,
viewportWidth: number,
viewportHeight: number,
): PathSegmentOverlay[] {
const segments: PathSegmentOverlay[] = [];
for (let index = 0; index < path.length - 1; index += 1) {
const sourceId = path[index];
const targetId = path[index + 1];
const sourceData = sigma.getNodeDisplayData(sourceId);
const targetData = sigma.getNodeDisplayData(targetId);
if (!sourceData || !targetData) {
continue;
}
const source = sigma.graphToViewport({ x: sourceData.x, y: sourceData.y });
const target = sigma.graphToViewport({ x: targetData.x, y: targetData.y });
if (!isPointNearViewport(source, viewportWidth, viewportHeight) && !isPointNearViewport(target, viewportWidth, viewportHeight)) {
continue;
}
const attrs = pathEdgeIds[index] && graph.hasEdge(pathEdgeIds[index])
? (graph.getEdgeAttributes(pathEdgeIds[index]) as EdgeAttributes)
: ({
baseColor: GRAPH_THEME.palette.accent.path,
baseSize: 1.2,
edgeVariant: "pathSignal",
arrowVisibilityPolicy: "always",
} as EdgeAttributes);
const pathStyle = resolveEdgeElementStyle(GRAPH_THEME, zoomTier, "path", attrs, sourceId, targetId);
if (!pathStyle.color || !pathStyle.size) {
continue;
}
segments.push({
sourceId,
targetId,
source,
target,
color: pathStyle.color,
size: pathStyle.size,
});
}
return segments;
}
function buildEffectAvailability(
interactionState: GraphInteractionState,
effectsState: GraphEffectsState,
temporalState: GraphTemporalState | null | undefined,
analytics: GraphAnalyticsSnapshot | null,
isLayoutRunning: boolean,
sigma: Sigma | null,
viewportWidth: number,
viewportHeight: number,
): GraphDiagnosticsSnapshot["effectAvailability"] {
const visiblePathSegments = sigma
? collectPathSegments(
sigma,
interactionState.activePath,
interactionState.activePathEdgeIds,
interactionState.zoomTier,
viewportWidth,
viewportHeight,
).length
: 0;
const hasActivePath = interactionState.activePath.length > 1;
const pulseTierReady = zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.pathPulse.minZoomTier);
const flowTierReady = zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.pathFlow.minZoomTier);
const lensTierReady = zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.lens.minZoomTier);
const temporalTierReady = zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.temporalEmphasis.minZoomTier);
const regionsTierReady = zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.semanticRegions.minZoomTier);
const contoursTierReady = zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.contours.minZoomTier);
const hasPrimaryNode = Boolean(interactionState.hoveredNodeId || interactionState.selectedNodeId);
const layoutReason = "Layout is still settling";
const pathPulse = !effectsState.pathPulseEnabled
? { enabled: false, available: false, reason: "Disabled by toggle" }
: isLayoutRunning
? { enabled: true, available: false, reason: layoutReason }
: !hasActivePath
? { enabled: true, available: false, reason: "No active path" }
: !pulseTierReady
? {
enabled: true,
available: false,
reason: "Disabled by zoom tier",
detail: `Requires ${GRAPH_THEME.effects.pathPulse.minZoomTier}`,
}
: visiblePathSegments === 0
? { enabled: true, available: false, reason: "Path is off-screen" }
: visiblePathSegments > GRAPH_THEME.effects.pathPulse.maxSegments
? {
enabled: true,
available: false,
reason: "Disabled by path size cap",
detail: `${visiblePathSegments} visible segments`,
visibleSegments: visiblePathSegments,
segmentCap: GRAPH_THEME.effects.pathPulse.maxSegments,
}
: {
enabled: true,
available: true,
reason: "Ready",
visibleSegments: visiblePathSegments,
segmentCap: GRAPH_THEME.effects.pathPulse.maxSegments,
};
const pathFlow = !effectsState.pathFlowEnabled
? { enabled: false, available: false, reason: "Disabled by toggle" }
: isLayoutRunning
? { enabled: true, available: false, reason: layoutReason }
: !hasActivePath
? { enabled: true, available: false, reason: "No active path" }
: !flowTierReady
? {
enabled: true,
available: false,
reason: "Disabled by zoom tier",
detail: `Requires ${GRAPH_THEME.effects.pathFlow.minZoomTier}`,
}
: visiblePathSegments === 0
? { enabled: true, available: false, reason: "Path is off-screen" }
: visiblePathSegments > GRAPH_THEME.effects.pathFlow.maxSegments
? {
enabled: true,
available: false,
reason: "Disabled by path size cap",
detail: `${visiblePathSegments} visible segments`,
visibleSegments: visiblePathSegments,
segmentCap: GRAPH_THEME.effects.pathFlow.maxSegments,
}
: {
enabled: true,
available: true,
reason: "Ready",
visibleSegments: visiblePathSegments,
segmentCap: GRAPH_THEME.effects.pathFlow.maxSegments,
};
const lens = !effectsState.lensEnabled
? { enabled: false, available: false, reason: "Disabled by toggle" }
: isLayoutRunning
? { enabled: true, available: false, reason: layoutReason }
: !hasPrimaryNode
? { enabled: true, available: false, reason: "No focal node" }
: !lensTierReady
? {
enabled: true,
available: false,
reason: "Disabled by zoom tier",
detail: `Requires ${GRAPH_THEME.effects.lens.minZoomTier}`,
}
: { enabled: true, available: true, reason: "Ready" };
const temporalEmphasis = !effectsState.temporalEmphasisEnabled
? { enabled: false, available: false, reason: "Disabled by toggle" }
: isLayoutRunning
? { enabled: true, available: false, reason: layoutReason }
: !temporalState?.currentTime
? { enabled: true, available: false, reason: "No temporal focus time" }
: !temporalTierReady
? {
enabled: true,
available: false,
reason: "Disabled by zoom tier",
detail: `Requires ${GRAPH_THEME.effects.temporalEmphasis.minZoomTier}`,
}
: { enabled: true, available: true, reason: "Ready" };
const semanticRegions = !effectsState.semanticRegionsEnabled
? { enabled: false, available: false, reason: "Disabled by toggle" }
: isLayoutRunning
? { enabled: true, available: false, reason: layoutReason }
: !regionsTierReady
? {
enabled: true,
available: false,
reason: "Disabled by zoom tier",
detail: `Requires ${GRAPH_THEME.effects.semanticRegions.minZoomTier}`,
}
: !analytics?.semanticRegions.ready
? {
enabled: true,
available: false,
reason: analytics?.semanticRegions.reason ?? "Waiting for semantic region summaries",
}
: { enabled: true, available: true, reason: analytics.semanticRegions.reason };
const contours = !effectsState.contoursEnabled
? { enabled: false, available: false, reason: "Disabled by toggle" }
: isLayoutRunning
? { enabled: true, available: false, reason: layoutReason }
: !contoursTierReady
? {
enabled: true,
available: false,
reason: "Disabled by zoom tier",
detail: `Requires ${GRAPH_THEME.effects.contours.minZoomTier}`,
}
: !analytics?.centrality.ready
? {
enabled: true,
available: false,
reason: analytics?.centrality.reason ?? "Waiting for centrality ranking",
}
: { enabled: true, available: true, reason: analytics.centrality.reason };
const pathfinding = !effectsState.pathfindingEnabled
? { enabled: false, available: false, reason: "Disabled by toggle" }
: !analytics?.directedPath.ready
? {
enabled: true,
available: false,
reason: analytics?.directedPath.reason ?? "Waiting for a traced path",
}
: {
enabled: true,
available: true,
reason: analytics.directedPath.verifiedAgainstActivePath
? "Ready · local path matches traced path"
: "Ready · local directed path differs from traced path",
};
const communities = !effectsState.communitiesEnabled
? { enabled: false, available: false, reason: "Disabled by toggle" }
: !analytics?.communities.ready
? {
enabled: true,
available: false,
reason: analytics?.communities.reason ?? "Waiting for community summaries",
}
: {
enabled: true,
available: true,
reason: analytics.communities.modularity !== null
? `Ready · modularity ${analytics.communities.modularity.toFixed(3)}`
: analytics.communities.reason,
};
const centrality = !effectsState.centralityEnabled
? { enabled: false, available: false, reason: "Disabled by toggle" }
: !analytics?.centrality.ready
? {
enabled: true,
available: false,
reason: analytics?.centrality.reason ?? "Waiting for centrality ranking",
}
: { enabled: true, available: true, reason: analytics.centrality.reason };
const legend = effectsState.legendEnabled
? { enabled: true, available: true, reason: "Panel enabled" }
: { enabled: false, available: false, reason: "Disabled by toggle" };
const diagnostics = !GRAPH_THEME.effects.diagnostics.enabledInDev
? { enabled: false, available: false, reason: "Disabled in production" }
: effectsState.diagnosticsEnabled
? { enabled: true, available: true, reason: "Ready" }
: { enabled: false, available: false, reason: "Disabled by toggle" };
return {
pathPulse,
pathFlow,
lens,
temporalEmphasis,
semanticRegions,
contours,
pathfinding,
communities,
centrality,
legend,
diagnostics,
};
}
function drawGlowHalo(
context: CanvasRenderingContext2D,
x: number,
y: number,
radius: number,
color: string,
) {
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, color);
gradient.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = gradient;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
}
function drawNodeBadge(
context: CanvasRenderingContext2D,
x: number,
y: number,
nodeRadius: number,
badgeKind: GraphBadgeKind,
badgeCount: number | undefined,
) {
const badgeTheme = GRAPH_THEME.nodes.badges[badgeKind];
const radius = GRAPH_THEME.nodes.badge.radius;
const offset = GRAPH_THEME.nodes.badge.offset;
const badgeX = x + Math.max(nodeRadius * 0.62, radius + offset);
const badgeY = y - Math.max(nodeRadius * 0.62, radius + offset);
drawGlowHalo(
context,
badgeX,
badgeY,
GRAPH_THEME.overlays.badgeGlowRadius,
withAlpha(badgeTheme.color, GRAPH_THEME.nodes.badge.glowAlpha),
);
context.fillStyle = badgeTheme.color;
context.beginPath();
context.arc(badgeX, badgeY, radius, 0, Math.PI * 2);
context.fill();
context.strokeStyle = GRAPH_THEME.nodes.badge.stroke;
context.lineWidth = 1;
context.beginPath();
context.arc(badgeX, badgeY, radius, 0, Math.PI * 2);
context.stroke();
context.fillStyle = GRAPH_THEME.nodes.badge.textColor;
context.font = `700 ${GRAPH_THEME.nodes.badge.fontSize}px Inter, system-ui, sans-serif`;
context.textAlign = "center";
context.textBaseline = "middle";
const label = badgeKind === "provenance" && badgeCount && badgeCount > 1
? String(Math.min(9, badgeCount))
: badgeTheme.label;
context.fillText(label, badgeX, badgeY + 0.5);
}
function applySceneState(
sigma: Sigma,
displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
interactionState: GraphInteractionState,
analyticsSnapshot: GraphAnalyticsSnapshot | null,
refreshTargets?: {
nodes?: string[];
edges?: string[];
},
) {
const { zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
const primaryNodeId = hoveredNodeId || selectedNodeId;
const focusIds = primaryNodeId && graph.hasNode(primaryNodeId) ? buildFocusSet(primaryNodeId) : new Set<string>();
const edgeEndpointIds = buildEdgeEndpointSet(displayGraph, selectedEdgeId);
const pathNodeIds = new Set(activePath);
const pathEdgeIds = buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds);
const overviewBackboneEdgeIds = new Set(analyticsSnapshot?.overviewBackbone.edgeIds ?? []);
const cameraRatio = sigma.getCamera().getState().ratio;
sigma.setSetting("nodeReducer", (node, data) => {
const attrs = data as NodeAttributes;
const state = resolveNodeVisualState(
node,
zoomTier,
hoveredNodeId,
selectedNodeId,
selectedEdgeId,
focusIds,
edgeEndpointIds,
pathNodeIds,
);
const style = resolveNodeElementStyle(GRAPH_THEME, zoomTier, state, attrs, data.label, cameraRatio);
return {
...data,
color: style.color,
shellColor: style.shellColor,
coreScale: style.coreScale,
size: style.size,
forceLabel: style.forceLabel,
label: style.label,
zIndex: style.zIndex,
hidden: style.hidden,
borderColor: style.borderColor,
borderSize: style.borderSize,
ringColor: style.showRing ? style.ringColor : style.borderColor,
ringSize: style.ringSize,
};
});
sigma.setSetting("edgeReducer", (edge, data) => {
const attrs = data as EdgeAttributes;
const [source, target] = displayGraph.extremities(edge);
const stableEdgeId = String(edge);
const state = resolveEdgeVisualState(
stableEdgeId,
source,
target,
zoomTier,
hoveredNodeId,
selectedNodeId,
selectedEdgeId,
focusIds,
pathEdgeIds,
overviewBackboneEdgeIds,
);
const style = resolveEdgeElementStyle(GRAPH_THEME, zoomTier, state, attrs, source, target);
return {
...data,
hidden: style.hidden,
type: style.type,
color: style.color,
size: style.size,
zIndex: style.zIndex,
curvature: style.curvature,
};
});
if ((refreshTargets?.nodes?.length ?? 0) > 0 || (refreshTargets?.edges?.length ?? 0) > 0) {
sigma.scheduleRefresh({
partialGraph: {
nodes: refreshTargets?.nodes,
edges: refreshTargets?.edges,
},
});
return;
}
sigma.scheduleRefresh();
}
function dispatchBehaviorAction(
behaviors: GraphBehavior[],
context: GraphBehaviorContext,
action: GraphBehaviorActionRequest,
) {
for (const behavior of behaviors) {
if (behavior.performAction?.(context, action)) {
return;
}
}
}
export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
function GraphCanvas(
{
graphVersion,
graphReady,
displayGraph,
displayMeta,
displayState,
onNodeClick,
onEdgeClick,
selectedNodeId,
selectedEdgeId,
activePath = [],
activePathEdgeIds = [],
effectsState,
temporalState,
isLayoutRunning,
viewMode,
className,
showFitViewButton = true,
pluginOverlays = [],
onSceneRuntimeChange,
onInteractionStateChange,
onCameraStateChange,
onDiagnosticsChange,
onAnalyticsChange,
},
ref,
) {
const containerRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLCanvasElement>(null);
const sigmaRef = useRef<Sigma | null>(null);
const fa2Ref = useRef<FA2Layout | null>(null);
const behaviorContextRef = useRef<GraphBehaviorContext | null>(null);
const runtimeRef = useRef<GraphSceneRuntime | null>(null);
const sigmaResizeObserverRef = useRef<ResizeObserver | null>(null);
const sigmaCameraSyncRef = useRef<(() => void) | null>(null);
const displayGraphRef = useRef<GraphSceneGraph>(displayGraph);
const displayStateRef = useRef(displayState);
const displayMetaRef = useRef(displayMeta);
const graphVersionRef = useRef(graphVersion);
const selectedNodeIdRef = useRef(selectedNodeId);
const viewModeRef = useRef(viewMode);
const onNodeClickRef = useRef(onNodeClick);
const onEdgeClickRef = useRef(onEdgeClick);
const onSceneRuntimeChangeRef = useRef(onSceneRuntimeChange);
const onCameraStateChangeRef = useRef(onCameraStateChange);
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [zoomTier, setZoomTier] = useState<GraphZoomTier>("overview");
const [analyticsSnapshot, setAnalyticsSnapshot] = useState<GraphAnalyticsSnapshot | null>(null);
const appliedGraphVersionRef = useRef<number | null>(null);
const fittedDisplaySignatureRef = useRef<DisplayFitSignature | null>(null);
const layoutSyncFrameRef = useRef<number | null>(null);
const layoutSyncTickRef = useRef(0);
const deferredFocusFrameRef = useRef<number | null>(null);
displayGraphRef.current = displayGraph;
displayStateRef.current = displayState;
displayMetaRef.current = displayMeta;
graphVersionRef.current = graphVersion;
selectedNodeIdRef.current = selectedNodeId;
viewModeRef.current = viewMode;
onNodeClickRef.current = onNodeClick;
onEdgeClickRef.current = onEdgeClick;
onSceneRuntimeChangeRef.current = onSceneRuntimeChange;
onCameraStateChangeRef.current = onCameraStateChange;
const behaviors = useMemo<GraphBehavior[]>(
() => [
hoverActivationBehavior,
clickSelectionBehavior,
focusCameraBehavior,
createSearchFocusBehavior(),
createPathHighlightBehavior(),
fitViewBehavior,
createViewModeSwitchBehavior(),
],
[],
);
const interactionState = useMemo(
() => createInteractionState(
hoveredNodeId,
selectedNodeId,
selectedEdgeId,
activePath,
activePathEdgeIds,
viewMode,
zoomTier,
isLayoutRunning,
),
[activePath, activePathEdgeIds, hoveredNodeId, isLayoutRunning, selectedEdgeId, selectedNodeId, viewMode, zoomTier],
);
const interactionStateRef = useRef<GraphInteractionState>(interactionState);
interactionStateRef.current = interactionState;
const previousInteractionStateRef = useRef<GraphInteractionState | null>(null);
const shouldComputeCommunities = effectsState.communitiesEnabled || effectsState.semanticRegionsEnabled;
const shouldComputeCentrality = effectsState.centralityEnabled || effectsState.semanticRegionsEnabled || effectsState.contoursEnabled;
const analyticsBase = useMemo(
() => computeGraphAnalyticsBase(displayGraph, {
computeCommunities: shouldComputeCommunities,
computeCentrality: shouldComputeCentrality,
}),
[displayGraph, shouldComputeCentrality, shouldComputeCommunities],
);
const displayFitSignature = useMemo<DisplayFitSignature>(() => ({
graphVersion,
viewMode,
layoutMode: displayMeta.layoutMode,
displayGraph,
}), [displayGraph, displayMeta.layoutMode, graphVersion, viewMode]);
const animateCameraToBounds = useCallback((
intent: "fit-display-graph" | "fit-selection-context" | "fit-focused-graph",
targetBounds: SigmaCustomBBox | null,
diagnostics?: Record<string, unknown>,
) => {
const sigma = sigmaRef.current;
if (!sigma) {
return;
}
if (sigma.getCustomBBox()) {
sigma.setCustomBBox(null);
}
const container = containerRef.current;
const dimensions = sigma.getDimensions();
const currentCameraState = sigma.getCamera().getState();
const target = targetBounds
? computeCameraTargetFromBounds(sigma, targetBounds)
: {
x: 0.5,
y: 0.5,
ratio: 1,
angle: currentCameraState.angle,
};
if (!target) {
debugGraphRuntime("camera-fit-target-invalid", {
intent,
graphVersion: graphVersionRef.current,
viewMode: viewModeRef.current,
order: displayGraphRef.current.order,
size: displayGraphRef.current.size,
boundsX: targetBounds?.x ?? null,
boundsY: targetBounds?.y ?? null,
...diagnostics,
});
return;
}
debugGraphRuntime("camera-fit-target", {
intent,
graphVersion: graphVersionRef.current,
viewMode: viewModeRef.current,
order: displayGraphRef.current.order,
size: displayGraphRef.current.size,
selectionPreserved: Boolean(selectedNodeIdRef.current),
containerWidth: container?.clientWidth ?? dimensions.width,
containerHeight: container?.clientHeight ?? dimensions.height,
targetX: target.x,
targetY: target.y,
targetRatio: target.ratio,
boundsX: targetBounds?.x ?? null,
boundsY: targetBounds?.y ?? null,
...diagnostics,
});
void sigma.getCamera().animate(
target,
{ duration: GRAPH_THEME.motion.cameraMs, easing: "quadraticOut" },
);
}, []);
const fitDisplayGraphInView = useCallback(() => {
const sigma = sigmaRef.current;
if (!sigma) {
return;
}
const bounds = computeDisplayedGraphBounds(sigma, displayGraphRef.current);
if (!bounds) {
debugGraphRuntime("camera-fit-display-graph-fallback", {
graphVersion: graphVersionRef.current,
viewMode: viewModeRef.current,
reason: "display-bounds-unavailable",
order: displayGraphRef.current.order,
size: displayGraphRef.current.size,
});
animateCameraToBounds("fit-display-graph", null, {
fallback: "reset-empty-bounds",
});
return;
}
const displayBBox = expandGraphSpaceBounds(bounds, bounds, {
paddingRatio: 0.14,
minSpanRatio: 0.02,
minSpanFloor: 0.04,
});
animateCameraToBounds("fit-display-graph", displayBBox, {
boundsCount: bounds.count,
minX: bounds.minX,
maxX: bounds.maxX,
minY: bounds.minY,
maxY: bounds.maxY,
});
}, [animateCameraToBounds]);
const centerSelectionInView = useCallback(function centerSelectionInViewInternal(nodeId: string, attempt = 0) {
const sigma = sigmaRef.current;
if (!sigma) {
return;
}
const currentDisplayGraph = displayGraphRef.current;
const selectionNodeIds = collectSelectionContextNodeIds(
currentDisplayGraph,
displayStateRef.current,
nodeId,
);
if (selectionNodeIds.length === 0) {
debugGraphRuntime("camera-selection-missing-node", {
nodeId,
graphVersion: graphVersionRef.current,
viewMode: viewModeRef.current,
});
fitDisplayGraphInView();
return;
}
const bounds = computeGraphSpaceBounds(currentDisplayGraph, selectionNodeIds);
if (!bounds) {
if (attempt < 3) {
debugGraphRuntime("camera-selection-deferred", {
nodeId,
graphVersion: graphVersionRef.current,
attempt: attempt + 1,
});
if (deferredFocusFrameRef.current !== null) {
window.cancelAnimationFrame(deferredFocusFrameRef.current);
}
deferredFocusFrameRef.current = window.requestAnimationFrame(() => {
deferredFocusFrameRef.current = null;
centerSelectionInViewInternal(nodeId, attempt + 1);
});
} else {
debugGraphRuntime("camera-selection-fallback-fit", {
nodeId,
graphVersion: graphVersionRef.current,
viewMode: viewModeRef.current,
});
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;
}
animateCameraToBounds("fit-selection-context", selectionBBox, {
nodeId,
contextCount: bounds.count,
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,
});
}, [animateCameraToBounds, fitDisplayGraphInView]);
const focusNodeInView = useCallback((nodeId: string) => {
const sigma = sigmaRef.current;
if (!sigma) {
return;
}
const focusedView = viewModeRef.current === "focused"
&& Boolean(selectedNodeIdRef.current)
&& graph.hasNode(selectedNodeIdRef.current);
if (focusedView) {
const focusedBounds = computeDisplayedGraphBounds(sigma, displayGraphRef.current);
const focusedBBox = focusedBounds
? expandGraphSpaceBounds(focusedBounds, focusedBounds, {
paddingRatio: 0.14,
minSpanRatio: 0.08,
minSpanFloor: 0.02,
})
: null;
animateCameraToBounds("fit-focused-graph", focusedBBox, {
nodeId,
focusedCount: focusedBounds?.count ?? 0,
focusedMinX: focusedBounds?.minX ?? null,
focusedMaxX: focusedBounds?.maxX ?? null,
focusedMinY: focusedBounds?.minY ?? null,
focusedMaxY: focusedBounds?.maxY ?? null,
});
return;
}
centerSelectionInView(nodeId);
}, [animateCameraToBounds, centerSelectionInView]);
const fitCurrentView = useCallback(() => {
fitDisplayGraphInView();
}, [fitDisplayGraphInView]);
const dispatchAction = useCallback((action: GraphBehaviorActionRequest) => {
const context = behaviorContextRef.current;
if (!context) {
return;
}
dispatchBehaviorAction(behaviors, context, action);
}, [behaviors]);
const getBehaviorContext = useCallback((sigma?: Sigma | null): GraphBehaviorContext | null => {
const runtimeSigma = sigma ?? sigmaRef.current;
if (!runtimeSigma) {
return null;
}
const context: GraphBehaviorContext = {
sigma: runtimeSigma,
graph,
displayGraph: displayGraphRef.current,
getInteractionState: () => interactionStateRef.current,
setHoveredNodeId,
onNodeSelectionChange: (nodeId: string) => onNodeClickRef.current(nodeId),
onEdgeSelectionChange: (edgeId: string) => onEdgeClickRef.current?.(edgeId),
focusNodeInView,
centerSelectionInView,
fitCurrentView,
dispatchAction,
};
behaviorContextRef.current = context;
return context;
}, [centerSelectionInView, dispatchAction, fitCurrentView, focusNodeInView]);
const dispatchToBehaviors = useCallback((
hook: "onNodeEnter" | "onNodeLeave" | "onNodeClick" | "onEdgeClick" | "onStageClick" | "onCameraChange",
...args: unknown[]
) => {
const context = getBehaviorContext();
if (!context) {
return;
}
for (const behavior of behaviors) {
const handler = behavior[hook];
if (typeof handler === "function") {
(handler as (...handlerArgs: unknown[]) => void)(context, ...args);
}
}
}, [behaviors, getBehaviorContext]);
useImperativeHandle(ref, () => ({
fitView: () => dispatchAction({ type: "fitView" }),
focusNode: (nodeId: string) => dispatchAction({ type: "focusNode", nodeId }),
zoomIn: () => {
const sigma = sigmaRef.current;
if (!sigma) {
return;
}
debugGraphRuntime("camera-zoom-in", {
graphVersion: graphVersionRef.current,
ratioBefore: sigma.getCamera().getState().ratio,
});
sigma.getCamera().animatedZoom({ duration: GRAPH_THEME.motion.cameraMs });
},
zoomOut: () => {
const sigma = sigmaRef.current;
if (!sigma) {
return;
}
debugGraphRuntime("camera-zoom-out", {
graphVersion: graphVersionRef.current,
ratioBefore: sigma.getCamera().getState().ratio,
});
sigma.getCamera().animatedUnzoom({ duration: GRAPH_THEME.motion.cameraMs });
},
requestRender: () => sigmaRef.current?.scheduleRefresh(),
getCameraState: () => {
const sigma = sigmaRef.current;
if (!sigma) {
return null;
}
const state = sigma.getCamera().getState();
return { x: state.x, y: state.y, ratio: state.ratio };
},
}), [dispatchAction]);
const syncCameraState = useCallback((sigma: Sigma) => {
const camera = sigma.getCamera();
const state = camera.getState();
const cameraState: GraphCameraState = {
x: state.x,
y: state.y,
ratio: state.ratio,
};
const nextTier = getZoomTier(cameraState.ratio);
setZoomTier((current) => (current === nextTier ? current : nextTier));
onCameraStateChangeRef.current?.(cameraState);
dispatchToBehaviors("onCameraChange", cameraState);
debugGraphRuntime("camera-updated", {
graphVersion: graphVersionRef.current,
ratio: cameraState.ratio,
});
}, [dispatchToBehaviors]);
useEffect(() => {
if (!graphReady || sigmaRef.current || !containerRef.current) {
return;
}
const sigma = new Sigma(displayGraphRef.current, containerRef.current, SIGMA_SETTINGS);
sigmaRef.current = sigma;
appliedGraphVersionRef.current = graphVersionRef.current;
const camera = sigma.getCamera();
const runtime: GraphSceneRuntime = {
renderer: "sigma",
scene: sigma,
graph,
displayGraph: displayGraphRef.current,
graphVersion: graphVersionRef.current,
layoutMode: displayMetaRef.current.layoutMode,
requestRender: () => sigma.scheduleRefresh(),
getCameraState: () => {
const state = camera.getState();
return { x: state.x, y: state.y, ratio: state.ratio };
},
};
runtimeRef.current = runtime;
onSceneRuntimeChangeRef.current?.(runtime);
debugGraphRuntime("sigma-created", {
graphVersion: graphVersionRef.current,
layoutMode: displayMetaRef.current.layoutMode,
order: displayGraphRef.current.order,
size: displayGraphRef.current.size,
});
const context = getBehaviorContext(sigma);
if (context) {
for (const behavior of behaviors) {
behavior.attach(context);
}
}
const handleResize = () => {
if (containerRef.current && containerRef.current.offsetWidth > 0) {
sigma.scheduleRefresh();
}
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(containerRef.current);
sigmaResizeObserverRef.current = resizeObserver;
const handleCameraUpdated = () => syncCameraState(sigma);
sigmaCameraSyncRef.current = handleCameraUpdated;
camera.on("updated", handleCameraUpdated);
sigma.on("clickNode", ({ node }) => dispatchToBehaviors("onNodeClick", node));
sigma.on("clickEdge", ({ edge }) => {
const currentDisplayGraph = displayGraphRef.current;
const [source, target] = currentDisplayGraph.extremities(edge);
const stableEdgeId = String(edge);
const attrs = currentDisplayGraph.getEdgeAttributes(edge) as EdgeAttributes;
if (isEdgeInteractable(currentDisplayGraph, interactionStateRef.current, stableEdgeId, source, target, attrs)) {
dispatchToBehaviors("onEdgeClick", stableEdgeId);
}
});
sigma.on("clickStage", () => dispatchToBehaviors("onStageClick"));
sigma.on("enterNode", ({ node }) => dispatchToBehaviors("onNodeEnter", node));
sigma.on("leaveNode", ({ node }) => dispatchToBehaviors("onNodeLeave", node));
sigma.on("enterEdge", ({ edge }) => {
const container = containerRef.current;
if (!container) {
return;
}
const currentDisplayGraph = displayGraphRef.current;
const [source, target] = currentDisplayGraph.extremities(edge);
const stableEdgeId = String(edge);
const attrs = currentDisplayGraph.getEdgeAttributes(edge) as EdgeAttributes;
container.style.cursor = isEdgeInteractable(currentDisplayGraph, interactionStateRef.current, stableEdgeId, source, target, attrs)
? "pointer"
: "";
});
sigma.on("leaveEdge", () => {
if (containerRef.current) {
containerRef.current.style.cursor = "";
}
});
requestAnimationFrame(() => {
syncCameraState(sigma);
});
}, [behaviors, dispatchToBehaviors, getBehaviorContext, graphReady, syncCameraState]);
useEffect(() => {
return () => {
const sigma = sigmaRef.current;
const context = behaviorContextRef.current;
if (context) {
for (const behavior of behaviors) {
behavior.detach(context);
}
}
if (containerRef.current) {
containerRef.current.style.cursor = "";
}
sigmaResizeObserverRef.current?.disconnect();
sigmaResizeObserverRef.current = null;
if (sigma && sigmaCameraSyncRef.current) {
sigma.getCamera().off("updated", sigmaCameraSyncRef.current);
}
sigmaCameraSyncRef.current = null;
if (sigma) {
debugGraphRuntime("sigma-killed", {
graphVersion: graphVersionRef.current,
});
sigma.kill();
}
if (deferredFocusFrameRef.current !== null) {
window.cancelAnimationFrame(deferredFocusFrameRef.current);
deferredFocusFrameRef.current = null;
}
runtimeRef.current = null;
behaviorContextRef.current = null;
sigmaRef.current = null;
onSceneRuntimeChangeRef.current?.(null);
};
}, [behaviors]);
useEffect(() => {
const sigma = sigmaRef.current;
if (!graphReady || !sigma) {
return;
}
if (appliedGraphVersionRef.current === graphVersion && sigma.getGraph() === displayGraph) {
return;
}
debugGraphRuntime("sigma-set-graph", {
graphVersion,
displayFitViewMode: displayFitSignature.viewMode,
displayFitLayoutMode: displayFitSignature.layoutMode,
layoutMode: displayMeta.layoutMode,
order: displayGraph.order,
size: displayGraph.size,
});
if (deferredFocusFrameRef.current !== null) {
window.cancelAnimationFrame(deferredFocusFrameRef.current);
deferredFocusFrameRef.current = null;
}
sigma.setCustomBBox(null);
sigma.setGraph(displayGraph);
appliedGraphVersionRef.current = graphVersion;
fittedDisplaySignatureRef.current = null;
previousInteractionStateRef.current = null;
behaviorContextRef.current = getBehaviorContext(sigma);
if (runtimeRef.current) {
runtimeRef.current.displayGraph = displayGraph;
runtimeRef.current.graphVersion = graphVersion;
runtimeRef.current.layoutMode = displayMeta.layoutMode;
}
sigma.scheduleRefresh();
}, [displayFitSignature.layoutMode, displayFitSignature.viewMode, displayGraph, displayMeta.layoutMode, getBehaviorContext, graphReady, graphVersion]);
useEffect(() => {
const sigma = sigmaRef.current;
if (!graphReady || !sigma || isSameDisplayFitSignature(fittedDisplaySignatureRef.current, displayFitSignature)) {
return;
}
const frame = window.requestAnimationFrame(() => {
if (
!sigmaRef.current
|| graphVersionRef.current !== graphVersion
|| displayGraphRef.current !== displayFitSignature.displayGraph
|| viewModeRef.current !== displayFitSignature.viewMode
|| displayMetaRef.current.layoutMode !== displayFitSignature.layoutMode
) {
return;
}
fittedDisplaySignatureRef.current = displayFitSignature;
debugGraphRuntime("display-fit-signature-applied", {
graphVersion: displayFitSignature.graphVersion,
viewMode: displayFitSignature.viewMode,
layoutMode: displayFitSignature.layoutMode,
order: displayFitSignature.displayGraph.order,
size: displayFitSignature.displayGraph.size,
});
if (selectedNodeIdRef.current && viewModeRef.current === "focused") {
debugGraphRuntime("initial-focused-fit", {
graphVersion,
nodeId: selectedNodeIdRef.current,
});
focusNodeInView(selectedNodeIdRef.current);
return;
}
if (selectedNodeIdRef.current) {
debugGraphRuntime("initial-focus-node", {
graphVersion,
nodeId: selectedNodeIdRef.current,
});
centerSelectionInView(selectedNodeIdRef.current);
return;
}
debugGraphRuntime("initial-fit-view", {
graphVersion,
});
dispatchAction({ type: "fitView" });
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [centerSelectionInView, dispatchAction, displayFitSignature, focusNodeInView, graphReady, graphVersion]);
useEffect(() => {
const context = getBehaviorContext();
if (!context) {
return;
}
for (const behavior of behaviors) {
behavior.onStateChange?.(context, interactionState);
}
for (const behavior of behaviors) {
behavior.apply?.(context, interactionState);
}
onInteractionStateChange?.(interactionState);
}, [behaviors, getBehaviorContext, interactionState, onInteractionStateChange]);
useEffect(() => {
const sigma = sigmaRef.current;
const container = containerRef.current;
if (!sigma || !container) {
setAnalyticsSnapshot(null);
onAnalyticsChange?.(null);
return;
}
const visibleNodes = collectVisibleNodeSamples(
sigma,
displayGraph,
container.clientWidth,
container.clientHeight,
).map((sample) => sample.nodeId);
const analytics = buildGraphAnalyticsSnapshot({
graphRef: displayGraph,
interactionState,
base: analyticsBase,
visibleNodeIds: visibleNodes,
});
setAnalyticsSnapshot(analytics);
onAnalyticsChange?.(analytics);
}, [analyticsBase, displayGraph, interactionState, onAnalyticsChange]);
useEffect(() => {
if (!onDiagnosticsChange) {
return;
}
const sigma = sigmaRef.current;
const container = containerRef.current;
const availability = buildEffectAvailability(
interactionState,
effectsState,
temporalState,
analyticsSnapshot,
isLayoutRunning,
sigma,
container?.clientWidth ?? 0,
container?.clientHeight ?? 0,
);
onDiagnosticsChange(availability);
}, [analyticsSnapshot, effectsState, interactionState, isLayoutRunning, onDiagnosticsChange, temporalState]);
useEffect(() => {
previousInteractionStateRef.current = null;
}, [displayGraph]);
useEffect(() => {
const sigma = sigmaRef.current;
if (!sigma) {
return;
}
const previousInteractionState = previousInteractionStateRef.current;
const refreshTargets = previousInteractionState
? collectInteractionRefreshTargets(displayGraph, previousInteractionState, interactionState)
: undefined;
applySceneState(sigma, displayGraph, interactionState, analyticsSnapshot, refreshTargets);
previousInteractionStateRef.current = interactionState;
}, [analyticsSnapshot, displayGraph, interactionState]);
const drawOverlayFrame = useCallback(() => {
const sigma = sigmaRef.current;
const overlay = overlayRef.current;
const container = containerRef.current;
if (!sigma || !overlay || !container) {
return false;
}
const rect = container.getBoundingClientRect();
const pixelRatio = window.devicePixelRatio || 1;
if (overlay.width !== Math.floor(rect.width * pixelRatio) || overlay.height !== Math.floor(rect.height * pixelRatio)) {
overlay.width = Math.floor(rect.width * pixelRatio);
overlay.height = Math.floor(rect.height * pixelRatio);
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
}
const context = overlay.getContext("2d");
if (!context) {
return false;
}
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, rect.width, rect.height);
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
const focusIds = primaryNodeId && graph.hasNode(primaryNodeId) ? buildFocusSet(primaryNodeId) : new Set<string>();
const edgeEndpointIds = buildEdgeEndpointSet(displayGraph, interactionState.selectedEdgeId);
const pathNodeIds = new Set(interactionState.activePath);
const pathSegments = collectPathSegments(
sigma,
interactionState.activePath,
interactionState.activePathEdgeIds,
interactionState.zoomTier,
rect.width,
rect.height,
);
const visibleNodeSamples = collectVisibleNodeSamples(sigma, displayGraph, rect.width, rect.height);
const effectAvailability = buildEffectAvailability(
interactionState,
effectsState,
temporalState,
analyticsSnapshot,
isLayoutRunning,
sigma,
rect.width,
rect.height,
);
const cameraRatio = sigma.getCamera().getState().ratio;
const nodesToDecorate = new Set<string>([
...focusIds,
...edgeEndpointIds,
...pathNodeIds,
...(primaryNodeId ? [primaryNodeId] : []),
]);
const now = performance.now() / 1000;
if (!isLayoutRunning) {
drawContourLayer(context, analyticsSnapshot, visibleNodeSamples, interactionState, effectsState);
drawSemanticRegionsLayer(context, analyticsSnapshot, visibleNodeSamples, interactionState, effectsState);
drawTemporalEmphasisLayer(context, visibleNodeSamples, temporalState, interactionState, effectsState);
}
nodesToDecorate.forEach((nodeId) => {
const displayData = sigma.getNodeDisplayData(nodeId);
if (!displayData) return;
const attrs = graph.getNodeAttributes(nodeId) as NodeAttributes;
const state = resolveNodeVisualState(
nodeId,
interactionState.zoomTier,
interactionState.hoveredNodeId,
interactionState.selectedNodeId,
interactionState.selectedEdgeId,
focusIds,
edgeEndpointIds,
pathNodeIds,
);
const style = resolveNodeElementStyle(
GRAPH_THEME,
interactionState.zoomTier,
state,
attrs,
attrs.label,
cameraRatio,
);
const point = sigma.graphToViewport({ x: displayData.x, y: displayData.y });
if (style.showHalo) {
const radius = Math.max(
displayData.size * GRAPH_THEME.overlays.glowRadiusMultiplier * (style.nodeVariant === "selected" ? 1.08 : 1),
GRAPH_THEME.overlays.minGlowRadius,
);
drawGlowHalo(
context,
point.x,
point.y,
radius,
withAlpha(
style.haloColor,
nodeId === primaryNodeId ? GRAPH_THEME.overlays.hoverGlowAlpha : GRAPH_THEME.overlays.pathGlowAlpha,
),
);
}
if (style.showBadge && style.badgeKind) {
drawNodeBadge(context, point.x, point.y, displayData.size, style.badgeKind, style.badgeCount);
}
});
if (!isLayoutRunning && primaryNodeId && effectAvailability.lens.available) {
drawLensLayer(context, sigma, primaryNodeId, focusIds);
}
drawPathEffectsLayer(context, pathSegments, effectsState, effectAvailability, now);
return effectAvailability.pathPulse.available || effectAvailability.pathFlow.available;
}, [analyticsSnapshot, displayGraph, effectsState, interactionState, isLayoutRunning, temporalState]);
useEffect(() => {
const sigma = sigmaRef.current;
const container = containerRef.current;
if (!graphReady || !sigma || !container) {
return;
}
let frame = 0;
let disposed = false;
const render = () => {
if (disposed) {
return;
}
const animated = drawOverlayFrame();
if (animated) {
frame = window.requestAnimationFrame(render);
}
};
const redrawStatic = () => {
if (frame !== 0) {
return;
}
drawOverlayFrame();
};
const camera = sigma.getCamera();
const resizeObserver = new ResizeObserver(redrawStatic);
resizeObserver.observe(container);
camera.on("updated", redrawStatic);
render();
return () => {
disposed = true;
resizeObserver.disconnect();
camera.off("updated", redrawStatic);
if (frame !== 0) {
window.cancelAnimationFrame(frame);
}
};
}, [drawOverlayFrame, graphReady]);
useEffect(() => {
const sigma = sigmaRef.current;
const layoutTargetGraph = displayMeta.layoutMode === "owned" ? displayGraph : graph;
const targetKey = displayMeta.layoutMode === "owned"
? `display:${graphVersion}:${viewMode}`
: `store:${displayMeta.layoutMode}`;
const currentTargetKey = (fa2Ref.current as (FA2Layout & { __targetKey?: string }) | null)?.__targetKey;
if (!isLayoutRunning) {
fa2Ref.current?.stop();
if (layoutSyncFrameRef.current !== null) {
window.cancelAnimationFrame(layoutSyncFrameRef.current);
layoutSyncFrameRef.current = null;
}
return;
}
if (!fa2Ref.current || currentTargetKey !== targetKey) {
fa2Ref.current?.kill();
const nextLayout = new FA2Layout(layoutTargetGraph, FA2_SETTINGS) as FA2Layout & { __targetKey?: string };
nextLayout.__targetKey = targetKey;
fa2Ref.current = nextLayout;
debugGraphRuntime("layout-target-changed", {
graphVersion,
layoutMode: displayMeta.layoutMode,
target: displayMeta.layoutMode === "owned" ? "display-owned" : displayMeta.layoutMode === "base" ? "base" : "store-for-mirror",
order: layoutTargetGraph.order,
size: layoutTargetGraph.size,
});
}
fa2Ref.current.start();
if (displayMeta.layoutMode === "mirrored" && sigma) {
let disposed = false;
const syncTick = () => {
if (disposed || !sigmaRef.current || !isLayoutRunning) {
return;
}
const changed = syncDisplayNodePositionsFromStore(displayGraphRef.current, graph);
if (changed > 0) {
sigmaRef.current.scheduleRefresh();
layoutSyncTickRef.current += 1;
if (layoutSyncTickRef.current === 1 || layoutSyncTickRef.current % 30 === 0) {
debugGraphRuntime("layout-mirror-sync", {
graphVersion: graphVersionRef.current,
changedNodes: changed,
tick: layoutSyncTickRef.current,
});
}
}
layoutSyncFrameRef.current = window.requestAnimationFrame(syncTick);
};
layoutSyncTickRef.current = 0;
layoutSyncFrameRef.current = window.requestAnimationFrame(syncTick);
return () => {
disposed = true;
if (layoutSyncFrameRef.current !== null) {
window.cancelAnimationFrame(layoutSyncFrameRef.current);
layoutSyncFrameRef.current = null;
}
fa2Ref.current?.stop();
};
}
return () => {
fa2Ref.current?.stop();
};
}, [displayGraph, displayMeta.layoutMode, graphVersion, isLayoutRunning, viewMode]);
useEffect(() => {
return () => {
if (layoutSyncFrameRef.current !== null) {
window.cancelAnimationFrame(layoutSyncFrameRef.current);
layoutSyncFrameRef.current = null;
}
fa2Ref.current?.kill();
fa2Ref.current = null;
};
}, []);
const handleFitView = useCallback(() => {
fitCurrentView();
}, [fitCurrentView]);
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<div
ref={containerRef}
className={className}
style={{ width: "100%", height: "100%", background: "transparent" }}
/>
<canvas
ref={overlayRef}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 4,
}}
/>
{pluginOverlays.length ? (
<div
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
zIndex: 6,
}}
>
{pluginOverlays.map((overlay, index) => (
<div key={`graph-plugin-overlay-${index}`} style={{ position: "absolute", inset: 0 }}>
{overlay}
</div>
))}
</div>
) : null}
{showFitViewButton ? (
<button
id="graph-fit-view-btn"
onClick={handleFitView}
style={{
position: "absolute",
bottom: 24,
left: 24,
padding: "8px 16px",
background: "linear-gradient(135deg, rgba(27, 79, 170, 0.9), rgba(53, 123, 255, 0.84))",
color: "#fff",
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
borderRadius: 10,
cursor: "pointer",
fontWeight: 700,
zIndex: 10,
backdropFilter: "blur(10px)",
boxShadow: `0 10px 28px ${GRAPH_THEME.palette.background.shellGlow}`,
fontSize: 12,
letterSpacing: "0.01em",
}}
>
Fit View
</button>
) : null}
</div>
);
}
);