Skip to content

Commit c0d08c4

Browse files
authored
Merge pull request #486 from Hawksight-AI/fix/graph-motion
Fix explorer zooming and Loading Flicker
2 parents c9a382e + 5a388d0 commit c0d08c4

12 files changed

Lines changed: 1198 additions & 246 deletions

File tree

explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx

Lines changed: 953 additions & 179 deletions
Large diffs are not rendered by default.

explorer/src/workspaces/GraphWorkspace/GraphRuntimeStage.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState }
33
import { batchMergeEdges, batchMergeNodes, clearGraph, graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
44
import { SigmaSceneAdapter } from "./SigmaSceneAdapter";
55
import { createGraphLoadProgress } from "./graphLoading";
6+
import { resolveDisplayGraph } from "./graphSceneState";
67
import {
78
chooseColorAccessor,
89
colorForNodeKey,
@@ -41,6 +42,7 @@ const STAGE_EFFECTS_STATE: GraphEffectsState = {
4142
lensMode: "neighborhood",
4243
effectQuality: "bounded",
4344
};
45+
const EMPTY_PATH: string[] = [];
4446

4547
const socketProtocol = () => (window.location.protocol === "https:" ? "wss:" : "ws:");
4648

@@ -117,6 +119,10 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
117119
const prevActiveIdsRef = useRef<Set<string>>(new Set());
118120
const [graphVersion, setGraphVersion] = useState(0);
119121
const [runtimeLayoutSource, setRuntimeLayoutSource] = useState<GraphLayoutSource>(snapshot?.summary.layoutSource ?? "runtime");
122+
const displayResult = useMemo(
123+
() => resolveDisplayGraph(selectedNodeId, activePath, EMPTY_PATH, viewMode, { aggregationEnabled: true }),
124+
[activePath, graphVersion, selectedNodeId, viewMode],
125+
);
120126

121127
const stageSignature = useMemo(() => (snapshot ? `${snapshot.fetchedAt}:${snapshot.summary.nodeCount}:${snapshot.summary.edgeCount}` : null), [snapshot]);
122128

@@ -451,9 +457,15 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
451457
<SigmaSceneAdapter
452458
ref={sceneRef}
453459
onNodeSelect={onNodeSelect}
460+
graphVersion={graphVersion}
461+
graphReady={Boolean(snapshot)}
462+
displayGraph={displayResult.graph}
463+
displayMeta={displayResult.meta}
464+
displayState={displayResult.state}
454465
selectedEdgeId=""
455466
selectedNodeId={selectedNodeId}
456467
activePath={activePath}
468+
activePathEdgeIds={EMPTY_PATH}
457469
effectsState={STAGE_EFFECTS_STATE}
458470
isLayoutRunning={isLayoutRunning}
459471
onLayoutRunningChange={onLayoutRunningChange}

explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx

Lines changed: 104 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import { useLoadGraph, useReloadGraph } from "./useLoadGraph";
1212
import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
1313
import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading";
1414
import { GRAPH_THEME, withAlpha } from "./graphTheme";
15-
import { resolveDisplayGraph } from "./graphSceneState";
15+
import { resolveDisplayGraph, resolveDisplayStateSnapshot } from "./graphSceneState";
16+
import { computeGraphAnalyticsBase } from "./graphAnalytics";
1617
import {
1718
type GraphPlugin,
1819
type GraphPluginActionRequest,
@@ -108,6 +109,15 @@ const LazyGraphInspectorPanel = lazy(() => import("./GraphInspectorPanel").then(
108109
const loadExplorationEffectsPlugin = () => import("./plugins/explorationEffectsPluginPhaseC").then((module) => module.explorationEffectsPluginPhaseC);
109110
const loadNeighborhoodPanelPlugin = () => import("./plugins/neighborhoodPanelPlugin").then((module) => module.neighborhoodPanelPlugin);
110111
const loadTemporalOverlayPlugin = () => import("./plugins/temporalOverlayPlugin").then((module) => module.temporalOverlayPlugin);
112+
const EMPTY_PATH: string[] = [];
113+
const DEBUG_GRAPH_WORKSPACE = import.meta.env.DEV;
114+
115+
function debugGraphWorkspace(message: string, payload?: Record<string, unknown>) {
116+
if (!DEBUG_GRAPH_WORKSPACE) {
117+
return;
118+
}
119+
console.debug(`[GraphWorkspace] ${message}`, payload ?? {});
120+
}
111121

112122
function useDebounce<T>(value: T, delay: number): T {
113123
const [debouncedValue, setDebouncedValue] = useState<T>(value);
@@ -670,6 +680,8 @@ export function GraphWorkspace() {
670680
const [selectedNodeId, setSelectedNodeId] = useState("");
671681
const [selectedEdgeId, setSelectedEdgeId] = useState("");
672682
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
683+
const [graphReady, setGraphReady] = useState(false);
684+
const [graphVersion, setGraphVersion] = useState(0);
673685
const [viewMode, setViewMode] = useState<GraphViewMode>("full");
674686
const [aggregationEnabled] = useState(true);
675687
const [collapsedNeighborhoodNodeIds, setCollapsedNeighborhoodNodeIds] = useState<string[]>([]);
@@ -715,9 +727,18 @@ export function GraphWorkspace() {
715727
});
716728
const reload = useReloadGraph();
717729

730+
const handleLoadProgress = useCallback((progress: GraphLoadProgress) => {
731+
setLoadingProgress(progress);
732+
if (progress.phase !== "ready" && progress.phase !== "stabilizing_layout") {
733+
setGraphReady(false);
734+
}
735+
}, []);
736+
718737
const { data: summary, isLoading, isFetching } = useLoadGraph({
719738
enabled: true,
720739
onGraphReady: (graphSummary) => {
740+
setGraphReady(true);
741+
setGraphVersion((current) => current + 1);
721742
setIsLayoutRunning(!graphSummary.layoutReady);
722743
if (settlingOverlayTimeoutRef.current !== null) {
723744
window.clearTimeout(settlingOverlayTimeoutRef.current);
@@ -746,7 +767,7 @@ export function GraphWorkspace() {
746767
settlingOverlayTimeoutRef.current = null;
747768
}, 900);
748769
},
749-
onProgress: setLoadingProgress,
770+
onProgress: handleLoadProgress,
750771
});
751772

752773
useEffect(() => {
@@ -808,6 +829,7 @@ export function GraphWorkspace() {
808829
});
809830
prevActiveIdsRef.current = nextActiveIds;
810831
setActiveNodeCount(data.active_node_count);
832+
setGraphVersion((current) => current + 1);
811833
sceneRef.current?.getRuntime()?.requestRender();
812834
});
813835
} catch (fetchError) {
@@ -877,8 +899,10 @@ export function GraphWorkspace() {
877899
setPathResult(null);
878900
setSearchResults([]);
879901
setSearchError("");
880-
setIsLayoutRunning(false);
881-
}, []);
902+
if (viewMode === "focused") {
903+
setIsLayoutRunning(false);
904+
}
905+
}, [viewMode]);
882906

883907
const handleEdgeSelect = useCallback((edgeId: string) => {
884908
setSelectedEdgeId(edgeId);
@@ -997,6 +1021,7 @@ export function GraphWorkspace() {
9971021
},
9981022
]);
9991023
logEvent("add-node", `Added node ${payload.label ?? payload.id}${payload.nodeType ? ` (${payload.nodeType})` : ""} via realtime ws`, { nodeId: payload.id, nodeType: payload.nodeType });
1024+
setGraphVersion((current) => current + 1);
10001025
sceneRef.current?.getRuntime()?.requestRender();
10011026
}
10021027
if (eventType === "ADD_EDGE") {
@@ -1010,6 +1035,7 @@ export function GraphWorkspace() {
10101035
},
10111036
]);
10121037
logEvent("add-edge", `Added edge ${payload.edgeType ?? payload.id} (${payload.source_id}${payload.target_id}) via realtime ws`, { edgeId: payload.id, edgeType: payload.edgeType, source: payload.source_id, target: payload.target_id });
1038+
setGraphVersion((current) => current + 1);
10131039
sceneRef.current?.getRuntime()?.requestRender();
10141040
}
10151041
} catch (socketError) {
@@ -1026,18 +1052,77 @@ export function GraphWorkspace() {
10261052
setCollapsedNeighborhoodNodeIds([]);
10271053
}, [summary?.edgeCount, summary?.nodeCount]);
10281054

