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) { 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>; 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, 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(); 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( 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(null); const overlayRef = useRef(null); const sigmaRef = useRef(null); const fa2Ref = useRef(null); const behaviorContextRef = useRef(null); const runtimeRef = useRef(null); const sigmaResizeObserverRef = useRef(null); const sigmaCameraSyncRef = useRef<(() => void) | null>(null); const displayGraphRef = useRef(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(null); const [zoomTier, setZoomTier] = useState("overview"); const [analyticsSnapshot, setAnalyticsSnapshot] = useState(null); const appliedGraphVersionRef = useRef(null); const fittedDisplaySignatureRef = useRef(null); const layoutSyncFrameRef = useRef(null); const layoutSyncTickRef = useRef(0); const deferredFocusFrameRef = useRef(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( () => [ 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(interactionState); interactionStateRef.current = interactionState; const previousInteractionStateRef = useRef(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(() => ({ 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, ) => { 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(); 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([ ...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 (
{pluginOverlays.length ? (
{pluginOverlays.map((overlay, index) => (
{overlay}
))}
) : null} {showFitViewButton ? ( ) : null}
); } );