mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-09 04:00:52 +00:00
## Folder & Project
- Renamed `semantica-explorer/` → `explorer/` (cleaner path)
- Browser tab title: `Semantica Knowledge Explorer`
- Brand pill: `SEM` → `SKE` (tooltip: Semantica Knowledge Explorer)
- Nav rail label: `Explore` → `Knowledge Explorer`
- package.json name: `semantica-knowledge-explorer`
- Downgraded Vite 8 → Vite 5 for Node v20.17.0 compatibility
## App Shell
- Dynamic per-workspace kicker labels replacing static "Workspace" pill:
Graph Studio · Vocabulary Browser · Reasoning Engine · SPARQL Query ·
Decision Intelligence · Knowledge Audit · Graph Governance
## Enrich Workspace — 2 new tabs
### Entity Resolution tab
- Similarity threshold slider (0.50–0.99)
- Run Dedup Scan → POST /api/enrich/dedup
- Flagged pairs list with colour-coded score bars (red/amber/green)
- Expandable inline diff: primary vs duplicate side-by-side
- One-click Merge → POST /api/enrich/merge with logEvent dispatch
- Dismiss per pair; Clear all button
- Merge history sidebar pulled live from Registry store
### Registry tab (Document Registry)
- Live chronological audit log of all KG mutations in-session
- Colour-coded op-type badges: IMPORT · MERGE · ADD NODE · ADD EDGE ·
INFER · DELETE · EXPORT · VOCAB
- Filter pills to narrow by operation type
- Expandable JSON detail rows per entry
- Clear log button
- Entirely client-side via registryStore (no backend needed)
## Manage Workspace — 2 new tabs
### KG Overview tab
- Stats chips: total nodes, edges, graph density
- Node type breakdown bar chart (up to 8 types, colour-coded)
- Edge type breakdown bar chart from /api/graph/stats
- Top-10 most connected nodes ranked by degree
- Skeleton loading states + Refresh button
### Ontology Summary tab
- Read-only SKOS scheme tree (scheme → top concepts → narrower)
- Concept detail panel: labels, notation, description, narrower nav
- "Open Full Browser" button deep-links to Vocabulary Browser tab
## Decision Workspace polish
- CausalFlowDiagram: vertical node cards connected by relationship pills
- Outcome badges: colour-coded (green=approved, red=rejected, amber=deferred)
- Live filter input across decision ID, category, and outcome
- Animated skeleton loading while list fetches
## Graph Inspector polish
- PathFlowViz: clickable node chips connected by edge-type labels;
clicking a chip focuses that node in the canvas
- Link Prediction button shows spinner while computing
- Empty states for path trace and candidate links sections
## Registry dispatch — WebSocket
- ADD_NODE events → logEvent("add-node", …) in GraphWorkspace WS handler
- ADD_EDGE events → logEvent("add-edge", …) in GraphWorkspace WS handler
- Import, Export, Merge already dispatched logEvent on API response
## Graph visibility overhaul
### Edge colours (were nearly transparent, now clearly visible)
- edgeBackbone: rgba(…, 0.04) → rgba(…, 0.38)
- edgeStructure: rgba(…, 0.009) → rgba(…, 0.28)
- edgeInspection: rgba(…, 0.026) → rgba(…, 0.48)
- Muted edges: 0.009–0.02 → 0.12–0.26
- Focus edges: 0.16 → 0.42
### Edge sizes
- default minSize: 0.18 → 0.9 (always at least 1 pixel wide)
- path minSize: 1.8 → 2.4
- inactive/muted: hide:true → hide:false (dimmed not hidden)
### Node sizes
- default sizeMultiplier: 0.72 → 0.92
- default minSize: 0.68 → 3.5 (visible at all zoom levels)
- overview nodeScale: 0.66 → 0.88
- nodeTintMix (colour): 0.03 → 0.14
- nodeCoreMix (brightness): 0.52 → 0.72
### Label budget
- overview: 10 → 28 labels
- structure: 36 → 60 labels
- inspection: 80 → 120 labels
### Sigma settings
- renderEdgeLabels: false → true (relationship type on every edge)
- edgeLabelSize: — → 10
- labelRenderedSizeThreshold: 4 → 2
- labelDensity: 0.86 → 1.1
- hideLabelsOnMove: true → false (labels stay visible while panning)
- hideEdgesOnMove: true → false (edges stay visible while panning)
- minCameraRatio: — → 0.04 (prevents zooming inside a node)
- maxCameraRatio: — → 8 (graph stays visible when zoomed out)
### Zoom controls
- Added Zoom In (+) and Zoom Out (−) buttons to graph toolbar
- Smooth animated zoom via camera.animatedZoom / animatedUnzoom (200ms)
- Mouse scroll wheel clamped between minCameraRatio and maxCameraRatio
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1148 lines
38 KiB
TypeScript
1148 lines
38 KiB
TypeScript
import { useEffect, useMemo, useRef, useCallback, forwardRef, useImperativeHandle, useState, type ReactNode } from "react";
|
|
import type Graph from "graphology";
|
|
import Sigma from "sigma";
|
|
import FA2Layout from "graphology-layout-forceatlas2/worker";
|
|
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
|
import type { GraphBehavior, GraphBehaviorContext, GraphBehaviorActionRequest } from "./behaviors/types";
|
|
import { hoverActivationBehavior } from "./behaviors/hoverActivationBehavior";
|
|
import { clickSelectionBehavior } from "./behaviors/clickSelectionBehavior";
|
|
import { focusCameraBehavior } from "./behaviors/focusCameraBehavior";
|
|
import { createSearchFocusBehavior } from "./behaviors/searchFocusBehavior";
|
|
import { createPathHighlightBehavior } from "./behaviors/pathHighlightBehavior";
|
|
import { fitViewBehavior } from "./behaviors/fitViewBehavior";
|
|
import { createViewModeSwitchBehavior } from "./behaviors/viewModeSwitchBehavior";
|
|
import {
|
|
GRAPH_THEME,
|
|
getZoomTier,
|
|
type GraphBadgeKind,
|
|
type GraphZoomTier,
|
|
withAlpha,
|
|
zoomTierAtLeast,
|
|
} from "./graphTheme";
|
|
import {
|
|
buildFocusSet,
|
|
buildEdgeEndpointSet,
|
|
buildPathEdgeSet,
|
|
collectInteractionRefreshTargets,
|
|
createInteractionState,
|
|
isEdgeInteractable,
|
|
resolveDisplayGraph,
|
|
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,
|
|
GraphDiagnosticsSnapshot,
|
|
GraphEffectsState,
|
|
GraphInteractionState,
|
|
GraphLayoutStatus,
|
|
GraphTemporalState,
|
|
GraphViewMode,
|
|
} from "./types";
|
|
import type { GraphSceneRuntime } from "./scene";
|
|
|
|
export type { GraphViewMode } from "./types";
|
|
|
|
export interface GraphCanvasHandle {
|
|
fitView: () => void;
|
|
focusNode: (nodeId: string) => void;
|
|
requestRender: () => void;
|
|
getCameraState: () => GraphCameraState | null;
|
|
}
|
|
|
|
export interface GraphCanvasProps {
|
|
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: 2,
|
|
defaultNodeType: "circle",
|
|
defaultEdgeType: "line",
|
|
hideLabelsOnMove: false,
|
|
hideEdgesOnMove: false,
|
|
enableEdgeEvents: true,
|
|
renderEdgeLabels: true,
|
|
edgeLabelSize: 10,
|
|
edgeLabelColor: { color: "rgba(180, 210, 255, 0.72)" },
|
|
labelDensity: 1.1,
|
|
labelGridCellSize: 80,
|
|
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,
|
|
};
|
|
|
|
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,
|
|
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 pathPulse = !effectsState.pathPulseEnabled
|
|
? { enabled: false, available: false, reason: "Disabled by toggle" }
|
|
: !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" }
|
|
: !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" }
|
|
: !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" }
|
|
: !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" }
|
|
: !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" }
|
|
: !contoursTierReady
|
|
? {
|
|
enabled: true,
|
|
available: false,
|
|
reason: "Disabled by zoom tier",
|
|
detail: `Requires ${GRAPH_THEME.effects.contours.minZoomTier}`,
|
|
}
|
|
: !analytics?.centrality.ready
|
|
? {
|
|
enabled: true,
|
|
available: false,
|
|
reason: analytics?.centrality.reason ?? "Waiting for centrality ranking",
|
|
}
|
|
: { enabled: true, available: true, reason: analytics.centrality.reason };
|
|
|
|
const pathfinding = !effectsState.pathfindingEnabled
|
|
? { enabled: false, available: false, reason: "Disabled by toggle" }
|
|
: !analytics?.directedPath.ready
|
|
? {
|
|
enabled: true,
|
|
available: false,
|
|
reason: analytics?.directedPath.reason ?? "Waiting for a traced path",
|
|
}
|
|
: {
|
|
enabled: true,
|
|
available: true,
|
|
reason: analytics.directedPath.verifiedAgainstActivePath
|
|
? "Ready · local path matches traced path"
|
|
: "Ready · local directed path differs from traced path",
|
|
};
|
|
|
|
const communities = !effectsState.communitiesEnabled
|
|
? { enabled: false, available: false, reason: "Disabled by toggle" }
|
|
: !analytics?.communities.ready
|
|
? {
|
|
enabled: true,
|
|
available: false,
|
|
reason: analytics?.communities.reason ?? "Waiting for community summaries",
|
|
}
|
|
: {
|
|
enabled: true,
|
|
available: true,
|
|
reason: analytics.communities.modularity !== null
|
|
? `Ready · modularity ${analytics.communities.modularity.toFixed(3)}`
|
|
: analytics.communities.reason,
|
|
};
|
|
|
|
const centrality = !effectsState.centralityEnabled
|
|
? { enabled: false, available: false, reason: "Disabled by toggle" }
|
|
: !analytics?.centrality.ready
|
|
? {
|
|
enabled: true,
|
|
available: false,
|
|
reason: analytics?.centrality.reason ?? "Waiting for centrality ranking",
|
|
}
|
|
: { enabled: true, available: true, reason: analytics.centrality.reason };
|
|
|
|
const legend = effectsState.legendEnabled
|
|
? { enabled: true, available: true, reason: "Panel enabled" }
|
|
: { enabled: false, available: false, reason: "Disabled by toggle" };
|
|
|
|
const diagnostics = !GRAPH_THEME.effects.diagnostics.enabledInDev
|
|
? { enabled: false, available: false, reason: "Disabled in production" }
|
|
: effectsState.diagnosticsEnabled
|
|
? { enabled: true, available: true, reason: "Ready" }
|
|
: { enabled: false, available: false, reason: "Disabled by toggle" };
|
|
|
|
return {
|
|
pathPulse,
|
|
pathFlow,
|
|
lens,
|
|
temporalEmphasis,
|
|
semanticRegions,
|
|
contours,
|
|
pathfinding,
|
|
communities,
|
|
centrality,
|
|
legend,
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
function drawGlowHalo(
|
|
context: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
radius: number,
|
|
color: string,
|
|
) {
|
|
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
|
|
gradient.addColorStop(0, color);
|
|
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
|
context.fillStyle = gradient;
|
|
context.beginPath();
|
|
context.arc(x, y, radius, 0, Math.PI * 2);
|
|
context.fill();
|
|
}
|
|
|
|
function drawNodeBadge(
|
|
context: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
nodeRadius: number,
|
|
badgeKind: GraphBadgeKind,
|
|
badgeCount: number | undefined,
|
|
) {
|
|
const badgeTheme = GRAPH_THEME.nodes.badges[badgeKind];
|
|
const radius = GRAPH_THEME.nodes.badge.radius;
|
|
const offset = GRAPH_THEME.nodes.badge.offset;
|
|
const badgeX = x + Math.max(nodeRadius * 0.62, radius + offset);
|
|
const badgeY = y - Math.max(nodeRadius * 0.62, radius + offset);
|
|
|
|
drawGlowHalo(
|
|
context,
|
|
badgeX,
|
|
badgeY,
|
|
GRAPH_THEME.overlays.badgeGlowRadius,
|
|
withAlpha(badgeTheme.color, GRAPH_THEME.nodes.badge.glowAlpha),
|
|
);
|
|
|
|
context.fillStyle = badgeTheme.color;
|
|
context.beginPath();
|
|
context.arc(badgeX, badgeY, radius, 0, Math.PI * 2);
|
|
context.fill();
|
|
|
|
context.strokeStyle = GRAPH_THEME.nodes.badge.stroke;
|
|
context.lineWidth = 1;
|
|
context.beginPath();
|
|
context.arc(badgeX, badgeY, radius, 0, Math.PI * 2);
|
|
context.stroke();
|
|
|
|
context.fillStyle = GRAPH_THEME.nodes.badge.textColor;
|
|
context.font = `700 ${GRAPH_THEME.nodes.badge.fontSize}px Inter, system-ui, sans-serif`;
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
const label = badgeKind === "provenance" && badgeCount && badgeCount > 1
|
|
? String(Math.min(9, badgeCount))
|
|
: badgeTheme.label;
|
|
context.fillText(label, badgeX, badgeY + 0.5);
|
|
}
|
|
|
|
function applySceneState(
|
|
sigma: Sigma,
|
|
displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
|
interactionState: GraphInteractionState,
|
|
analyticsSnapshot: GraphAnalyticsSnapshot | null,
|
|
refreshTargets?: {
|
|
nodes?: string[];
|
|
edges?: string[];
|
|
},
|
|
) {
|
|
const { zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
|
|
const primaryNodeId = hoveredNodeId || selectedNodeId;
|
|
const focusIds = primaryNodeId && graph.hasNode(primaryNodeId) ? buildFocusSet(primaryNodeId) : new Set<string>();
|
|
const edgeEndpointIds = buildEdgeEndpointSet(displayGraph, selectedEdgeId);
|
|
const pathNodeIds = new Set(activePath);
|
|
const pathEdgeIds = buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds);
|
|
const overviewBackboneEdgeIds = new Set(analyticsSnapshot?.overviewBackbone.edgeIds ?? []);
|
|
const cameraRatio = sigma.getCamera().getState().ratio;
|
|
|
|
sigma.setSetting("nodeReducer", (node, data) => {
|
|
const attrs = data as NodeAttributes;
|
|
const state = resolveNodeVisualState(
|
|
node,
|
|
zoomTier,
|
|
hoveredNodeId,
|
|
selectedNodeId,
|
|
selectedEdgeId,
|
|
focusIds,
|
|
edgeEndpointIds,
|
|
pathNodeIds,
|
|
);
|
|
const style = resolveNodeElementStyle(GRAPH_THEME, zoomTier, state, attrs, data.label, cameraRatio);
|
|
|
|
return {
|
|
...data,
|
|
color: style.color,
|
|
shellColor: style.shellColor,
|
|
coreScale: style.coreScale,
|
|
size: style.size,
|
|
forceLabel: style.forceLabel,
|
|
label: style.label,
|
|
zIndex: style.zIndex,
|
|
hidden: style.hidden,
|
|
borderColor: style.borderColor,
|
|
borderSize: style.borderSize,
|
|
ringColor: style.showRing ? style.ringColor : style.borderColor,
|
|
ringSize: style.ringSize,
|
|
};
|
|
});
|
|
|
|
sigma.setSetting("edgeReducer", (edge, data) => {
|
|
const attrs = data as EdgeAttributes;
|
|
const [source, target] = displayGraph.extremities(edge);
|
|
const stableEdgeId = String(edge);
|
|
const state = resolveEdgeVisualState(
|
|
stableEdgeId,
|
|
source,
|
|
target,
|
|
zoomTier,
|
|
hoveredNodeId,
|
|
selectedNodeId,
|
|
selectedEdgeId,
|
|
focusIds,
|
|
pathEdgeIds,
|
|
overviewBackboneEdgeIds,
|
|
);
|
|
const style = resolveEdgeElementStyle(GRAPH_THEME, zoomTier, state, attrs, source, target);
|
|
|
|
return {
|
|
...data,
|
|
hidden: style.hidden,
|
|
type: style.type,
|
|
color: style.color,
|
|
size: style.size,
|
|
zIndex: style.zIndex,
|
|
curvature: style.curvature,
|
|
};
|
|
});
|
|
|
|
if ((refreshTargets?.nodes?.length ?? 0) > 0 || (refreshTargets?.edges?.length ?? 0) > 0) {
|
|
sigma.scheduleRefresh({
|
|
partialGraph: {
|
|
nodes: refreshTargets?.nodes,
|
|
edges: refreshTargets?.edges,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
sigma.scheduleRefresh();
|
|
}
|
|
|
|
function dispatchBehaviorAction(
|
|
behaviors: GraphBehavior[],
|
|
context: GraphBehaviorContext,
|
|
action: GraphBehaviorActionRequest,
|
|
) {
|
|
for (const behavior of behaviors) {
|
|
if (behavior.performAction?.(context, action)) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|
function GraphCanvas(
|
|
{
|
|
onNodeClick,
|
|
onEdgeClick,
|
|
selectedNodeId,
|
|
selectedEdgeId,
|
|
activePath = [],
|
|
activePathEdgeIds = [],
|
|
effectsState,
|
|
temporalState,
|
|
isLayoutRunning,
|
|
viewMode,
|
|
className,
|
|
showFitViewButton = true,
|
|
pluginOverlays = [],
|
|
onSceneRuntimeChange,
|
|
onInteractionStateChange,
|
|
onCameraStateChange,
|
|
onDiagnosticsChange,
|
|
onAnalyticsChange,
|
|
},
|
|
ref,
|
|
) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const overlayRef = useRef<HTMLCanvasElement>(null);
|
|
const sigmaRef = useRef<Sigma | null>(null);
|
|
const fa2Ref = useRef<FA2Layout | null>(null);
|
|
const behaviorContextRef = useRef<GraphBehaviorContext | null>(null);
|
|
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
|
const [zoomTier, setZoomTier] = useState<GraphZoomTier>("overview");
|
|
const [analyticsSnapshot, setAnalyticsSnapshot] = useState<GraphAnalyticsSnapshot | null>(null);
|
|
|
|
const behaviors = useMemo<GraphBehavior[]>(
|
|
() => [
|
|
hoverActivationBehavior,
|
|
clickSelectionBehavior,
|
|
focusCameraBehavior,
|
|
createSearchFocusBehavior(),
|
|
createPathHighlightBehavior(),
|
|
fitViewBehavior,
|
|
createViewModeSwitchBehavior(),
|
|
],
|
|
[],
|
|
);
|
|
|
|
const isFocusedView = viewMode === "focused" && Boolean(selectedNodeId) && graph.hasNode(selectedNodeId);
|
|
const displayGraph = useMemo(
|
|
() => resolveDisplayGraph(selectedNodeId, activePath, activePathEdgeIds, viewMode),
|
|
[activePath, activePathEdgeIds, selectedNodeId, viewMode],
|
|
);
|
|
|
|
const interactionState = useMemo(
|
|
() => createInteractionState(
|
|
hoveredNodeId,
|
|
selectedNodeId,
|
|
selectedEdgeId,
|
|
activePath,
|
|
activePathEdgeIds,
|
|
viewMode,
|
|
zoomTier,
|
|
isLayoutRunning,
|
|
),
|
|
[activePath, activePathEdgeIds, hoveredNodeId, isLayoutRunning, selectedEdgeId, selectedNodeId, viewMode, zoomTier],
|
|
);
|
|
const interactionStateRef = useRef<GraphInteractionState>(interactionState);
|
|
interactionStateRef.current = interactionState;
|
|
const previousInteractionStateRef = useRef<GraphInteractionState | null>(null);
|
|
const shouldComputeCommunities = effectsState.communitiesEnabled || effectsState.semanticRegionsEnabled;
|
|
const shouldComputeCentrality = effectsState.centralityEnabled || effectsState.semanticRegionsEnabled || effectsState.contoursEnabled;
|
|
const analyticsBase = useMemo(
|
|
() => computeGraphAnalyticsBase(displayGraph, {
|
|
computeCommunities: shouldComputeCommunities,
|
|
computeCentrality: shouldComputeCentrality,
|
|
}),
|
|
[displayGraph, shouldComputeCentrality, shouldComputeCommunities],
|
|
);
|
|
|
|
const focusNodeInView = useCallback((nodeId: string) => {
|
|
const sigma = sigmaRef.current;
|
|
if (!sigma) {
|
|
return;
|
|
}
|
|
|
|
if (isFocusedView) {
|
|
sigma.getCamera().animatedReset({ duration: GRAPH_THEME.motion.cameraMs });
|
|
sigma.refresh();
|
|
return;
|
|
}
|
|
|
|
const data = sigma.getNodeDisplayData(nodeId);
|
|
if (!data) {
|
|
sigma.getCamera().animatedReset({ duration: GRAPH_THEME.motion.cameraMs });
|
|
return;
|
|
}
|
|
|
|
void sigma.getCamera().animate(
|
|
{ x: data.x, y: data.y, ratio: 0.3 },
|
|
{ duration: GRAPH_THEME.motion.cameraMs, easing: "quadraticOut" },
|
|
);
|
|
}, [isFocusedView]);
|
|
|
|
const fitCurrentView = useCallback(() => {
|
|
const sigma = sigmaRef.current;
|
|
if (!sigma) {
|
|
return;
|
|
}
|
|
|
|
if (selectedNodeId) {
|
|
focusNodeInView(selectedNodeId);
|
|
return;
|
|
}
|
|
|
|
sigma.getCamera().animatedReset({ duration: GRAPH_THEME.motion.cameraMs });
|
|
}, [focusNodeInView, selectedNodeId]);
|
|
|
|
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,
|
|
getInteractionState: () => interactionStateRef.current,
|
|
setHoveredNodeId,
|
|
onNodeSelectionChange: onNodeClick,
|
|
onEdgeSelectionChange: onEdgeClick ?? (() => {}),
|
|
focusNodeInView,
|
|
fitCurrentView,
|
|
dispatchAction,
|
|
};
|
|
behaviorContextRef.current = context;
|
|
return context;
|
|
}, [displayGraph, dispatchAction, fitCurrentView, focusNodeInView, onEdgeClick, onNodeClick]);
|
|
|
|
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 }),
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
const sigma = new Sigma(displayGraph, containerRef.current, SIGMA_SETTINGS);
|
|
sigmaRef.current = sigma;
|
|
const camera = sigma.getCamera();
|
|
const runtime: GraphSceneRuntime = {
|
|
renderer: "sigma",
|
|
scene: sigma,
|
|
graph,
|
|
displayGraph,
|
|
requestRender: () => sigma.scheduleRefresh(),
|
|
getCameraState: () => {
|
|
const state = camera.getState();
|
|
return { x: state.x, y: state.y, ratio: state.ratio };
|
|
},
|
|
};
|
|
onSceneRuntimeChange?.(runtime);
|
|
const context = getBehaviorContext(sigma);
|
|
|
|
if (context) {
|
|
for (const behavior of behaviors) {
|
|
behavior.attach(context);
|
|
}
|
|
}
|
|
|
|
const syncTier = () => {
|
|
const cameraState: GraphCameraState = {
|
|
x: camera.getState().x,
|
|
y: camera.getState().y,
|
|
ratio: camera.getState().ratio,
|
|
};
|
|
const nextTier = getZoomTier(cameraState.ratio);
|
|
setZoomTier((current) => (current === nextTier ? current : nextTier));
|
|
onCameraStateChange?.(cameraState);
|
|
dispatchToBehaviors("onCameraChange", cameraState);
|
|
};
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
if (containerRef.current && containerRef.current.offsetWidth > 0) {
|
|
sigma.scheduleRefresh();
|
|
}
|
|
});
|
|
resizeObserver.observe(containerRef.current);
|
|
|
|
camera.on("updated", syncTier);
|
|
sigma.on("clickNode", ({ node }) => dispatchToBehaviors("onNodeClick", node));
|
|
sigma.on("clickEdge", ({ edge }) => {
|
|
const [source, target] = displayGraph.extremities(edge);
|
|
const stableEdgeId = String(edge);
|
|
const attrs = displayGraph.getEdgeAttributes(edge) as EdgeAttributes;
|
|
|
|
if (isEdgeInteractable(displayGraph, 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 [source, target] = displayGraph.extremities(edge);
|
|
const stableEdgeId = String(edge);
|
|
const attrs = displayGraph.getEdgeAttributes(edge) as EdgeAttributes;
|
|
container.style.cursor = isEdgeInteractable(displayGraph, interactionStateRef.current, stableEdgeId, source, target, attrs)
|
|
? "pointer"
|
|
: "";
|
|
});
|
|
sigma.on("leaveEdge", () => {
|
|
if (containerRef.current) {
|
|
containerRef.current.style.cursor = "";
|
|
}
|
|
});
|
|
|
|
requestAnimationFrame(() => {
|
|
syncTier();
|
|
dispatchAction({ type: "fitView" });
|
|
});
|
|
|
|
return () => {
|
|
if (context) {
|
|
for (const behavior of behaviors) {
|
|
behavior.detach(context);
|
|
}
|
|
}
|
|
camera.off("updated", syncTier);
|
|
resizeObserver.disconnect();
|
|
if (containerRef.current) {
|
|
containerRef.current.style.cursor = "";
|
|
}
|
|
sigma.kill();
|
|
behaviorContextRef.current = null;
|
|
sigmaRef.current = null;
|
|
onSceneRuntimeChange?.(null);
|
|
};
|
|
}, [behaviors, dispatchAction, dispatchToBehaviors, displayGraph, getBehaviorContext, onCameraStateChange, onSceneRuntimeChange]);
|
|
|
|
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,
|
|
sigma,
|
|
container?.clientWidth ?? 0,
|
|
container?.clientHeight ?? 0,
|
|
);
|
|
onDiagnosticsChange(availability);
|
|
}, [analyticsSnapshot, effectsState, interactionState, 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]);
|
|
|
|
useEffect(() => {
|
|
const sigma = sigmaRef.current;
|
|
const overlay = overlayRef.current;
|
|
const container = containerRef.current;
|
|
if (!sigma || !overlay || !container) {
|
|
return;
|
|
}
|
|
|
|
let frame = 0;
|
|
|
|
const draw = () => {
|
|
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) {
|
|
frame = window.requestAnimationFrame(draw);
|
|
return;
|
|
}
|
|
|
|
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
|
context.clearRect(0, 0, rect.width, rect.height);
|
|
|
|
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
|
const focusIds = primaryNodeId && graph.hasNode(primaryNodeId) ? buildFocusSet(primaryNodeId) : new Set<string>();
|
|
const edgeEndpointIds = buildEdgeEndpointSet(displayGraph, interactionState.selectedEdgeId);
|
|
const pathNodeIds = new Set(interactionState.activePath);
|
|
const pathSegments = collectPathSegments(
|
|
sigma,
|
|
interactionState.activePath,
|
|
interactionState.activePathEdgeIds,
|
|
interactionState.zoomTier,
|
|
rect.width,
|
|
rect.height,
|
|
);
|
|
const visibleNodeSamples = collectVisibleNodeSamples(sigma, displayGraph, rect.width, rect.height);
|
|
const effectAvailability = buildEffectAvailability(
|
|
interactionState,
|
|
effectsState,
|
|
temporalState,
|
|
analyticsSnapshot,
|
|
sigma,
|
|
rect.width,
|
|
rect.height,
|
|
);
|
|
const cameraRatio = sigma.getCamera().getState().ratio;
|
|
const nodesToDecorate = new Set<string>([
|
|
...focusIds,
|
|
...edgeEndpointIds,
|
|
...pathNodeIds,
|
|
...(primaryNodeId ? [primaryNodeId] : []),
|
|
]);
|
|
const now = performance.now() / 1000;
|
|
|
|
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 (primaryNodeId && effectAvailability.lens.available) {
|
|
drawLensLayer(context, sigma, primaryNodeId, focusIds);
|
|
}
|
|
|
|
drawPathEffectsLayer(context, pathSegments, effectsState, effectAvailability, now);
|
|
|
|
frame = window.requestAnimationFrame(draw);
|
|
};
|
|
|
|
draw();
|
|
return () => {
|
|
window.cancelAnimationFrame(frame);
|
|
};
|
|
}, [analyticsSnapshot, displayGraph, effectsState, interactionState, temporalState]);
|
|
|
|
useEffect(() => {
|
|
if (selectedNodeId || isFocusedView) {
|
|
fa2Ref.current?.stop();
|
|
return;
|
|
}
|
|
|
|
if (isLayoutRunning) {
|
|
if (!fa2Ref.current) {
|
|
fa2Ref.current = new FA2Layout(graph, FA2_SETTINGS);
|
|
}
|
|
fa2Ref.current.start();
|
|
} else {
|
|
fa2Ref.current?.stop();
|
|
}
|
|
|
|
return () => {
|
|
fa2Ref.current?.stop();
|
|
};
|
|
}, [isFocusedView, isLayoutRunning, selectedNodeId]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
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>
|
|
);
|
|
}
|
|
);
|