1029-
const showLoadingOverlay = isLoading || isFetching || loadingProgress?.phase === "stabilizing_layout";
1055+
const showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress));
1056+
const showSettlingStatus = graphReady && loadingProgress?.phase === "stabilizing_layout";
10301057
const hasGraphContent = Boolean(summary?.nodeCount);
1031-
const activePath = pathResult?.path ?? [];
1032-
const activePathEdgeIds = pathResult?.edge_ids ?? [];
1058+
const activePath = pathResult?.path ?? EMPTY_PATH;
1059+
const activePathEdgeIds = pathResult?.edge_ids ?? EMPTY_PATH;
1060+
const structuralSelectedNodeId = useMemo(() => {
1061+
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
1062+
return "";
1063+
}
1064+
if (viewMode === "focused") {
1065+
return selectedNodeId;
1066+
}
1067+
return collapsedNeighborhoodNodeIds.includes(selectedNodeId) ? selectedNodeId : "";
1068+
}, [collapsedNeighborhoodNodeIds, selectedNodeId, viewMode]);
1069+
const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH;
1070+
const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH;
1071+
const groupedViewAvailable = useMemo(
1072+
() => computeGraphAnalyticsBase(graph, { computeCommunities: true, computeCentrality: false }).communitiesByNode.size > 0,
1073+
[graphVersion],
1074+
);
10331075
const displayResult = useMemo(
1034-
() => resolveDisplayGraph(selectedNodeId, activePath, activePathEdgeIds, viewMode, {
1076+
() => resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
1077+
aggregationEnabled,
1078+
collapsedNeighborhoodNodeIds,
1079+
groupedViewAvailable,
1080+
}),
1081+
[
1082+
aggregationEnabled,
1083+
collapsedNeighborhoodNodeIds,
1084+
graphVersion,
1085+
groupedViewAvailable,
1086+
structuralActivePath,
1087+
structuralActivePathEdgeIds,
1088+
structuralSelectedNodeId,
1089+
viewMode,
1090+
],
1091+
);
1092+
const displayState = useMemo(
1093+
() => resolveDisplayStateSnapshot(selectedNodeId, activePath, viewMode, {
10351094
aggregationEnabled,
10361095
collapsedNeighborhoodNodeIds,
1096+
groupedViewAvailable,
10371097
}),
1038-
[activePath, activePathEdgeIds, aggregationEnabled, collapsedNeighborhoodNodeIds, selectedNodeId, viewMode],
1098+
[activePath, aggregationEnabled, collapsedNeighborhoodNodeIds, groupedViewAvailable, selectedNodeId, viewMode],
10391099
);
1040-
const displayState = displayResult.state;
1100+
const displayMeta = displayResult.meta;
1101+
const previousDisplayGraphRef = useRef(displayResult.graph);
1102+
const previousDisplayStateRef = useRef(displayState);
1103+
useEffect(() => {
1104+
const graphRebuilt = previousDisplayGraphRef.current !== displayResult.graph;
1105+
const displayStateChanged = previousDisplayStateRef.current !== displayState;
1106+
debugGraphWorkspace("display-state-derived", {
1107+
selectedNodeId,
1108+
structuralSelectedNodeId,
1109+
viewMode,
1110+
graphRebuilt,
1111+
displayStateChanged,
1112+
aggregationEnabled,
1113+
collapsedNeighborhoodActive: Boolean(structuralSelectedNodeId && collapsedNeighborhoodNodeIds.includes(structuralSelectedNodeId)),
1114+
});
1115+
previousDisplayGraphRef.current = displayResult.graph;
1116+
previousDisplayStateRef.current = displayState;
1117+
}, [
1118+
aggregationEnabled,
1119+
collapsedNeighborhoodNodeIds,
1120+
displayResult.graph,
1121+
displayState,
1122+
selectedNodeId,
1123+
structuralSelectedNodeId,
1124+
viewMode,
1125+
]);
10411126
const focusedSummary = useMemo(() => {
10421127
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
10431128
if (viewMode === "grouped") {
@@ -1473,25 +1558,13 @@ export function GraphWorkspace() {
14731558
id: "zoom-in",
14741559
label: "+ Zoom In",
14751560
title: "Zoom in (or scroll up on the canvas)",
1476-
onClick: () => {
1477-
const runtime = sceneRef.current?.getRuntime();
1478-
if (runtime?.renderer === "sigma") {
1479-
const camera = (runtime.scene as import("sigma").default).getCamera();
1480-
camera.animatedZoom({ duration: 200 });
1481-
}
1482-
},
1561+
onClick: () => sceneRef.current?.zoomIn(),
14831562
},
14841563
{
14851564
id: "zoom-out",
14861565
label: "- Zoom Out",
14871566
title: "Zoom out (or scroll down on the canvas)",
1488-
onClick: () => {
1489-
const runtime = sceneRef.current?.getRuntime();
1490-
if (runtime?.renderer === "sigma") {
1491-
const camera = (runtime.scene as import("sigma").default).getCamera();
1492-
camera.animatedUnzoom({ duration: 200 });
1493-
}
1494-
},
1567+
onClick: () => sceneRef.current?.zoomOut(),
14951568
},
14961569
{
14971570
id: "fit-view",
@@ -1550,16 +1623,20 @@ export function GraphWorkspace() {
15501623
const sceneAdapterProps = {
15511624
onNodeSelect: focusNode,
15521625
onEdgeSelect: handleEdgeSelect,
1626+
graphVersion,
1627+
graphReady,
1628+
displayGraph: displayResult.graph,
1629+
displayMeta,
1630+
displayState,
15531631
selectedNodeId,
15541632
selectedEdgeId,
15551633
activePath,
15561634
activePathEdgeIds,
15571635
effectsState,
15581636
temporalState,
15591637
isLayoutRunning,
1638+
layoutSource: graphSummary?.layoutSource,
15601639
viewMode,
1561-
aggregationEnabled,
1562-
collapsedNeighborhoodNodeIds,
15631640
showFitViewButton: false,
15641641
pluginOverlays: pluginOverlays.map((overlay) => overlay.element),
15651642
onRuntimeChange: handleSceneRuntimeChange,
@@ -1580,7 +1657,7 @@ export function GraphWorkspace() {
15801657
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
15811658
<div className="explore-toolbar">
15821659
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
1583-
{showLoadingOverlay && loadingProgress ? (
1660+
{(showLoadingOverlay || showSettlingStatus) && loadingProgress ? (
15841661
<MetricChip>{getGraphLoadTitle(loadingProgress.phase)}</MetricChip>
15851662
) : null}
15861663
{summary ? (

explorer/src/workspaces/GraphWorkspace/SigmaSceneAdapter.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export const SigmaSceneAdapter = forwardRef<GraphSceneHandle, GraphSceneProps>(
2424
useImperativeHandle(ref, () => ({
2525
fitView: () => canvasRef.current?.fitView(),
2626
focusNode: (nodeId: string) => canvasRef.current?.focusNode(nodeId),
27+
zoomIn: () => canvasRef.current?.zoomIn(),
28+
zoomOut: () => canvasRef.current?.zoomOut(),
2729
getRuntime: () => runtimeRef.current,
2830
setLayoutRunning: onLayoutRunningChange
2931
? (running: boolean) => {

explorer/src/workspaces/GraphWorkspace/behaviors/focusCameraBehavior.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ export const focusCameraBehavior: GraphBehavior = {
55
attach: () => {},
66
detach: () => {},
77
performAction: (context, action) => {
8-
if (action.type !== "focusNode") {
9-
return false;
8+
if (action.type === "focusNode") {
9+
context.focusNodeInView(action.nodeId);
10+
return true;
1011
}
1112

12-
context.focusNodeInView(action.nodeId);
13-
return true;
13+
if (action.type === "centerSelection") {
14+
context.centerSelectionInView(action.nodeId);
15+
return true;
16+
}
17+
18+
return false;
1419
},
1520
};
Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
import type { GraphBehavior } from "./types";
22

33
export function createSearchFocusBehavior(): GraphBehavior {
4-
let lastFocusedNodeId = "";
4+
let lastSelectedNodeId = "";
55

66
return {
77
id: "search-focus",
88
attach: () => {},
99
detach: () => {
10-
lastFocusedNodeId = "";
10+
lastSelectedNodeId = "";
1111
},
1212
onStateChange: (context, interactionState) => {
13-
const nextFocusedNodeId = interactionState.focusedNodeId;
14-
if (!nextFocusedNodeId || nextFocusedNodeId === lastFocusedNodeId) {
15-
lastFocusedNodeId = nextFocusedNodeId;
13+
const nextSelectedNodeId = interactionState.selectedNodeId;
14+
if (!nextSelectedNodeId || nextSelectedNodeId === lastSelectedNodeId) {
15+
lastSelectedNodeId = nextSelectedNodeId;
1616
return;
1717
}
1818

19-
lastFocusedNodeId = nextFocusedNodeId;
20-
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
19+
lastSelectedNodeId = nextSelectedNodeId;
20+
context.dispatchAction({ type: "centerSelection", nodeId: nextSelectedNodeId });
2121
},
2222
};
2323
}

explorer/src/workspaces/GraphWorkspace/behaviors/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import type { GraphCameraState, GraphInteractionState } from "../types";
66

77
export type GraphBehaviorActionRequest =
88
| { type: "fitView" }
9-
| { type: "focusNode"; nodeId: string };
9+
| { type: "focusNode"; nodeId: string }
10+
| { type: "centerSelection"; nodeId: string };
1011

1112
export interface GraphBehaviorContext {
1213
sigma: Sigma;
@@ -17,6 +18,7 @@ export interface GraphBehaviorContext {
1718
onNodeSelectionChange: (nodeId: string) => void;
1819
onEdgeSelectionChange: (edgeId: string) => void;
1920
focusNodeInView: (nodeId: string) => void;
21+
centerSelectionInView: (nodeId: string) => void;
2022
fitCurrentView: () => void;
2123
dispatchAction: (action: GraphBehaviorActionRequest) => void;
2224
}

explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ export function createViewModeSwitchBehavior(): GraphBehavior {
1616
}
1717

1818
lastViewMode = interactionState.viewMode;
19+
const nextSelectedNodeId = interactionState.selectedNodeId;
1920

20-
if (interactionState.focusedNodeId) {
21-
context.dispatchAction({ type: "focusNode", nodeId: interactionState.focusedNodeId });
21+
if (interactionState.viewMode === "focused" && nextSelectedNodeId) {
22+
context.dispatchAction({ type: "focusNode", nodeId: nextSelectedNodeId });
2223
return;
2324
}
2425

0 commit comments

Comments
 (0)