mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-30 04:40:16 +00:00
2674 lines
88 KiB
TypeScript
2674 lines
88 KiB
TypeScript
import { useEffect, useMemo, useRef, useCallback, forwardRef, useImperativeHandle, useState, type ReactNode } from "react";
|
|
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 {
|
|
buildFocusSetInGraph,
|
|
buildEdgeEndpointSet,
|
|
buildPathEdgeSet,
|
|
collectInteractionRefreshTargets,
|
|
createInteractionState,
|
|
isEdgeInteractable,
|
|
classifyFullGraphEdge,
|
|
mapFullEdgeClassToVisualState,
|
|
resolveEdgeElementStyle,
|
|
resolveEdgeVisualState,
|
|
resolveDistanceEdgeStyle,
|
|
resolveDistanceNodeStyle,
|
|
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 {
|
|
buildGraphStructureCurveCache,
|
|
clearGraphStructureLayer,
|
|
createGraphStructureCacheKey,
|
|
drawGraphStructureLayer,
|
|
evaluateGraphStructureLayerGate,
|
|
getGraphStructureLayerDiagnostics,
|
|
type GraphStructureCurveCache,
|
|
} from "./graphStructureLayer";
|
|
import type {
|
|
GraphAnalyticsSnapshot,
|
|
GraphCameraState,
|
|
GraphDistanceVisualState,
|
|
GraphDisplayMeta,
|
|
GraphDisplayStateSnapshot,
|
|
GraphDiagnosticsSnapshot,
|
|
GraphEffectsState,
|
|
GraphFullEdgeClass,
|
|
GraphFullEdgeClassCounts,
|
|
GraphFullEdgeClassDiagnostics,
|
|
GraphInteractionState,
|
|
GraphLayoutStatus,
|
|
GraphRuntimeDiagnosticsSnapshot,
|
|
GraphStructureLayerDiagnostics,
|
|
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;
|
|
focusedNodeId: string;
|
|
selectedEdgeId: string;
|
|
activePath?: string[];
|
|
activePathEdgeIds?: string[];
|
|
distanceVisualState?: GraphDistanceVisualState;
|
|
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?: (diagnostics: GraphRuntimeDiagnosticsSnapshot) => 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 FULL_GRAPH_LAYOUT_SETTLE_MS = 4_500;
|
|
|
|
const GROUPED_FA2_SETTINGS = {
|
|
iterations: GRAPH_THEME.grouped.layout.iterations,
|
|
settings: {
|
|
barnesHutOptimize: true,
|
|
barnesHutTheta: 0.5,
|
|
adjustSizes: true,
|
|
gravity: GRAPH_THEME.grouped.layout.gravity,
|
|
scalingRatio: GRAPH_THEME.grouped.layout.scalingRatio,
|
|
edgeWeightInfluence: GRAPH_THEME.grouped.layout.edgeWeightInfluence,
|
|
linLogMode: false,
|
|
strongGravityMode: false,
|
|
slowDown: GRAPH_THEME.grouped.layout.slowDown,
|
|
},
|
|
};
|
|
|
|
const SIGMA_SETTINGS = {
|
|
allowInvalidContainer: true,
|
|
labelRenderedSizeThreshold: 6,
|
|
defaultNodeType: "circle",
|
|
defaultEdgeType: "line",
|
|
hideLabelsOnMove: true,
|
|
hideEdgesOnMove: true,
|
|
enableEdgeEvents: true,
|
|
// #1009: edge labels (the edge `type` — "works_for", "leads", ...) were
|
|
// hardcoded off, so edge text never rendered regardless of data. The
|
|
// labelDensity / labelGridCellSize / labelRenderedSizeThreshold settings
|
|
// below already throttle label density for both nodes and edges.
|
|
renderEdgeLabels: true,
|
|
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" };
|
|
|
|
// #1009: edge labels are immediately available once the graph is loaded —
|
|
// they have no async analytics or zoom-tier dependency.
|
|
const edgeLabels = effectsState.edgeLabelsEnabled
|
|
? { enabled: true, available: true, reason: "Ready" }
|
|
: { 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,
|
|
edgeLabels,
|
|
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);
|
|
}
|
|
|
|
type ReducerSceneState = {
|
|
viewMode: GraphViewMode;
|
|
zoomTier: GraphZoomTier;
|
|
hoveredNodeId: string | null;
|
|
selectedNodeId: string;
|
|
selectedEdgeId: string;
|
|
activePath: string[];
|
|
activePathEdgeIds: string[];
|
|
focusIds: Set<string>;
|
|
edgeEndpointIds: Set<string>;
|
|
pathNodeIds: Set<string>;
|
|
pathEdgeIds: Set<string>;
|
|
highlightedIncidentEdgeIds: Set<string>;
|
|
overviewBackboneEdgeIds: Set<string>;
|
|
distanceVisualState?: GraphDistanceVisualState;
|
|
};
|
|
|
|
const FULL_EDGE_CLASSES: GraphFullEdgeClass[] = [
|
|
"hidden",
|
|
"backbone",
|
|
"bridge",
|
|
"local-context",
|
|
"selected",
|
|
"path",
|
|
"muted",
|
|
];
|
|
|
|
function createFullEdgeClassCounts(): GraphFullEdgeClassCounts {
|
|
return FULL_EDGE_CLASSES.reduce((counts, edgeClass) => {
|
|
counts[edgeClass] = 0;
|
|
return counts;
|
|
}, {} as GraphFullEdgeClassCounts);
|
|
}
|
|
|
|
function getIncidentEdgeRevealCap(viewMode: GraphViewMode, zoomTier: GraphZoomTier): number {
|
|
return GRAPH_THEME.edges.contextCaps[viewMode]?.[zoomTier] ?? 0;
|
|
}
|
|
|
|
function scoreIncidentEdge(
|
|
attrs: EdgeAttributes,
|
|
edgeId: string,
|
|
otherEndpointId: string,
|
|
visibleNeighborIds: Set<string>,
|
|
focusIds: Set<string>,
|
|
pathEdgeIds: Set<string>,
|
|
selectedEdgeId: string,
|
|
): number {
|
|
if (pathEdgeIds.has(edgeId)) {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
if (selectedEdgeId && edgeId === selectedEdgeId) {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
|
|
const weight = Math.max(Number(attrs.weight ?? attrs.representativeWeight ?? 1) || 1, 1);
|
|
const normalizedWeight = Math.min(1, Math.log1p(weight) / Math.log(25));
|
|
const visualPriority = Math.max(0, Math.min(Number(attrs.visualPriority ?? 0), 1));
|
|
const relationshipStrength = Math.max(0, Math.min(Number(attrs.relationshipStrength ?? 0), 1));
|
|
|
|
return (visibleNeighborIds.has(otherEndpointId) ? 6 : 0)
|
|
+ (focusIds.has(otherEndpointId) ? 1.25 : 0)
|
|
+ visualPriority * 2
|
|
+ normalizedWeight * 1.4
|
|
+ relationshipStrength;
|
|
}
|
|
|
|
function buildHighlightedIncidentEdgeIds(
|
|
displayGraph: GraphSceneGraph,
|
|
interactionState: GraphInteractionState,
|
|
displayState: GraphDisplayStateSnapshot | undefined,
|
|
focusIds: Set<string>,
|
|
pathEdgeIds: Set<string>,
|
|
): Set<string> {
|
|
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
|
if (!primaryNodeId || !displayGraph.hasNode(primaryNodeId)) {
|
|
return new Set();
|
|
}
|
|
|
|
const cap = getIncidentEdgeRevealCap(interactionState.viewMode, interactionState.zoomTier);
|
|
if (cap <= 0 && !interactionState.selectedEdgeId && pathEdgeIds.size === 0) {
|
|
return new Set();
|
|
}
|
|
|
|
const visibleNeighborIds = new Set(
|
|
(displayState?.selectedVisibleNeighborIds ?? [])
|
|
.filter((nodeId) => displayGraph.hasNode(nodeId)),
|
|
);
|
|
const candidates: Array<{ edgeId: string; score: number }> = [];
|
|
|
|
displayGraph.edges(primaryNodeId).forEach((edge) => {
|
|
const edgeId = String(edge);
|
|
if (!displayGraph.hasEdge(edgeId)) {
|
|
return;
|
|
}
|
|
const [source, target] = displayGraph.extremities(edgeId);
|
|
const sourceId = String(source);
|
|
const targetId = String(target);
|
|
const otherEndpointId = sourceId === primaryNodeId ? targetId : sourceId;
|
|
const attrs = displayGraph.getEdgeAttributes(edgeId) as EdgeAttributes;
|
|
candidates.push({
|
|
edgeId,
|
|
score: scoreIncidentEdge(
|
|
attrs,
|
|
edgeId,
|
|
otherEndpointId,
|
|
visibleNeighborIds,
|
|
focusIds,
|
|
pathEdgeIds,
|
|
interactionState.selectedEdgeId,
|
|
),
|
|
});
|
|
});
|
|
|
|
candidates.sort((left, right) => {
|
|
if (right.score !== left.score) {
|
|
return right.score - left.score;
|
|
}
|
|
return left.edgeId.localeCompare(right.edgeId);
|
|
});
|
|
|
|
const selected = new Set<string>();
|
|
candidates.forEach((candidate) => {
|
|
if (
|
|
candidate.edgeId === interactionState.selectedEdgeId
|
|
|| pathEdgeIds.has(candidate.edgeId)
|
|
|| selected.size < cap
|
|
) {
|
|
selected.add(candidate.edgeId);
|
|
}
|
|
});
|
|
return selected;
|
|
}
|
|
|
|
function buildReducerSceneState(
|
|
displayGraph: GraphSceneGraph,
|
|
interactionState: GraphInteractionState,
|
|
displayState?: GraphDisplayStateSnapshot,
|
|
analyticsSnapshot?: GraphAnalyticsSnapshot | null,
|
|
distanceVisualState?: GraphDistanceVisualState,
|
|
): ReducerSceneState {
|
|
const { viewMode, zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
|
|
const primaryNodeId = hoveredNodeId || selectedNodeId;
|
|
const focusIds = primaryNodeId
|
|
? (
|
|
displayGraph.hasNode(primaryNodeId)
|
|
? buildFocusSetInGraph(displayGraph, primaryNodeId)
|
|
: new Set<string>()
|
|
)
|
|
: new Set<string>();
|
|
const pathEdgeIds = buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds);
|
|
|
|
return {
|
|
viewMode,
|
|
zoomTier,
|
|
hoveredNodeId,
|
|
selectedNodeId,
|
|
selectedEdgeId,
|
|
activePath,
|
|
activePathEdgeIds: interactionState.activePathEdgeIds,
|
|
focusIds,
|
|
edgeEndpointIds: buildEdgeEndpointSet(displayGraph, selectedEdgeId),
|
|
pathNodeIds: new Set(activePath),
|
|
pathEdgeIds,
|
|
overviewBackboneEdgeIds: new Set(analyticsSnapshot?.overviewBackbone.edgeIds ?? []),
|
|
distanceVisualState,
|
|
highlightedIncidentEdgeIds: buildHighlightedIncidentEdgeIds(
|
|
displayGraph,
|
|
interactionState,
|
|
displayState,
|
|
focusIds,
|
|
pathEdgeIds,
|
|
),
|
|
};
|
|
}
|
|
|
|
function getFullGraphEdgeClass(
|
|
displayGraph: GraphSceneGraph,
|
|
edgeId: string,
|
|
currentState: ReducerSceneState,
|
|
): GraphFullEdgeClass {
|
|
if (!displayGraph.hasEdge(edgeId)) {
|
|
return "hidden";
|
|
}
|
|
|
|
const [source, target] = displayGraph.extremities(edgeId);
|
|
const sourceId = String(source);
|
|
const targetId = String(target);
|
|
const sourceAttrs = displayGraph.hasNode(sourceId)
|
|
? displayGraph.getNodeAttributes(sourceId) as NodeAttributes
|
|
: undefined;
|
|
const targetAttrs = displayGraph.hasNode(targetId)
|
|
? displayGraph.getNodeAttributes(targetId) as NodeAttributes
|
|
: undefined;
|
|
|
|
return classifyFullGraphEdge(
|
|
edgeId,
|
|
sourceId,
|
|
targetId,
|
|
currentState.zoomTier,
|
|
currentState.hoveredNodeId,
|
|
currentState.selectedNodeId,
|
|
currentState.selectedEdgeId,
|
|
currentState.focusIds,
|
|
currentState.pathEdgeIds,
|
|
currentState.highlightedIncidentEdgeIds,
|
|
currentState.overviewBackboneEdgeIds,
|
|
sourceAttrs,
|
|
targetAttrs,
|
|
);
|
|
}
|
|
|
|
function buildFullGraphEdgeClassDiagnostics(
|
|
displayGraph: GraphSceneGraph,
|
|
currentState: ReducerSceneState,
|
|
): GraphFullEdgeClassDiagnostics {
|
|
const counts = createFullEdgeClassCounts();
|
|
|
|
displayGraph.forEachEdge((edgeId) => {
|
|
const edgeClass = currentState.viewMode === "full"
|
|
? getFullGraphEdgeClass(displayGraph, String(edgeId), currentState)
|
|
: "hidden";
|
|
counts[edgeClass] += 1;
|
|
});
|
|
|
|
return {
|
|
mode: currentState.viewMode,
|
|
zoomTier: currentState.zoomTier,
|
|
totalEdges: displayGraph.size,
|
|
visibleEdges: displayGraph.size - counts.hidden,
|
|
counts,
|
|
updatedAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
function applySceneState(
|
|
sigma: Sigma,
|
|
reducerSceneStateRef: { current: ReducerSceneState },
|
|
reducerWarningStateRef: { current: { missingEdges: Set<string>; missingNodes: Set<string> } },
|
|
refreshTargets?: {
|
|
nodes?: string[];
|
|
edges?: string[];
|
|
},
|
|
) {
|
|
sigma.setSetting("nodeReducer", (node, data) => {
|
|
const currentGraph = sigma.getGraph() as GraphSceneGraph;
|
|
const currentState = reducerSceneStateRef.current;
|
|
const cameraRatio = sigma.getCamera().getState().ratio;
|
|
if (!currentGraph.hasNode(node)) {
|
|
if (!reducerWarningStateRef.current.missingNodes.has(node)) {
|
|
reducerWarningStateRef.current.missingNodes.add(node);
|
|
debugGraphRuntime("node-reducer-node-missing", {
|
|
nodeId: node,
|
|
order: currentGraph.order,
|
|
size: currentGraph.size,
|
|
});
|
|
}
|
|
return {
|
|
...data,
|
|
hidden: true,
|
|
};
|
|
}
|
|
const attrs = data as NodeAttributes;
|
|
const state = resolveNodeVisualState(
|
|
node,
|
|
currentState.zoomTier,
|
|
currentState.hoveredNodeId,
|
|
currentState.selectedNodeId,
|
|
currentState.selectedEdgeId,
|
|
currentState.focusIds,
|
|
currentState.edgeEndpointIds,
|
|
currentState.pathNodeIds,
|
|
);
|
|
const style = resolveNodeElementStyle(
|
|
GRAPH_THEME,
|
|
currentState.zoomTier,
|
|
state,
|
|
attrs,
|
|
data.label,
|
|
cameraRatio,
|
|
);
|
|
const distanceStyle = currentState.viewMode === "full"
|
|
? resolveDistanceNodeStyle(
|
|
GRAPH_THEME,
|
|
currentState.zoomTier,
|
|
style,
|
|
currentState.distanceVisualState,
|
|
String(node),
|
|
)
|
|
: {};
|
|
const resolvedStyle = { ...style, ...distanceStyle };
|
|
|
|
return {
|
|
...data,
|
|
color: resolvedStyle.color,
|
|
shellColor: resolvedStyle.shellColor,
|
|
coreScale: resolvedStyle.coreScale,
|
|
size: resolvedStyle.size,
|
|
forceLabel: resolvedStyle.forceLabel,
|
|
label: resolvedStyle.label,
|
|
zIndex: resolvedStyle.zIndex,
|
|
hidden: resolvedStyle.hidden,
|
|
borderColor: resolvedStyle.borderColor,
|
|
borderSize: resolvedStyle.borderSize,
|
|
ringColor: resolvedStyle.showRing ? resolvedStyle.ringColor : resolvedStyle.borderColor,
|
|
ringSize: resolvedStyle.ringSize,
|
|
entityShape: resolvedStyle.entityShape,
|
|
entityShapeKind: resolvedStyle.entityShapeKind,
|
|
entityAspectRatio: resolvedStyle.entityAspectRatio,
|
|
};
|
|
});
|
|
|
|
sigma.setSetting("edgeReducer", (edge, data) => {
|
|
const currentGraph = sigma.getGraph() as GraphSceneGraph;
|
|
const currentState = reducerSceneStateRef.current;
|
|
if (!currentGraph.hasEdge(edge)) {
|
|
if (!reducerWarningStateRef.current.missingEdges.has(edge)) {
|
|
reducerWarningStateRef.current.missingEdges.add(edge);
|
|
debugGraphRuntime("edge-reducer-edge-missing", {
|
|
edgeId: edge,
|
|
order: currentGraph.order,
|
|
size: currentGraph.size,
|
|
});
|
|
}
|
|
return {
|
|
...data,
|
|
hidden: true,
|
|
};
|
|
}
|
|
const attrs = data as EdgeAttributes;
|
|
const [source, target] = currentGraph.extremities(edge);
|
|
const stableEdgeId = String(edge);
|
|
const hasActiveInteraction = Boolean(
|
|
currentState.hoveredNodeId
|
|
|| currentState.selectedNodeId
|
|
|| currentState.selectedEdgeId
|
|
|| currentState.pathEdgeIds.size > 0,
|
|
);
|
|
const fullEdgeClass = currentState.viewMode === "full"
|
|
? getFullGraphEdgeClass(currentGraph, stableEdgeId, currentState)
|
|
: undefined;
|
|
const state = currentState.viewMode === "full"
|
|
? mapFullEdgeClassToVisualState(
|
|
fullEdgeClass ?? "hidden",
|
|
{
|
|
hoveredNodeId: currentState.hoveredNodeId,
|
|
hasActiveInteraction,
|
|
},
|
|
)
|
|
: resolveEdgeVisualState(
|
|
stableEdgeId,
|
|
String(source),
|
|
String(target),
|
|
currentState.zoomTier,
|
|
currentState.hoveredNodeId,
|
|
currentState.selectedNodeId,
|
|
currentState.selectedEdgeId,
|
|
currentState.focusIds,
|
|
currentState.pathEdgeIds,
|
|
currentState.highlightedIncidentEdgeIds,
|
|
);
|
|
const style = resolveEdgeElementStyle(
|
|
GRAPH_THEME,
|
|
currentState.zoomTier,
|
|
state,
|
|
attrs,
|
|
source,
|
|
target,
|
|
currentState.viewMode,
|
|
stableEdgeId,
|
|
fullEdgeClass,
|
|
);
|
|
const distanceStyle = currentState.viewMode === "full"
|
|
? resolveDistanceEdgeStyle(
|
|
style,
|
|
currentState.distanceVisualState,
|
|
String(source),
|
|
String(target),
|
|
fullEdgeClass,
|
|
)
|
|
: {};
|
|
const resolvedStyle = { ...style, ...distanceStyle };
|
|
|
|
return {
|
|
...data,
|
|
hidden: resolvedStyle.hidden,
|
|
type: resolvedStyle.type,
|
|
color: resolvedStyle.color,
|
|
size: resolvedStyle.size,
|
|
zIndex: resolvedStyle.zIndex,
|
|
curvature: resolvedStyle.curvature,
|
|
// #1009: Sigma's edge label renderer draws data.label — the graph
|
|
// stores the relationship type in edgeType, which the renderer never
|
|
// saw, so enabling renderEdgeLabels alone left edges blank.
|
|
// Use || rather than ?? so that an empty-string edgeType (possible
|
|
// when the API returns type: "") does not produce a blank label.
|
|
label: resolvedStyle.hidden ? undefined : String(attrs.edgeType || data.label || ""),
|
|
};
|
|
});
|
|
|
|
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,
|
|
focusedNodeId,
|
|
selectedEdgeId,
|
|
activePath = [],
|
|
activePathEdgeIds = [],
|
|
distanceVisualState,
|
|
effectsState,
|
|
temporalState,
|
|
isLayoutRunning,
|
|
onLayoutRunningChange,
|
|
viewMode,
|
|
className,
|
|
showFitViewButton = true,
|
|
pluginOverlays = [],
|
|
onSceneRuntimeChange,
|
|
onInteractionStateChange,
|
|
onCameraStateChange,
|
|
onDiagnosticsChange,
|
|
onAnalyticsChange,
|
|
},
|
|
ref,
|
|
) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const overlayRef = useRef<HTMLCanvasElement>(null);
|
|
const structureLayerCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const structureLayerCacheRef = useRef<GraphStructureCurveCache | null>(null);
|
|
const structureLayerLastDrawAtRef = useRef<number | null>(null);
|
|
const structureLayerDiagnosticsRef = useRef<GraphStructureLayerDiagnostics | null>(null);
|
|
const sigmaRef = useRef<Sigma | null>(null);
|
|
const fa2Ref = useRef<FA2Layout | null>(null);
|
|
const behaviorContextRef = useRef<GraphBehaviorContext | null>(null);
|
|
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 focusedNodeIdRef = useRef(focusedNodeId);
|
|
const distanceVisualStateRef = useRef(distanceVisualState);
|
|
const viewModeRef = useRef(viewMode);
|
|
const onNodeClickRef = useRef(onNodeClick);
|
|
const onEdgeClickRef = useRef(onEdgeClick);
|
|
const onSceneRuntimeChangeRef = useRef(onSceneRuntimeChange);
|
|
const onCameraStateChangeRef = useRef(onCameraStateChange);
|
|
// #1009: tracked as a ref so the Sigma creation effect always reads the
|
|
// current value without needing effectsState in its dependency array.
|
|
const effectsStateRef = useRef(effectsState);
|
|
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
|
const [zoomTier, setZoomTier] = useState<GraphZoomTier>("overview");
|
|
const [analyticsSnapshot, setAnalyticsSnapshot] = useState<GraphAnalyticsSnapshot | null>(null);
|
|
const [layoutSettledEpoch, setLayoutSettledEpoch] = useState(0);
|
|
const appliedGraphVersionRef = useRef<number | null>(null);
|
|
const fittedDisplaySignatureRef = useRef<DisplayFitSignature | null>(null);
|
|
const layoutSyncFrameRef = useRef<number | null>(null);
|
|
const layoutSyncTickRef = useRef(0);
|
|
const deferredFocusFrameRef = useRef<number | null>(null);
|
|
const fullGraphLayoutSettleTimeoutRef = useRef<number | null>(null);
|
|
const groupedLayoutSettleTimeoutRef = useRef<number | null>(null);
|
|
const reducerWarningStateRef = useRef<{ missingEdges: Set<string>; missingNodes: Set<string> }>({
|
|
missingEdges: new Set<string>(),
|
|
missingNodes: new Set<string>(),
|
|
});
|
|
|
|
displayGraphRef.current = displayGraph;
|
|
displayStateRef.current = displayState;
|
|
displayMetaRef.current = displayMeta;
|
|
graphVersionRef.current = graphVersion;
|
|
selectedNodeIdRef.current = selectedNodeId;
|
|
focusedNodeIdRef.current = focusedNodeId;
|
|
distanceVisualStateRef.current = distanceVisualState;
|
|
viewModeRef.current = viewMode;
|
|
onNodeClickRef.current = onNodeClick;
|
|
onEdgeClickRef.current = onEdgeClick;
|
|
onSceneRuntimeChangeRef.current = onSceneRuntimeChange;
|
|
onCameraStateChangeRef.current = onCameraStateChange;
|
|
effectsStateRef.current = effectsState;
|
|
|
|
const behaviors = useMemo<GraphBehavior[]>(
|
|
() => [
|
|
hoverActivationBehavior,
|
|
clickSelectionBehavior,
|
|
focusCameraBehavior,
|
|
createSearchFocusBehavior(),
|
|
createPathHighlightBehavior(),
|
|
fitViewBehavior,
|
|
createViewModeSwitchBehavior(),
|
|
],
|
|
[],
|
|
);
|
|
|
|
const interactionState = useMemo(
|
|
() => createInteractionState(
|
|
hoveredNodeId,
|
|
selectedNodeId,
|
|
focusedNodeId,
|
|
selectedEdgeId,
|
|
activePath,
|
|
activePathEdgeIds,
|
|
viewMode,
|
|
zoomTier,
|
|
isLayoutRunning,
|
|
),
|
|
[
|
|
activePath,
|
|
activePathEdgeIds,
|
|
focusedNodeId,
|
|
hoveredNodeId,
|
|
isLayoutRunning,
|
|
selectedEdgeId,
|
|
selectedNodeId,
|
|
viewMode,
|
|
zoomTier,
|
|
],
|
|
);
|
|
const interactionStateRef = useRef<GraphInteractionState>(interactionState);
|
|
interactionStateRef.current = interactionState;
|
|
const previousInteractionStateRef = useRef<GraphInteractionState | null>(null);
|
|
const previousDistanceVisualStateRef = useRef<GraphDistanceVisualState | undefined>(undefined);
|
|
useEffect(() => {
|
|
if (!isLayoutRunning) {
|
|
setLayoutSettledEpoch((epoch) => epoch + 1);
|
|
}
|
|
}, [displayGraph, graphVersion, isLayoutRunning]);
|
|
|
|
const shouldComputeCommunities = effectsState.communitiesEnabled || effectsState.semanticRegionsEnabled;
|
|
const shouldComputeCentrality = effectsState.centralityEnabled || effectsState.semanticRegionsEnabled || effectsState.contoursEnabled;
|
|
const analyticsBase = useMemo(
|
|
() => computeGraphAnalyticsBase(displayGraph, {
|
|
computeCommunities: shouldComputeCommunities,
|
|
computeCentrality: shouldComputeCentrality,
|
|
}),
|
|
[displayGraph, shouldComputeCentrality, shouldComputeCommunities],
|
|
);
|
|
const reducerSceneState = useMemo(
|
|
() => buildReducerSceneState(displayGraph, interactionState, displayState, analyticsSnapshot, distanceVisualState),
|
|
[analyticsSnapshot, displayGraph, displayState, distanceVisualState, interactionState],
|
|
);
|
|
const reducerSceneStateRef = useRef<ReducerSceneState>(reducerSceneState);
|
|
reducerSceneStateRef.current = reducerSceneState;
|
|
const edgeClassDiagnostics = useMemo(
|
|
() => buildFullGraphEdgeClassDiagnostics(displayGraph, reducerSceneState),
|
|
[displayGraph, reducerSceneState],
|
|
);
|
|
const structureLayerGate = useMemo(
|
|
() => evaluateGraphStructureLayerGate({
|
|
mode: GRAPH_THEME.edges.fullGraphStructureLayer.mode,
|
|
viewMode,
|
|
isLayoutRunning,
|
|
edgeDiagnostics: edgeClassDiagnostics,
|
|
minimumLiteralEdges: GRAPH_THEME.edges.fullGraphStructureLayer.minimumLiteralEdges,
|
|
}),
|
|
[edgeClassDiagnostics, isLayoutRunning, viewMode],
|
|
);
|
|
const structureLayerCache = useMemo(() => {
|
|
if (!structureLayerGate.enabled) {
|
|
return null;
|
|
}
|
|
const cacheKey = createGraphStructureCacheKey({
|
|
graphVersion,
|
|
zoomTier,
|
|
layoutSettledEpoch,
|
|
overviewBackboneEdgeIds: reducerSceneState.overviewBackboneEdgeIds,
|
|
});
|
|
return buildGraphStructureCurveCache({
|
|
graphRef: displayGraph,
|
|
cacheKey,
|
|
classifyEdge: (edgeId) => getFullGraphEdgeClass(displayGraph, edgeId, reducerSceneState),
|
|
maxCurves: GRAPH_THEME.edges.fullGraphStructureLayer.maxCurves,
|
|
curveStrength: GRAPH_THEME.edges.fullGraphStructureLayer.curveStrength,
|
|
});
|
|
}, [displayGraph, graphVersion, layoutSettledEpoch, reducerSceneState, structureLayerGate.enabled, zoomTier]);
|
|
structureLayerCacheRef.current = structureLayerCache;
|
|
const structureLayerDiagnostics = useMemo(
|
|
() => getGraphStructureLayerDiagnostics({
|
|
gate: structureLayerGate,
|
|
cache: structureLayerCache,
|
|
minimumCurves: GRAPH_THEME.edges.fullGraphStructureLayer.minimumCurves,
|
|
canvasAvailable: Boolean(structureLayerCanvasRef.current),
|
|
lastDrawAt: structureLayerLastDrawAtRef.current,
|
|
}),
|
|
[structureLayerCache, structureLayerGate],
|
|
);
|
|
structureLayerDiagnosticsRef.current = structureLayerDiagnostics;
|
|
const displayFitSignature = useMemo<DisplayFitSignature>(() => ({
|
|
graphVersion,
|
|
viewMode,
|
|
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,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const selectedDisplayNodeId = selectionNodeIds[0];
|
|
const selectedDisplayData = sigma.getNodeDisplayData(selectedDisplayNodeId);
|
|
if (selectedDisplayData) {
|
|
const viewportPoint = sigma.graphToViewport({
|
|
x: selectedDisplayData.x,
|
|
y: selectedDisplayData.y,
|
|
});
|
|
const dimensions = sigma.getDimensions();
|
|
if (isPointNearViewport(viewportPoint, dimensions.width, dimensions.height, 96)) {
|
|
debugGraphRuntime("camera-selection-visible-noop", {
|
|
nodeId,
|
|
selectedDisplayNodeId,
|
|
graphVersion: graphVersionRef.current,
|
|
viewMode: viewModeRef.current,
|
|
viewportX: viewportPoint.x,
|
|
viewportY: viewportPoint.y,
|
|
});
|
|
sigma.scheduleRefresh();
|
|
return;
|
|
}
|
|
}
|
|
|
|
const bounds = computeDisplayedNodeBounds(sigma, selectionNodeIds);
|
|
if (!bounds) {
|
|
if (attempt < 3) {
|
|
debugGraphRuntime("camera-selection-display-bounds-deferred", {
|
|
nodeId,
|
|
graphVersion: graphVersionRef.current,
|
|
attempt: attempt + 1,
|
|
viewMode: viewModeRef.current,
|
|
contextCount: selectionNodeIds.length,
|
|
});
|
|
if (deferredFocusFrameRef.current !== null) {
|
|
window.cancelAnimationFrame(deferredFocusFrameRef.current);
|
|
}
|
|
deferredFocusFrameRef.current = window.requestAnimationFrame(() => {
|
|
deferredFocusFrameRef.current = null;
|
|
centerSelectionInViewInternal(nodeId, attempt + 1);
|
|
});
|
|
} else {
|
|
debugGraphRuntime("camera-selection-display-bounds-fallback-fit", {
|
|
nodeId,
|
|
graphVersion: graphVersionRef.current,
|
|
viewMode: viewModeRef.current,
|
|
contextCount: selectionNodeIds.length,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const camera = sigma.getCamera();
|
|
const currentCameraState = camera.getState();
|
|
const target = {
|
|
x: (bounds.minX + bounds.maxX) / 2,
|
|
y: (bounds.minY + bounds.maxY) / 2,
|
|
ratio: currentCameraState.ratio,
|
|
angle: currentCameraState.angle,
|
|
};
|
|
|
|
debugGraphRuntime("camera-selection-gentle-center", {
|
|
nodeId,
|
|
contextCount: bounds.count,
|
|
boundsSource: "displayed-selection-context",
|
|
minX: bounds.minX,
|
|
maxX: bounds.maxX,
|
|
minY: bounds.minY,
|
|
maxY: bounds.maxY,
|
|
targetX: target.x,
|
|
targetY: target.y,
|
|
preservedRatio: target.ratio,
|
|
});
|
|
|
|
void camera.animate(
|
|
target,
|
|
{ duration: GRAPH_THEME.motion.cameraMs, easing: "quadraticOut" },
|
|
);
|
|
}, []);
|
|
|
|
const centerGroupedSelectionInView = useCallback((nodeId: string) => {
|
|
const sigma = sigmaRef.current;
|
|
if (!sigma) {
|
|
return;
|
|
}
|
|
|
|
const currentDisplayGraph = displayGraphRef.current;
|
|
const selectionNodeIds = collectSelectionContextNodeIds(
|
|
currentDisplayGraph,
|
|
displayStateRef.current,
|
|
nodeId,
|
|
);
|
|
if (selectionNodeIds.length === 0) {
|
|
debugGraphRuntime("camera-grouped-selection-missing-node", {
|
|
nodeId,
|
|
graphVersion: graphVersionRef.current,
|
|
viewMode: viewModeRef.current,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const bounds = computeDisplayedNodeBounds(sigma, selectionNodeIds);
|
|
if (!bounds) {
|
|
debugGraphRuntime("camera-grouped-selection-display-bounds-missing", {
|
|
nodeId,
|
|
graphVersion: graphVersionRef.current,
|
|
viewMode: viewModeRef.current,
|
|
contextCount: selectionNodeIds.length,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const camera = sigma.getCamera();
|
|
const currentCameraState = camera.getState();
|
|
const target = {
|
|
x: (bounds.minX + bounds.maxX) / 2,
|
|
y: (bounds.minY + bounds.maxY) / 2,
|
|
ratio: currentCameraState.ratio,
|
|
angle: currentCameraState.angle,
|
|
};
|
|
|
|
debugGraphRuntime("camera-grouped-selection-center", {
|
|
nodeId,
|
|
graphVersion: graphVersionRef.current,
|
|
viewMode: viewModeRef.current,
|
|
contextCount: bounds.count,
|
|
targetX: target.x,
|
|
targetY: target.y,
|
|
preservedRatio: target.ratio,
|
|
});
|
|
|
|
void camera.animate(
|
|
target,
|
|
{ duration: GRAPH_THEME.motion.cameraMs, easing: "quadraticOut" },
|
|
);
|
|
}, []);
|
|
|
|
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,
|
|
centerGroupedSelectionInView,
|
|
fitCurrentView,
|
|
dispatchAction,
|
|
};
|
|
behaviorContextRef.current = context;
|
|
return context;
|
|
}, [centerGroupedSelectionInView, 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,
|
|
// #1009: initialize with the current toggle value rather than the
|
|
// static default so that a user who disabled Edge Labels before
|
|
// graph/Sigma initialization sees the correct state after mount.
|
|
renderEdgeLabels: effectsStateRef.current.edgeLabelsEnabled,
|
|
});
|
|
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 = "";
|
|
}
|
|
});
|
|
|
|
try {
|
|
const structureCanvas = sigma.createCanvas("structure", {
|
|
beforeLayer: "nodes",
|
|
afterLayer: "edges",
|
|
style: {
|
|
pointerEvents: "none",
|
|
},
|
|
});
|
|
structureLayerCanvasRef.current = structureCanvas;
|
|
} catch (error) {
|
|
debugGraphRuntime("structure-layer-create-failed", {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
structureLayerCanvasRef.current = null;
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
syncCameraState(sigma);
|
|
});
|
|
}, [behaviors, dispatchToBehaviors, getBehaviorContext, graphReady, syncCameraState]);
|
|
|
|
// #1009: renderEdgeLabels follows the Effects-panel toggle instead of
|
|
// staying hardcoded — dense graphs get their label-free edges back.
|
|
useEffect(() => {
|
|
const sigma = sigmaRef.current;
|
|
if (!sigma) {
|
|
return;
|
|
}
|
|
sigma.setSetting("renderEdgeLabels", effectsState.edgeLabelsEnabled);
|
|
sigma.scheduleRefresh();
|
|
}, [effectsState.edgeLabelsEnabled]);
|
|
|
|
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,
|
|
});
|
|
if (structureLayerCanvasRef.current) {
|
|
try {
|
|
sigma.killLayer("structure");
|
|
} catch {
|
|
// Sigma.kill() also cleans layers; ignore if already removed.
|
|
}
|
|
}
|
|
structureLayerCanvasRef.current = null;
|
|
sigma.kill();
|
|
}
|
|
if (deferredFocusFrameRef.current !== null) {
|
|
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;
|
|
}
|
|
reducerWarningStateRef.current.missingEdges.clear();
|
|
reducerWarningStateRef.current.missingNodes.clear();
|
|
sigma.setCustomBBox(null);
|
|
sigma.setGraph(displayGraph);
|
|
appliedGraphVersionRef.current = graphVersion;
|
|
fittedDisplaySignatureRef.current = null;
|
|
previousInteractionStateRef.current = null;
|
|
previousDistanceVisualStateRef.current = undefined;
|
|
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 (focusedNodeIdRef.current && viewModeRef.current === "focused") {
|
|
debugGraphRuntime("initial-focused-fit", {
|
|
graphVersion,
|
|
nodeId: focusedNodeIdRef.current,
|
|
});
|
|
focusNodeInView(focusedNodeIdRef.current);
|
|
return;
|
|
}
|
|
|
|
debugGraphRuntime("initial-fit-view", {
|
|
graphVersion,
|
|
selectedNodeId: selectedNodeIdRef.current || null,
|
|
viewMode: viewModeRef.current,
|
|
});
|
|
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({
|
|
effectAvailability: availability,
|
|
edgeClasses: edgeClassDiagnostics,
|
|
structureLayer: structureLayerDiagnosticsRef.current ?? structureLayerDiagnostics,
|
|
distanceVisual: distanceVisualStateRef.current,
|
|
});
|
|
if (import.meta.env.DEV && effectsState.diagnosticsEnabled) {
|
|
console.debug("[Edge Truth]", edgeClassDiagnostics);
|
|
}
|
|
}, [
|
|
analyticsSnapshot,
|
|
edgeClassDiagnostics,
|
|
effectsState,
|
|
interactionState,
|
|
isLayoutRunning,
|
|
onDiagnosticsChange,
|
|
structureLayerDiagnostics,
|
|
temporalState,
|
|
distanceVisualState,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
previousInteractionStateRef.current = null;
|
|
previousDistanceVisualStateRef.current = undefined;
|
|
}, [displayGraph]);
|
|
|
|
useEffect(() => {
|
|
const sigma = sigmaRef.current;
|
|
if (!sigma) {
|
|
return;
|
|
}
|
|
|
|
const previousInteractionState = previousInteractionStateRef.current;
|
|
const distanceVisualStateChanged = previousDistanceVisualStateRef.current !== distanceVisualState;
|
|
const refreshTargets = !distanceVisualStateChanged && previousInteractionState
|
|
? collectInteractionRefreshTargets(displayGraph, previousInteractionState, interactionState)
|
|
: undefined;
|
|
|
|
applySceneState(sigma, reducerSceneStateRef, reducerWarningStateRef, refreshTargets);
|
|
previousInteractionStateRef.current = interactionState;
|
|
previousDistanceVisualStateRef.current = distanceVisualState;
|
|
}, [displayGraph, distanceVisualState, interactionState, reducerSceneStateRef]);
|
|
|
|
const drawStructureLayerFrame = useCallback(() => {
|
|
const sigma = sigmaRef.current;
|
|
const canvas = structureLayerCanvasRef.current;
|
|
const cache = structureLayerCacheRef.current;
|
|
const diagnostics = getGraphStructureLayerDiagnostics({
|
|
gate: structureLayerGate,
|
|
cache,
|
|
minimumCurves: GRAPH_THEME.edges.fullGraphStructureLayer.minimumCurves,
|
|
canvasAvailable: Boolean(canvas),
|
|
lastDrawAt: structureLayerLastDrawAtRef.current,
|
|
});
|
|
structureLayerDiagnosticsRef.current = diagnostics;
|
|
|
|
if (!sigma || !canvas || !diagnostics.enabled || !cache) {
|
|
clearGraphStructureLayer(canvas);
|
|
return;
|
|
}
|
|
|
|
const drawn = drawGraphStructureLayer({
|
|
sigma,
|
|
canvas,
|
|
cache,
|
|
});
|
|
if (drawn) {
|
|
structureLayerLastDrawAtRef.current = Date.now();
|
|
structureLayerDiagnosticsRef.current = {
|
|
...diagnostics,
|
|
lastDrawAt: structureLayerLastDrawAtRef.current,
|
|
};
|
|
}
|
|
}, [structureLayerGate]);
|
|
|
|
useEffect(() => {
|
|
const sigma = sigmaRef.current;
|
|
if (!graphReady || !sigma) {
|
|
return;
|
|
}
|
|
|
|
const draw = () => drawStructureLayerFrame();
|
|
sigma.on("afterRender", draw);
|
|
draw();
|
|
|
|
return () => {
|
|
sigma.off("afterRender", draw);
|
|
};
|
|
}, [drawStructureLayerFrame, graphReady, structureLayerCache]);
|
|
|
|
useEffect(() => {
|
|
if (isLayoutRunning || viewMode !== "full") {
|
|
clearGraphStructureLayer(structureLayerCanvasRef.current);
|
|
}
|
|
}, [isLayoutRunning, viewMode]);
|
|
|
|
const drawOverlayFrame = useCallback(() => {
|
|
const sigma = sigmaRef.current;
|
|
const overlay = overlayRef.current;
|
|
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
|
|
? (
|
|
displayGraph.hasNode(primaryNodeId)
|
|
? buildFocusSetInGraph(displayGraph, primaryNodeId)
|
|
: new Set<string>()
|
|
)
|
|
: 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 lensFocusIds = new Set<string>();
|
|
if (primaryNodeId) {
|
|
reducerSceneStateRef.current.highlightedIncidentEdgeIds.forEach((edgeId) => {
|
|
if (!displayGraph.hasEdge(edgeId)) {
|
|
return;
|
|
}
|
|
const [source, target] = displayGraph.extremities(edgeId);
|
|
const otherEndpointId = String(source) === primaryNodeId ? String(target) : String(source);
|
|
if (displayGraph.hasNode(otherEndpointId)) {
|
|
lensFocusIds.add(otherEndpointId);
|
|
}
|
|
});
|
|
}
|
|
const now = performance.now() / 1000;
|
|
|
|
if (!isLayoutRunning) {
|
|
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;
|
|
if (!displayGraph.hasNode(nodeId)) {
|
|
debugGraphRuntime("overlay-node-missing-from-display-graph", {
|
|
nodeId,
|
|
graphVersion: graphVersionRef.current,
|
|
viewMode: viewModeRef.current,
|
|
});
|
|
return;
|
|
}
|
|
const attrs = displayGraph.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, lensFocusIds);
|
|
}
|
|
|
|
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 usingGroupedOwnedLayout = displayMeta.layoutMode === "owned" && viewMode === "grouped";
|
|
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 (fullGraphLayoutSettleTimeoutRef.current !== null) {
|
|
window.clearTimeout(fullGraphLayoutSettleTimeoutRef.current);
|
|
fullGraphLayoutSettleTimeoutRef.current = null;
|
|
}
|
|
if (groupedLayoutSettleTimeoutRef.current !== null) {
|
|
window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
|
|
groupedLayoutSettleTimeoutRef.current = null;
|
|
}
|
|
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,
|
|
usingGroupedOwnedLayout ? GROUPED_FA2_SETTINGS : 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 (groupedLayoutSettleTimeoutRef.current !== null) {
|
|
window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
|
|
groupedLayoutSettleTimeoutRef.current = null;
|
|
}
|
|
if (fullGraphLayoutSettleTimeoutRef.current !== null) {
|
|
window.clearTimeout(fullGraphLayoutSettleTimeoutRef.current);
|
|
fullGraphLayoutSettleTimeoutRef.current = null;
|
|
}
|
|
|
|
if (usingGroupedOwnedLayout) {
|
|
groupedLayoutSettleTimeoutRef.current = window.setTimeout(() => {
|
|
fa2Ref.current?.stop();
|
|
groupedLayoutSettleTimeoutRef.current = null;
|
|
onLayoutRunningChange?.(false);
|
|
debugGraphRuntime("grouped-layout-auto-settled", {
|
|
graphVersion: graphVersionRef.current,
|
|
viewMode: viewModeRef.current,
|
|
order: displayGraphRef.current.order,
|
|
size: displayGraphRef.current.size,
|
|
});
|
|
}, GRAPH_THEME.grouped.layout.settleMs);
|
|
} else {
|
|
fullGraphLayoutSettleTimeoutRef.current = window.setTimeout(() => {
|
|
fa2Ref.current?.stop();
|
|
fullGraphLayoutSettleTimeoutRef.current = null;
|
|
onLayoutRunningChange?.(false);
|
|
debugGraphRuntime("full-layout-auto-settled", {
|
|
graphVersion: graphVersionRef.current,
|
|
viewMode: viewModeRef.current,
|
|
layoutMode: displayMetaRef.current.layoutMode,
|
|
order: displayGraphRef.current.order,
|
|
size: displayGraphRef.current.size,
|
|
});
|
|
}, FULL_GRAPH_LAYOUT_SETTLE_MS);
|
|
}
|
|
|
|
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 (fullGraphLayoutSettleTimeoutRef.current !== null) {
|
|
window.clearTimeout(fullGraphLayoutSettleTimeoutRef.current);
|
|
fullGraphLayoutSettleTimeoutRef.current = null;
|
|
}
|
|
if (groupedLayoutSettleTimeoutRef.current !== null) {
|
|
window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
|
|
groupedLayoutSettleTimeoutRef.current = null;
|
|
}
|
|
if (layoutSyncFrameRef.current !== null) {
|
|
window.cancelAnimationFrame(layoutSyncFrameRef.current);
|
|
layoutSyncFrameRef.current = null;
|
|
}
|
|
fa2Ref.current?.stop();
|
|
};
|
|
}
|
|
|
|
return () => {
|
|
if (fullGraphLayoutSettleTimeoutRef.current !== null) {
|
|
window.clearTimeout(fullGraphLayoutSettleTimeoutRef.current);
|
|
fullGraphLayoutSettleTimeoutRef.current = null;
|
|
}
|
|
if (groupedLayoutSettleTimeoutRef.current !== null) {
|
|
window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
|
|
groupedLayoutSettleTimeoutRef.current = null;
|
|
}
|
|
fa2Ref.current?.stop();
|
|
};
|
|
}, [displayGraph, displayMeta.layoutMode, graphVersion, isLayoutRunning, viewMode]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (fullGraphLayoutSettleTimeoutRef.current !== null) {
|
|
window.clearTimeout(fullGraphLayoutSettleTimeoutRef.current);
|
|
fullGraphLayoutSettleTimeoutRef.current = null;
|
|
}
|
|
if (groupedLayoutSettleTimeoutRef.current !== null) {
|
|
window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
|
|
groupedLayoutSettleTimeoutRef.current = null;
|
|
}
|
|
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>
|
|
);
|
|
}
|
|
);
|