mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Merge pull request #493 from Hawksight-AI/feat/explorer-grouped-view
Feat/explorer grouped view
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
zoomTierAtLeast,
|
||||
} from "./graphTheme";
|
||||
import {
|
||||
buildFocusSet,
|
||||
buildFocusSetInGraph,
|
||||
buildEdgeEndpointSet,
|
||||
buildPathEdgeSet,
|
||||
collectInteractionRefreshTargets,
|
||||
@@ -82,6 +81,7 @@ export interface GraphCanvasProps {
|
||||
onNodeClick: (nodeId: string) => void;
|
||||
onEdgeClick?: (edgeId: string) => void;
|
||||
selectedNodeId: string;
|
||||
focusedNodeId: string;
|
||||
selectedEdgeId: string;
|
||||
activePath?: string[];
|
||||
activePathEdgeIds?: string[];
|
||||
@@ -117,6 +117,21 @@ const FA2_SETTINGS = {
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -784,38 +799,96 @@ function drawNodeBadge(
|
||||
context.fillText(label, badgeX, badgeY + 0.5);
|
||||
}
|
||||
|
||||
function applySceneState(
|
||||
sigma: Sigma,
|
||||
displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
type ReducerSceneState = {
|
||||
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>;
|
||||
overviewBackboneEdgeIds: Set<string>;
|
||||
};
|
||||
|
||||
function buildReducerSceneState(
|
||||
displayGraph: GraphSceneGraph,
|
||||
interactionState: GraphInteractionState,
|
||||
analyticsSnapshot: GraphAnalyticsSnapshot | null,
|
||||
): ReducerSceneState {
|
||||
const { 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>();
|
||||
|
||||
return {
|
||||
zoomTier,
|
||||
hoveredNodeId,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
activePath,
|
||||
activePathEdgeIds: interactionState.activePathEdgeIds,
|
||||
focusIds,
|
||||
edgeEndpointIds: buildEdgeEndpointSet(displayGraph, selectedEdgeId),
|
||||
pathNodeIds: new Set(activePath),
|
||||
pathEdgeIds: buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds),
|
||||
overviewBackboneEdgeIds: new Set(analyticsSnapshot?.overviewBackbone.edgeIds ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function applySceneState(
|
||||
sigma: Sigma,
|
||||
reducerSceneStateRef: { current: ReducerSceneState },
|
||||
reducerWarningStateRef: { current: { missingEdges: Set<string>; missingNodes: Set<string> } },
|
||||
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 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,
|
||||
zoomTier,
|
||||
hoveredNodeId,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
focusIds,
|
||||
edgeEndpointIds,
|
||||
pathNodeIds,
|
||||
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 style = resolveNodeElementStyle(GRAPH_THEME, zoomTier, state, attrs, data.label, cameraRatio);
|
||||
|
||||
return {
|
||||
...data,
|
||||
@@ -835,22 +908,45 @@ function applySceneState(
|
||||
});
|
||||
|
||||
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] = displayGraph.extremities(edge);
|
||||
const [source, target] = currentGraph.extremities(edge);
|
||||
const stableEdgeId = String(edge);
|
||||
const state = resolveEdgeVisualState(
|
||||
stableEdgeId,
|
||||
source,
|
||||
target,
|
||||
zoomTier,
|
||||
hoveredNodeId,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
focusIds,
|
||||
pathEdgeIds,
|
||||
overviewBackboneEdgeIds,
|
||||
currentState.zoomTier,
|
||||
currentState.hoveredNodeId,
|
||||
currentState.selectedNodeId,
|
||||
currentState.selectedEdgeId,
|
||||
currentState.focusIds,
|
||||
currentState.pathEdgeIds,
|
||||
currentState.overviewBackboneEdgeIds,
|
||||
);
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
currentState.zoomTier,
|
||||
state,
|
||||
attrs,
|
||||
source,
|
||||
target,
|
||||
);
|
||||
const style = resolveEdgeElementStyle(GRAPH_THEME, zoomTier, state, attrs, source, target);
|
||||
|
||||
return {
|
||||
...data,
|
||||
@@ -899,12 +995,14 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
onNodeClick,
|
||||
onEdgeClick,
|
||||
selectedNodeId,
|
||||
focusedNodeId,
|
||||
selectedEdgeId,
|
||||
activePath = [],
|
||||
activePathEdgeIds = [],
|
||||
effectsState,
|
||||
temporalState,
|
||||
isLayoutRunning,
|
||||
onLayoutRunningChange,
|
||||
viewMode,
|
||||
className,
|
||||
showFitViewButton = true,
|
||||
@@ -930,6 +1028,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const displayMetaRef = useRef(displayMeta);
|
||||
const graphVersionRef = useRef(graphVersion);
|
||||
const selectedNodeIdRef = useRef(selectedNodeId);
|
||||
const focusedNodeIdRef = useRef(focusedNodeId);
|
||||
const viewModeRef = useRef(viewMode);
|
||||
const onNodeClickRef = useRef(onNodeClick);
|
||||
const onEdgeClickRef = useRef(onEdgeClick);
|
||||
@@ -943,12 +1042,18 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const layoutSyncFrameRef = useRef<number | null>(null);
|
||||
const layoutSyncTickRef = useRef(0);
|
||||
const deferredFocusFrameRef = 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;
|
||||
viewModeRef.current = viewMode;
|
||||
onNodeClickRef.current = onNodeClick;
|
||||
onEdgeClickRef.current = onEdgeClick;
|
||||
@@ -972,6 +1077,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
() => createInteractionState(
|
||||
hoveredNodeId,
|
||||
selectedNodeId,
|
||||
focusedNodeId,
|
||||
selectedEdgeId,
|
||||
activePath,
|
||||
activePathEdgeIds,
|
||||
@@ -979,7 +1085,17 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
zoomTier,
|
||||
isLayoutRunning,
|
||||
),
|
||||
[activePath, activePathEdgeIds, hoveredNodeId, isLayoutRunning, selectedEdgeId, selectedNodeId, viewMode, zoomTier],
|
||||
[
|
||||
activePath,
|
||||
activePathEdgeIds,
|
||||
focusedNodeId,
|
||||
hoveredNodeId,
|
||||
isLayoutRunning,
|
||||
selectedEdgeId,
|
||||
selectedNodeId,
|
||||
viewMode,
|
||||
zoomTier,
|
||||
],
|
||||
);
|
||||
const interactionStateRef = useRef<GraphInteractionState>(interactionState);
|
||||
interactionStateRef.current = interactionState;
|
||||
@@ -993,6 +1109,12 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
}),
|
||||
[displayGraph, shouldComputeCentrality, shouldComputeCommunities],
|
||||
);
|
||||
const reducerSceneState = useMemo(
|
||||
() => buildReducerSceneState(displayGraph, interactionState, analyticsSnapshot),
|
||||
[analyticsSnapshot, displayGraph, interactionState],
|
||||
);
|
||||
const reducerSceneStateRef = useRef<ReducerSceneState>(reducerSceneState);
|
||||
reducerSceneStateRef.current = reducerSceneState;
|
||||
const displayFitSignature = useMemo<DisplayFitSignature>(() => ({
|
||||
graphVersion,
|
||||
viewMode,
|
||||
@@ -1175,6 +1297,63 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
});
|
||||
}, [animateCameraToBounds, fitDisplayGraphInView]);
|
||||
|
||||
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) {
|
||||
@@ -1236,12 +1415,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
onEdgeSelectionChange: (edgeId: string) => onEdgeClickRef.current?.(edgeId),
|
||||
focusNodeInView,
|
||||
centerSelectionInView,
|
||||
centerGroupedSelectionInView,
|
||||
fitCurrentView,
|
||||
dispatchAction,
|
||||
};
|
||||
behaviorContextRef.current = context;
|
||||
return context;
|
||||
}, [centerSelectionInView, dispatchAction, fitCurrentView, focusNodeInView]);
|
||||
}, [centerGroupedSelectionInView, centerSelectionInView, dispatchAction, fitCurrentView, focusNodeInView]);
|
||||
|
||||
const dispatchToBehaviors = useCallback((
|
||||
hook: "onNodeEnter" | "onNodeLeave" | "onNodeClick" | "onEdgeClick" | "onStageClick" | "onCameraChange",
|
||||
@@ -1462,6 +1642,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
window.cancelAnimationFrame(deferredFocusFrameRef.current);
|
||||
deferredFocusFrameRef.current = null;
|
||||
}
|
||||
reducerWarningStateRef.current.missingEdges.clear();
|
||||
reducerWarningStateRef.current.missingNodes.clear();
|
||||
sigma.setCustomBBox(null);
|
||||
sigma.setGraph(displayGraph);
|
||||
appliedGraphVersionRef.current = graphVersion;
|
||||
@@ -1501,26 +1683,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
order: displayFitSignature.displayGraph.order,
|
||||
size: displayFitSignature.displayGraph.size,
|
||||
});
|
||||
if (selectedNodeIdRef.current && viewModeRef.current === "focused") {
|
||||
if (focusedNodeIdRef.current && viewModeRef.current === "focused") {
|
||||
debugGraphRuntime("initial-focused-fit", {
|
||||
graphVersion,
|
||||
nodeId: selectedNodeIdRef.current,
|
||||
nodeId: focusedNodeIdRef.current,
|
||||
});
|
||||
focusNodeInView(selectedNodeIdRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedNodeIdRef.current) {
|
||||
debugGraphRuntime("initial-focus-node", {
|
||||
graphVersion,
|
||||
nodeId: selectedNodeIdRef.current,
|
||||
});
|
||||
centerSelectionInView(selectedNodeIdRef.current);
|
||||
focusNodeInView(focusedNodeIdRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
debugGraphRuntime("initial-fit-view", {
|
||||
graphVersion,
|
||||
selectedNodeId: selectedNodeIdRef.current || null,
|
||||
viewMode: viewModeRef.current,
|
||||
});
|
||||
dispatchAction({ type: "fitView" });
|
||||
});
|
||||
@@ -1607,9 +1782,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
? collectInteractionRefreshTargets(displayGraph, previousInteractionState, interactionState)
|
||||
: undefined;
|
||||
|
||||
applySceneState(sigma, displayGraph, interactionState, analyticsSnapshot, refreshTargets);
|
||||
applySceneState(sigma, reducerSceneStateRef, reducerWarningStateRef, refreshTargets);
|
||||
previousInteractionStateRef.current = interactionState;
|
||||
}, [analyticsSnapshot, displayGraph, interactionState]);
|
||||
}, [displayGraph, interactionState, reducerSceneStateRef]);
|
||||
|
||||
const drawOverlayFrame = useCallback(() => {
|
||||
const sigma = sigmaRef.current;
|
||||
@@ -1637,7 +1812,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
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 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(
|
||||
@@ -1677,7 +1858,15 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
nodesToDecorate.forEach((nodeId) => {
|
||||
const displayData = sigma.getNodeDisplayData(nodeId);
|
||||
if (!displayData) return;
|
||||
const attrs = graph.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
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,
|
||||
@@ -1774,6 +1963,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
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}`;
|
||||
@@ -1781,6 +1971,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
|
||||
if (!isLayoutRunning) {
|
||||
fa2Ref.current?.stop();
|
||||
if (groupedLayoutSettleTimeoutRef.current !== null) {
|
||||
window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
|
||||
groupedLayoutSettleTimeoutRef.current = null;
|
||||
}
|
||||
if (layoutSyncFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(layoutSyncFrameRef.current);
|
||||
layoutSyncFrameRef.current = null;
|
||||
@@ -1790,7 +1984,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
|
||||
if (!fa2Ref.current || currentTargetKey !== targetKey) {
|
||||
fa2Ref.current?.kill();
|
||||
const nextLayout = new FA2Layout(layoutTargetGraph, FA2_SETTINGS) as FA2Layout & { __targetKey?: string };
|
||||
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", {
|
||||
@@ -1804,6 +2001,25 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
|
||||
fa2Ref.current.start();
|
||||
|
||||
if (groupedLayoutSettleTimeoutRef.current !== null) {
|
||||
window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
|
||||
groupedLayoutSettleTimeoutRef.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);
|
||||
}
|
||||
|
||||
if (displayMeta.layoutMode === "mirrored" && sigma) {
|
||||
let disposed = false;
|
||||
|
||||
@@ -1833,6 +2049,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (groupedLayoutSettleTimeoutRef.current !== null) {
|
||||
window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
|
||||
groupedLayoutSettleTimeoutRef.current = null;
|
||||
}
|
||||
if (layoutSyncFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(layoutSyncFrameRef.current);
|
||||
layoutSyncFrameRef.current = null;
|
||||
@@ -1842,12 +2062,20 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (groupedLayoutSettleTimeoutRef.current !== null) {
|
||||
window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
|
||||
groupedLayoutSettleTimeoutRef.current = null;
|
||||
}
|
||||
fa2Ref.current?.stop();
|
||||
};
|
||||
}, [displayGraph, displayMeta.layoutMode, graphVersion, isLayoutRunning, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (groupedLayoutSettleTimeoutRef.current !== null) {
|
||||
window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
|
||||
groupedLayoutSettleTimeoutRef.current = null;
|
||||
}
|
||||
if (layoutSyncFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(layoutSyncFrameRef.current);
|
||||
layoutSyncFrameRef.current = null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CSSProperties } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { graph } from "../../store/graphStore";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
import type { GraphSelectedNodeKind } from "./types";
|
||||
|
||||
export type LinkPrediction = {
|
||||
target: string;
|
||||
@@ -20,6 +21,10 @@ export type PathResponse = {
|
||||
|
||||
export interface GraphInspectorPanelProps {
|
||||
nodeId: string;
|
||||
inspectableNodeId?: string | null;
|
||||
selectedNodeKind?: GraphSelectedNodeKind;
|
||||
canActivateFocused?: boolean;
|
||||
focusedUnavailableReason?: string | null;
|
||||
predictions: LinkPrediction[];
|
||||
predictionType: string;
|
||||
onPredictionTypeChange: (value: string) => void;
|
||||
@@ -148,6 +153,10 @@ function PathFlowViz({
|
||||
|
||||
export function GraphInspectorPanel({
|
||||
nodeId,
|
||||
inspectableNodeId,
|
||||
selectedNodeKind = "none",
|
||||
canActivateFocused = false,
|
||||
focusedUnavailableReason = null,
|
||||
predictions,
|
||||
predictionType,
|
||||
onPredictionTypeChange,
|
||||
@@ -173,15 +182,38 @@ export function GraphInspectorPanel({
|
||||
);
|
||||
}
|
||||
|
||||
if (!graph.hasNode(nodeId)) {
|
||||
const resolvedNodeId = inspectableNodeId && graph.hasNode(inspectableNodeId) ? inspectableNodeId : null;
|
||||
const directlyInspectable = graph.hasNode(nodeId);
|
||||
const effectiveNodeId = directlyInspectable ? nodeId : resolvedNodeId;
|
||||
const actionNodeId = directlyInspectable ? nodeId : resolvedNodeId;
|
||||
const groupedDisplaySelection = selectedNodeKind === "grouped" && !directlyInspectable;
|
||||
|
||||
if (!effectiveNodeId) {
|
||||
return (
|
||||
<div style={{ padding: 24, color: "#8b949e", fontSize: 13, lineHeight: 1.6 }}>
|
||||
Selected item is not available for inspection in the current graph.
|
||||
</div>
|
||||
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ background: "#58a6ff", boxShadow: "0 0 10px rgba(88,166,255,0.45)", width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: "#58a6ff", fontSize: 12, fontWeight: 700 }}>Selection</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{nodeId}
|
||||
</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
|
||||
</div>
|
||||
<div style={groupedSelectionNoticeStyle}>
|
||||
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{canActivateFocused
|
||||
? "Activate Focused mode to resolve this grouped selection to its canonical node."
|
||||
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const attributes = graph.getNodeAttributes(nodeId) as {
|
||||
const attributes = graph.getNodeAttributes(effectiveNodeId) as {
|
||||
color?: string;
|
||||
content?: string;
|
||||
label?: string;
|
||||
@@ -204,12 +236,26 @@ export function GraphInspectorPanel({
|
||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>{attributes?.nodeType || "Entity"}</span>
|
||||
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>
|
||||
{groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")}
|
||||
</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{String(attributes?.label ?? nodeId)}
|
||||
{String(attributes?.label ?? effectiveNodeId)}
|
||||
</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||
{groupedDisplaySelection ? nodeId : effectiveNodeId}
|
||||
</div>
|
||||
{groupedDisplaySelection ? (
|
||||
<div style={groupedSelectionNoticeStyle}>
|
||||
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{canActivateFocused
|
||||
? `Canonical node available: ${effectiveNodeId}`
|
||||
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
|
||||
{attributes?.valid_from || attributes?.valid_until ? (
|
||||
<span style={subtleChipStyle}>temporal</span>
|
||||
@@ -234,7 +280,7 @@ export function GraphInspectorPanel({
|
||||
<button
|
||||
style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }}
|
||||
onClick={onRunPredictions}
|
||||
disabled={isRunningPredictions}
|
||||
disabled={isRunningPredictions || !actionNodeId}
|
||||
>
|
||||
{isRunningPredictions ? (
|
||||
<Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} />
|
||||
@@ -242,10 +288,10 @@ export function GraphInspectorPanel({
|
||||
{isRunningPredictions ? "Running…" : "Run Link Prediction"}
|
||||
</button>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")} disabled={!actionNodeId}>
|
||||
Provenance JSON
|
||||
</button>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")} disabled={!actionNodeId}>
|
||||
Provenance MD
|
||||
</button>
|
||||
</div>
|
||||
@@ -267,7 +313,7 @@ export function GraphInspectorPanel({
|
||||
placeholder="Target node ID"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button>
|
||||
<button style={actionButtonStyle} onClick={onTracePath} disabled={!actionNodeId}>Trace Causal Path</button>
|
||||
|
||||
{pathResult?.path?.length ? (
|
||||
<PathFlowViz
|
||||
@@ -386,6 +432,14 @@ const inputStyle: CSSProperties = {
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
|
||||
};
|
||||
|
||||
const groupedSelectionNoticeStyle: CSSProperties = {
|
||||
marginTop: 12,
|
||||
padding: "10px 12px",
|
||||
background: "rgba(88,166,255,0.08)",
|
||||
border: "1px solid rgba(88,166,255,0.2)",
|
||||
borderRadius: 12,
|
||||
};
|
||||
|
||||
const actionButtonStyle: CSSProperties = {
|
||||
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
|
||||
color: "#fff",
|
||||
|
||||
@@ -464,6 +464,7 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
|
||||
displayState={displayResult.state}
|
||||
selectedEdgeId=""
|
||||
selectedNodeId={selectedNodeId}
|
||||
focusedNodeId={viewMode === "focused" ? selectedNodeId : ""}
|
||||
activePath={activePath}
|
||||
activePathEdgeIds={EMPTY_PATH}
|
||||
effectsState={STAGE_EFFECTS_STATE}
|
||||
|
||||
@@ -12,8 +12,7 @@ import { useLoadGraph, useReloadGraph } from "./useLoadGraph";
|
||||
import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
|
||||
import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import { resolveDisplayGraph, resolveDisplayStateSnapshot } from "./graphSceneState";
|
||||
import { computeGraphAnalyticsBase } from "./graphAnalytics";
|
||||
import { checkGroupedViewAvailability, resolveDisplayGraph, resolveDisplayStateSnapshot, resolveGroupedDisplayNodeId, resolveGroupedDisplayStateSnapshot } from "./graphSceneState";
|
||||
import {
|
||||
type GraphPlugin,
|
||||
type GraphPluginActionRequest,
|
||||
@@ -34,6 +33,7 @@ import type {
|
||||
GraphLoadProgress,
|
||||
GraphLoadSummary,
|
||||
GraphSelectedEdgeState,
|
||||
GraphSelectedNodeKind,
|
||||
GraphSelectedNodeState,
|
||||
GraphTemporalState,
|
||||
GraphViewMode,
|
||||
@@ -542,6 +542,12 @@ function buildSelectedNodeState(
|
||||
};
|
||||
}
|
||||
|
||||
type FocusResolution = {
|
||||
kind: GraphSelectedNodeKind;
|
||||
resolvedNodeId: string | null;
|
||||
reason: string | null;
|
||||
};
|
||||
|
||||
function buildSelectedEdgeState(
|
||||
edgeId: string,
|
||||
displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
@@ -678,6 +684,8 @@ function collectPluginOverlays(
|
||||
|
||||
export function GraphWorkspace() {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||||
const [focusedNodeId, setFocusedNodeId] = useState("");
|
||||
const [lastGroupedSelectedNodeId, setLastGroupedSelectedNodeId] = useState("");
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState("");
|
||||
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
|
||||
const [graphReady, setGraphReady] = useState(false);
|
||||
@@ -845,16 +853,27 @@ export function GraphWorkspace() {
|
||||
};
|
||||
}, [debouncedTime, isLoading]);
|
||||
|
||||
const resolveNodeIdForFocusedMode = useCallback((nodeId: string): string | null => {
|
||||
const resolveNodeIdForFocusedMode = useCallback((
|
||||
nodeId: string,
|
||||
displayGraphCandidate?: GraphSceneRuntime["displayGraph"] | null,
|
||||
): FocusResolution => {
|
||||
if (!nodeId) {
|
||||
return null;
|
||||
return {
|
||||
kind: "none",
|
||||
resolvedNodeId: null,
|
||||
reason: "Select a node to inspect in Focused mode.",
|
||||
};
|
||||
}
|
||||
|
||||
if (graph.hasNode(nodeId)) {
|
||||
return nodeId;
|
||||
return {
|
||||
kind: "base",
|
||||
resolvedNodeId: nodeId,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
const currentDisplayGraph = pluginRuntimeRef.current?.displayGraph ?? graph;
|
||||
const currentDisplayGraph = displayGraphCandidate ?? pluginRuntimeRef.current?.displayGraph ?? graph;
|
||||
if (currentDisplayGraph.hasNode(nodeId)) {
|
||||
const displayAttrs = currentDisplayGraph.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
const communityGroup = displayAttrs.properties?.__communityGroup as
|
||||
@@ -866,40 +885,131 @@ export function GraphWorkspace() {
|
||||
|
||||
const anchorNodeId = communityGroup?.anchorNodeId || communityGroup?.sampleNodeIds?.[0] || "";
|
||||
if (anchorNodeId && graph.hasNode(anchorNodeId)) {
|
||||
return anchorNodeId;
|
||||
return {
|
||||
kind: "grouped",
|
||||
resolvedNodeId: anchorNodeId,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "grouped",
|
||||
resolvedNodeId: null,
|
||||
reason: "Focused mode is unavailable for this grouped selection.",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
return {
|
||||
kind: "unavailable",
|
||||
resolvedNodeId: null,
|
||||
reason: "Selected item is not available in the current graph.",
|
||||
};
|
||||
}, []);
|
||||
|
||||
const activateFocusedMode = useCallback(() => {
|
||||
const canonicalNodeId = resolveNodeIdForFocusedMode(selectedNodeId);
|
||||
if (!canonicalNodeId) {
|
||||
const focusedSelectionResolution = useMemo(
|
||||
() => resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph),
|
||||
[pluginRuntimeVersion, resolveNodeIdForFocusedMode, selectedNodeId, viewMode],
|
||||
);
|
||||
const inspectableNodeId = focusedSelectionResolution.resolvedNodeId ?? "";
|
||||
const canActivateFocusedMode = Boolean(focusedSelectionResolution.resolvedNodeId);
|
||||
const { available: groupedViewAvailable, reason: groupedViewReason } = useMemo(
|
||||
() => checkGroupedViewAvailability(),
|
||||
[graphVersion],
|
||||
);
|
||||
const groupedDisplayCandidate = useMemo(
|
||||
() => viewMode === "grouped"
|
||||
? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
})
|
||||
: null,
|
||||
[viewMode, aggregationEnabled, collapsedNeighborhoodNodeIds, graphVersion],
|
||||
);
|
||||
|
||||
const requestViewMode = useCallback((nextViewMode: GraphViewMode) => {
|
||||
if (nextViewMode === "focused") {
|
||||
const resolution = resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph);
|
||||
if (!resolution.resolvedNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusedNodeId(resolution.resolvedNodeId);
|
||||
setSelectedNodeId(resolution.resolvedNodeId);
|
||||
setViewMode("focused");
|
||||
setIsLayoutRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (canonicalNodeId !== selectedNodeId) {
|
||||
setSelectedNodeId(canonicalNodeId);
|
||||
if (nextViewMode === "grouped") {
|
||||
if (!groupedViewAvailable) {
|
||||
debugGraphWorkspace("grouped-view-unavailable", {
|
||||
reason: groupedViewReason,
|
||||
graphVersion,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const groupedDisplayGraph = groupedDisplayCandidate?.graph
|
||||
?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
}).graph;
|
||||
const nextGroupedSelection = [
|
||||
lastGroupedSelectedNodeId,
|
||||
selectedNodeId,
|
||||
focusedNodeId,
|
||||
]
|
||||
.map((candidateId) => resolveGroupedDisplayNodeId(groupedDisplayGraph, candidateId))
|
||||
.find((candidateId): candidateId is string => Boolean(candidateId))
|
||||
?? "";
|
||||
|
||||
setFocusedNodeId("");
|
||||
setSelectedNodeId(nextGroupedSelection);
|
||||
if (nextGroupedSelection) {
|
||||
setLastGroupedSelectedNodeId(nextGroupedSelection);
|
||||
}
|
||||
setViewMode("grouped");
|
||||
setIsLayoutRunning(true);
|
||||
return;
|
||||
}
|
||||
setViewMode("focused");
|
||||
}, [resolveNodeIdForFocusedMode, selectedNodeId]);
|
||||
const [canActivateFocusedMode, setCanActivateFocusedMode] = useState(false);
|
||||
useEffect(() => {
|
||||
setCanActivateFocusedMode(Boolean(resolveNodeIdForFocusedMode(selectedNodeId)));
|
||||
}, [pluginRuntimeVersion, resolveNodeIdForFocusedMode, selectedNodeId]);
|
||||
|
||||
setFocusedNodeId("");
|
||||
setSelectedNodeId((currentSelectedNodeId) => (
|
||||
currentSelectedNodeId && graph.hasNode(currentSelectedNodeId) ? currentSelectedNodeId : ""
|
||||
));
|
||||
setViewMode("full");
|
||||
}, [
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
focusedNodeId,
|
||||
graphVersion,
|
||||
groupedDisplayCandidate,
|
||||
groupedViewAvailable,
|
||||
groupedViewReason,
|
||||
lastGroupedSelectedNodeId,
|
||||
resolveNodeIdForFocusedMode,
|
||||
selectedNodeId,
|
||||
]);
|
||||
|
||||
const focusNode = useCallback((nodeId: string) => {
|
||||
if (!nodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedNodeId(nodeId);
|
||||
const currentDisplayGraph = pluginRuntimeRef.current?.displayGraph ?? graph;
|
||||
const nextSelectedNodeId = nodeId;
|
||||
|
||||
if (!graph.hasNode(nodeId) && currentDisplayGraph.hasNode(nodeId)) {
|
||||
setLastGroupedSelectedNodeId(nodeId);
|
||||
}
|
||||
|
||||
setSelectedNodeId(nextSelectedNodeId);
|
||||
setSelectedEdgeId("");
|
||||
setPathResult(null);
|
||||
setSearchResults([]);
|
||||
setSearchError("");
|
||||
if (viewMode === "focused") {
|
||||
if (viewMode === "focused" && graph.hasNode(nextSelectedNodeId)) {
|
||||
setFocusedNodeId(nextSelectedNodeId);
|
||||
setIsLayoutRunning(false);
|
||||
}
|
||||
}, [viewMode]);
|
||||
@@ -931,14 +1041,14 @@ export function GraphWorkspace() {
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleRunPredictions = useCallback(async () => {
|
||||
if (!selectedNodeId) return;
|
||||
if (!inspectableNodeId) return;
|
||||
setIsRunningPredictions(true);
|
||||
try {
|
||||
const response = await fetch("/api/enrich/links", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
node_id: selectedNodeId,
|
||||
node_id: inspectableNodeId,
|
||||
top_n: 6,
|
||||
candidate_type: predictionType || undefined,
|
||||
min_score: 0,
|
||||
@@ -955,13 +1065,13 @@ export function GraphWorkspace() {
|
||||
} finally {
|
||||
setIsRunningPredictions(false);
|
||||
}
|
||||
}, [predictionType, selectedNodeId]);
|
||||
}, [inspectableNodeId, predictionType]);
|
||||
|
||||
const handleTracePath = useCallback(async () => {
|
||||
if (!selectedNodeId || !pathTargetId.trim()) return;
|
||||
if (!inspectableNodeId || !pathTargetId.trim()) return;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`
|
||||
`/api/graph/node/${encodeURIComponent(inspectableNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Path lookup failed with status ${response.status}`);
|
||||
@@ -978,12 +1088,12 @@ export function GraphWorkspace() {
|
||||
console.error("[GraphWorkspace] path trace failed", pathError);
|
||||
setPathResult(null);
|
||||
}
|
||||
}, [pathTargetId, selectedNodeId]);
|
||||
}, [inspectableNodeId, pathTargetId]);
|
||||
|
||||
const handleDownloadProvenance = useCallback(async (format: "json" | "markdown") => {
|
||||
if (!selectedNodeId) return;
|
||||
if (!inspectableNodeId) return;
|
||||
const suffix = format === "markdown" ? "markdown" : "json";
|
||||
const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(selectedNodeId)}&format=${suffix}`);
|
||||
const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(inspectableNodeId)}&format=${suffix}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Provenance report failed with status ${response.status}`);
|
||||
}
|
||||
@@ -991,12 +1101,12 @@ export function GraphWorkspace() {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${selectedNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
|
||||
anchor.download = `${inspectableNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(anchor);
|
||||
}, [selectedNodeId]);
|
||||
}, [inspectableNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
@@ -1050,6 +1160,8 @@ export function GraphWorkspace() {
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedNeighborhoodNodeIds([]);
|
||||
setFocusedNodeId("");
|
||||
setLastGroupedSelectedNodeId("");
|
||||
}, [summary?.edgeCount, summary?.nodeCount]);
|
||||
|
||||
const showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress));
|
||||
@@ -1058,31 +1170,32 @@ export function GraphWorkspace() {
|
||||
const activePath = pathResult?.path ?? EMPTY_PATH;
|
||||
const activePathEdgeIds = pathResult?.edge_ids ?? EMPTY_PATH;
|
||||
const structuralSelectedNodeId = useMemo(() => {
|
||||
if (viewMode === "focused") {
|
||||
return focusedNodeId && graph.hasNode(focusedNodeId) ? focusedNodeId : "";
|
||||
}
|
||||
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
||||
return "";
|
||||
}
|
||||
if (viewMode === "focused") {
|
||||
return selectedNodeId;
|
||||
}
|
||||
return collapsedNeighborhoodNodeIds.includes(selectedNodeId) ? selectedNodeId : "";
|
||||
}, [collapsedNeighborhoodNodeIds, selectedNodeId, viewMode]);
|
||||
}, [collapsedNeighborhoodNodeIds, focusedNodeId, selectedNodeId, viewMode]);
|
||||
const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH;
|
||||
const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH;
|
||||
const groupedViewAvailable = useMemo(
|
||||
() => computeGraphAnalyticsBase(graph, { computeCommunities: true, computeCentrality: false }).communitiesByNode.size > 0,
|
||||
[graphVersion],
|
||||
);
|
||||
const displayResult = useMemo(
|
||||
() => resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
groupedViewAvailable,
|
||||
}),
|
||||
() => (
|
||||
viewMode === "grouped"
|
||||
? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
}))
|
||||
: resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
})
|
||||
),
|
||||
[
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
graphVersion,
|
||||
groupedViewAvailable,
|
||||
groupedDisplayCandidate,
|
||||
structuralActivePath,
|
||||
structuralActivePathEdgeIds,
|
||||
structuralSelectedNodeId,
|
||||
@@ -1090,14 +1203,59 @@ export function GraphWorkspace() {
|
||||
],
|
||||
);
|
||||
const displayState = useMemo(
|
||||
() => resolveDisplayStateSnapshot(selectedNodeId, activePath, viewMode, {
|
||||
() => (
|
||||
viewMode === "grouped"
|
||||
? resolveGroupedDisplayStateSnapshot(displayResult.graph, selectedNodeId, {
|
||||
groupedViewAvailable,
|
||||
groupedViewReason,
|
||||
selectedNodeKind: focusedSelectionResolution.kind,
|
||||
resolvedFocusedNodeId: focusedSelectionResolution.resolvedNodeId,
|
||||
focusedUnavailableReason: focusedSelectionResolution.reason,
|
||||
})
|
||||
: resolveDisplayStateSnapshot(selectedNodeId, activePath, viewMode, {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
groupedViewAvailable,
|
||||
groupedViewReason,
|
||||
selectedNodeKind: focusedSelectionResolution.kind,
|
||||
resolvedFocusedNodeId: focusedSelectionResolution.resolvedNodeId,
|
||||
focusedUnavailableReason: focusedSelectionResolution.reason,
|
||||
})
|
||||
),
|
||||
[
|
||||
activePath,
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
displayResult.graph,
|
||||
focusedSelectionResolution.kind,
|
||||
focusedSelectionResolution.reason,
|
||||
focusedSelectionResolution.resolvedNodeId,
|
||||
groupedViewAvailable,
|
||||
}),
|
||||
[activePath, aggregationEnabled, collapsedNeighborhoodNodeIds, groupedViewAvailable, selectedNodeId, viewMode],
|
||||
groupedViewReason,
|
||||
selectedNodeId,
|
||||
viewMode,
|
||||
],
|
||||
);
|
||||
const displayMeta = displayResult.meta;
|
||||
useEffect(() => {
|
||||
if (viewMode === "grouped" && !groupedViewAvailable) {
|
||||
debugGraphWorkspace("grouped-view-reset-to-full", {
|
||||
reason: groupedViewReason,
|
||||
graphVersion,
|
||||
});
|
||||
setViewMode("full");
|
||||
setFocusedNodeId("");
|
||||
setSelectedNodeId((currentSelectedNodeId) => (
|
||||
currentSelectedNodeId && graph.hasNode(currentSelectedNodeId) ? currentSelectedNodeId : ""
|
||||
));
|
||||
}
|
||||
}, [graphVersion, groupedViewAvailable, groupedViewReason, viewMode]);
|
||||
useEffect(() => {
|
||||
if (viewMode === "focused" && (!focusedNodeId || !graph.hasNode(focusedNodeId))) {
|
||||
setViewMode("full");
|
||||
setFocusedNodeId("");
|
||||
}
|
||||
}, [focusedNodeId, graphVersion, viewMode]);
|
||||
const previousDisplayGraphRef = useRef(displayResult.graph);
|
||||
const previousDisplayStateRef = useRef(displayState);
|
||||
useEffect(() => {
|
||||
@@ -1128,7 +1286,7 @@ export function GraphWorkspace() {
|
||||
if (viewMode === "grouped") {
|
||||
return displayState.groupedViewAvailable
|
||||
? "Communities compressed into grouped structure view"
|
||||
: "Grouped view is unavailable for the current graph";
|
||||
: (displayState.groupedViewReason ?? "Grouped view is unavailable for the current graph");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1262,11 +1420,7 @@ export function GraphWorkspace() {
|
||||
focusNode(action.nodeId);
|
||||
return;
|
||||
case "setViewMode":
|
||||
if (action.viewMode === "focused") {
|
||||
activateFocusedMode();
|
||||
return;
|
||||
}
|
||||
setViewMode(action.viewMode);
|
||||
requestViewMode(action.viewMode);
|
||||
return;
|
||||
case "collapseNeighborhood":
|
||||
if (!selectedNodeId) {
|
||||
@@ -1318,7 +1472,7 @@ export function GraphWorkspace() {
|
||||
setActiveDockPanelId((previous) => (previous === action.panelId ? null : previous));
|
||||
return;
|
||||
}
|
||||
}, [activateFocusedMode, focusNode, selectedNodeId, setEffectToggle]);
|
||||
}, [focusNode, requestViewMode, selectedNodeId, setEffectToggle]);
|
||||
|
||||
const diagnosticsSnapshot = useMemo<GraphDiagnosticsSnapshot | null>(() => {
|
||||
if (!GRAPH_THEME.effects.diagnostics.enabledInDev || !graphDiagnosticsState) {
|
||||
@@ -1497,25 +1651,27 @@ export function GraphWorkspace() {
|
||||
label: "Full Graph",
|
||||
title: "Return to the full graph context",
|
||||
active: viewMode === "full",
|
||||
onClick: () => setViewMode("full"),
|
||||
onClick: () => requestViewMode("full"),
|
||||
},
|
||||
{
|
||||
id: "view-grouped",
|
||||
label: "Grouped View",
|
||||
title: displayState.groupedViewAvailable
|
||||
? "Compress dense structure into detected communities"
|
||||
: "Grouped view is unavailable until communities can be detected",
|
||||
: (displayState.groupedViewReason ?? "Grouped view is unavailable until communities can be detected"),
|
||||
active: viewMode === "grouped",
|
||||
disabled: !displayState.groupedViewAvailable,
|
||||
onClick: () => setViewMode("grouped"),
|
||||
onClick: () => requestViewMode("grouped"),
|
||||
},
|
||||
{
|
||||
id: "view-focused",
|
||||
label: "Focused",
|
||||
title: "Inspect the selected node in a focused local graph",
|
||||
title: canActivateFocusedMode
|
||||
? "Inspect the selected node in a focused local graph"
|
||||
: (focusedSelectionResolution.reason ?? "Focused mode is unavailable for the current selection"),
|
||||
active: viewMode === "focused",
|
||||
disabled: viewMode !== "focused" && !canActivateFocusedMode,
|
||||
onClick: activateFocusedMode,
|
||||
onClick: () => requestViewMode("focused"),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1605,15 +1761,18 @@ export function GraphWorkspace() {
|
||||
|
||||
return groups;
|
||||
}, [
|
||||
activateFocusedMode,
|
||||
canActivateFocusedMode,
|
||||
displayState.groupedViewAvailable,
|
||||
displayState.groupedViewReason,
|
||||
handlePluginAction,
|
||||
hasGraphContent,
|
||||
isLayoutRunning,
|
||||
pluginToolbarItems,
|
||||
reload,
|
||||
requestViewMode,
|
||||
searchQuery,
|
||||
canActivateFocusedMode,
|
||||
focusedSelectionResolution.reason,
|
||||
selectedNodeId,
|
||||
selectedNodeState,
|
||||
showLoadingOverlay,
|
||||
@@ -1629,6 +1788,7 @@ export function GraphWorkspace() {
|
||||
displayMeta,
|
||||
displayState,
|
||||
selectedNodeId,
|
||||
focusedNodeId,
|
||||
selectedEdgeId,
|
||||
activePath,
|
||||
activePathEdgeIds,
|
||||
@@ -1882,6 +2042,10 @@ export function GraphWorkspace() {
|
||||
<Suspense fallback={<div style={inspectorFallbackStyle}>Loading inspector…</div>}>
|
||||
<LazyGraphInspectorPanel
|
||||
nodeId={selectedNodeId}
|
||||
inspectableNodeId={inspectableNodeId || null}
|
||||
selectedNodeKind={displayState.selectedNodeKind}
|
||||
canActivateFocused={canActivateFocusedMode}
|
||||
focusedUnavailableReason={displayState.focusedUnavailableReason}
|
||||
predictions={predictions}
|
||||
predictionType={predictionType}
|
||||
onPredictionTypeChange={setPredictionType}
|
||||
|
||||
@@ -563,6 +563,19 @@ export function GraphWorkspaceShell() {
|
||||
return `${visibleSelectedNode.neighborCount} direct neighbors highlighted`;
|
||||
}, [viewMode, visibleSelectedNode]);
|
||||
|
||||
const requestViewMode = useCallback((nextViewMode: GraphViewMode) => {
|
||||
if (nextViewMode === "focused") {
|
||||
if (!selectedNodeId) {
|
||||
return;
|
||||
}
|
||||
setViewMode("focused");
|
||||
setIsLayoutRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setViewMode("full");
|
||||
}, [selectedNodeId]);
|
||||
|
||||
const showLoadingOverlay =
|
||||
isLoading
|
||||
|| isFetching
|
||||
@@ -639,8 +652,8 @@ export function GraphWorkspaceShell() {
|
||||
<div className="graph-toggle-cluster">
|
||||
{selectedNodeId ? (
|
||||
<>
|
||||
<button onClick={() => setViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
|
||||
<button onClick={() => setViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
|
||||
<button onClick={() => requestViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
|
||||
<button onClick={() => requestViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: "#7f95b3", fontSize: 12 }}>Select a node to switch graph views</span>
|
||||
|
||||
@@ -15,6 +15,11 @@ export const focusCameraBehavior: GraphBehavior = {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action.type === "centerGroupedSelection") {
|
||||
context.centerGroupedSelectionInView(action.nodeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,22 +2,35 @@ import type { GraphBehavior } from "./types";
|
||||
|
||||
export function createSearchFocusBehavior(): GraphBehavior {
|
||||
let lastSelectedNodeId = "";
|
||||
let lastViewMode = "";
|
||||
|
||||
return {
|
||||
id: "search-focus",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
lastSelectedNodeId = "";
|
||||
lastViewMode = "";
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
const nextSelectedNodeId = interactionState.selectedNodeId;
|
||||
const nextViewMode = interactionState.viewMode;
|
||||
if (nextViewMode !== lastViewMode) {
|
||||
lastViewMode = nextViewMode;
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
return;
|
||||
}
|
||||
if (!nextSelectedNodeId || nextSelectedNodeId === lastSelectedNodeId) {
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
lastViewMode = nextViewMode;
|
||||
return;
|
||||
}
|
||||
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
context.dispatchAction({ type: "centerSelection", nodeId: nextSelectedNodeId });
|
||||
lastViewMode = nextViewMode;
|
||||
context.dispatchAction({
|
||||
type: nextViewMode === "grouped" ? "centerGroupedSelection" : "centerSelection",
|
||||
nodeId: nextSelectedNodeId,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { GraphCameraState, GraphInteractionState } from "../types";
|
||||
export type GraphBehaviorActionRequest =
|
||||
| { type: "fitView" }
|
||||
| { type: "focusNode"; nodeId: string }
|
||||
| { type: "centerSelection"; nodeId: string };
|
||||
| { type: "centerSelection"; nodeId: string }
|
||||
| { type: "centerGroupedSelection"; nodeId: string };
|
||||
|
||||
export interface GraphBehaviorContext {
|
||||
sigma: Sigma;
|
||||
@@ -19,6 +20,7 @@ export interface GraphBehaviorContext {
|
||||
onEdgeSelectionChange: (edgeId: string) => void;
|
||||
focusNodeInView: (nodeId: string) => void;
|
||||
centerSelectionInView: (nodeId: string) => void;
|
||||
centerGroupedSelectionInView: (nodeId: string) => void;
|
||||
fitCurrentView: () => void;
|
||||
dispatchAction: (action: GraphBehaviorActionRequest) => void;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ export function createViewModeSwitchBehavior(): GraphBehavior {
|
||||
}
|
||||
|
||||
lastViewMode = interactionState.viewMode;
|
||||
const nextSelectedNodeId = interactionState.selectedNodeId;
|
||||
const nextFocusedNodeId = interactionState.focusedNodeId;
|
||||
|
||||
if (interactionState.viewMode === "focused" && nextSelectedNodeId) {
|
||||
context.dispatchAction({ type: "focusNode", nodeId: nextSelectedNodeId });
|
||||
if (interactionState.viewMode === "focused" && nextFocusedNodeId) {
|
||||
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,18 @@ import {
|
||||
type GraphNodeVisualState,
|
||||
type GraphTheme,
|
||||
type GraphZoomTier,
|
||||
hashString,
|
||||
withAlpha,
|
||||
zoomTierAtLeast,
|
||||
} from "./graphTheme";
|
||||
import { computeGraphAnalyticsBase } from "./graphAnalytics";
|
||||
import type { GraphDisplayMeta, GraphDisplayStateSnapshot, GraphInteractionState, GraphViewMode } from "./types";
|
||||
import type {
|
||||
GraphDisplayMeta,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphInteractionState,
|
||||
GraphSelectedNodeKind,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
|
||||
const MAX_FOCUS_NEIGHBORS = GRAPH_THEME.focus.maxNeighbors;
|
||||
const FOCUS_RING_CAPACITY = GRAPH_THEME.focus.ringCapacity;
|
||||
@@ -28,6 +35,7 @@ const COLLAPSE_VISIBLE_NEIGHBORS = 8;
|
||||
const GROUP_SAMPLE_MEMBERS = 8;
|
||||
const AGGREGATED_EDGE_PREFIX = "__agg__:";
|
||||
const COMMUNITY_NODE_PREFIX = "__community__:";
|
||||
const DEBUG_GRAPH_SCENE_STATE = typeof import.meta !== "undefined" && import.meta.env?.DEV === true;
|
||||
|
||||
type GraphRef = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
|
||||
@@ -140,12 +148,185 @@ function createEmptyDisplayState(
|
||||
return {
|
||||
aggregationEnabled,
|
||||
groupedViewAvailable: false,
|
||||
groupedViewReason: null,
|
||||
selectedRootNodeId: selectedNodeId || null,
|
||||
selectedVisibleNeighborIds: [],
|
||||
selectedCollapsedNeighborIds: [],
|
||||
selectedNodeKind: selectedNodeId ? "unavailable" : "none",
|
||||
canActivateFocused: false,
|
||||
resolvedFocusedNodeId: null,
|
||||
focusedUnavailableReason: selectedNodeId ? "Selected item is not available in the current graph." : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGroupedDisplayNodeId(
|
||||
displayGraph: GraphRef,
|
||||
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;
|
||||
}
|
||||
|
||||
export function checkGroupedViewAvailability(): { available: boolean; reason: string | null } {
|
||||
const base = computeGraphAnalyticsBase(graph, {
|
||||
computeCommunities: true,
|
||||
computeCentrality: false,
|
||||
});
|
||||
const available = base.communitiesByNode.size > 0;
|
||||
return {
|
||||
available,
|
||||
reason: available ? null : "Grouped view is unavailable until communities can be detected.",
|
||||
};
|
||||
}
|
||||
|
||||
function rankGroupedNeighbors(
|
||||
displayGraph: GraphRef,
|
||||
nodeId: string,
|
||||
): string[] {
|
||||
const resolvedNodeId = resolveGroupedDisplayNodeId(displayGraph, nodeId);
|
||||
if (!resolvedNodeId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const scoredNeighbors = new Map<string, number>();
|
||||
displayGraph.forEachEdge((_, attrs, source, target) => {
|
||||
const sourceId = String(source);
|
||||
const targetId = String(target);
|
||||
if (sourceId !== resolvedNodeId && targetId !== resolvedNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const neighborId = sourceId === resolvedNodeId ? targetId : sourceId;
|
||||
if (!neighborId || neighborId === resolvedNodeId || !displayGraph.hasNode(neighborId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const weightCandidate = Number(
|
||||
(attrs as EdgeAttributes).aggregateCount
|
||||
?? (attrs as EdgeAttributes).baseSize
|
||||
?? (attrs as EdgeAttributes).size
|
||||
?? (attrs as EdgeAttributes).weight
|
||||
?? 1,
|
||||
);
|
||||
const weight = Number.isFinite(weightCandidate) && weightCandidate > 0 ? weightCandidate : 1;
|
||||
scoredNeighbors.set(neighborId, (scoredNeighbors.get(neighborId) ?? 0) + weight);
|
||||
});
|
||||
|
||||
return Array.from(scoredNeighbors.entries())
|
||||
.sort((left, right) => {
|
||||
if (right[1] !== left[1]) {
|
||||
return right[1] - left[1];
|
||||
}
|
||||
return left[0].localeCompare(right[0]);
|
||||
})
|
||||
.map(([neighborId]) => neighborId);
|
||||
}
|
||||
|
||||
export function resolveGroupedDisplayStateSnapshot(
|
||||
displayGraph: GraphRef,
|
||||
selectedNodeId: string,
|
||||
options?: {
|
||||
groupedViewAvailable?: boolean;
|
||||
groupedViewReason?: string | null;
|
||||
selectedNodeKind?: GraphSelectedNodeKind;
|
||||
resolvedFocusedNodeId?: string | null;
|
||||
focusedUnavailableReason?: string | null;
|
||||
},
|
||||
): GraphDisplayStateSnapshot {
|
||||
const displayState = createEmptyDisplayState(selectedNodeId, true);
|
||||
displayState.groupedViewAvailable = options?.groupedViewAvailable ?? true;
|
||||
displayState.groupedViewReason = options?.groupedViewReason ?? null;
|
||||
displayState.selectedNodeKind = options?.selectedNodeKind ?? (selectedNodeId ? "grouped" : "none");
|
||||
displayState.resolvedFocusedNodeId = options?.resolvedFocusedNodeId ?? null;
|
||||
displayState.canActivateFocused = Boolean(displayState.resolvedFocusedNodeId);
|
||||
displayState.focusedUnavailableReason = displayState.canActivateFocused
|
||||
? null
|
||||
: (options?.focusedUnavailableReason ?? displayState.focusedUnavailableReason);
|
||||
|
||||
if (!selectedNodeId) {
|
||||
return displayState;
|
||||
}
|
||||
|
||||
const resolvedNodeId = resolveGroupedDisplayNodeId(displayGraph, selectedNodeId);
|
||||
if (!resolvedNodeId) {
|
||||
return displayState;
|
||||
}
|
||||
|
||||
const rankedNeighbors = rankGroupedNeighbors(displayGraph, resolvedNodeId);
|
||||
displayState.selectedRootNodeId = resolvedNodeId;
|
||||
displayState.selectedVisibleNeighborIds = rankedNeighbors.slice(0, MAX_FOCUS_NEIGHBORS);
|
||||
displayState.selectedCollapsedNeighborIds = rankedNeighbors.slice(MAX_FOCUS_NEIGHBORS);
|
||||
return displayState;
|
||||
}
|
||||
|
||||
function validateGroupedDisplayGraph(grouped: Graph<NodeAttributes, EdgeAttributes>): string | null {
|
||||
if (grouped.order === 0) {
|
||||
return "Grouped view is unavailable because no community nodes could be created.";
|
||||
}
|
||||
|
||||
let invalidReason: string | null = null;
|
||||
grouped.forEachNode((nodeId, attrs) => {
|
||||
if (invalidReason) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeAttrs = attrs as NodeAttributes;
|
||||
if (!Number.isFinite(Number(nodeAttrs.x)) || !Number.isFinite(Number(nodeAttrs.y))) {
|
||||
invalidReason = `Grouped node ${nodeId} has invalid coordinates.`;
|
||||
}
|
||||
});
|
||||
|
||||
if (invalidReason) {
|
||||
return invalidReason;
|
||||
}
|
||||
|
||||
grouped.forEachEdge((edgeId, _attrs, sourceId, targetId) => {
|
||||
if (invalidReason) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!grouped.hasNode(sourceId) || !grouped.hasNode(targetId)) {
|
||||
invalidReason = `Grouped edge ${edgeId} references a missing grouped node.`;
|
||||
}
|
||||
});
|
||||
|
||||
return invalidReason;
|
||||
}
|
||||
|
||||
export function buildPathEdgeSet(
|
||||
graphRef: GraphRef,
|
||||
path: string[],
|
||||
@@ -229,7 +410,7 @@ function collectImpactedNodeIds(
|
||||
const impacted = new Set<string>(interactionState.activePath);
|
||||
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
||||
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
|
||||
buildFocusSet(primaryNodeId).forEach((nodeId) => impacted.add(nodeId));
|
||||
buildFocusSetInGraph(graphRef, primaryNodeId).forEach((nodeId) => impacted.add(nodeId));
|
||||
}
|
||||
|
||||
buildEdgeEndpointSet(graphRef, interactionState.selectedEdgeId)
|
||||
@@ -249,7 +430,7 @@ function collectImpactedEdgeKeys(
|
||||
const impacted = new Set<string>(buildPathEdgeSet(graphRef, interactionState.activePath, interactionState.activePathEdgeIds));
|
||||
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
||||
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
|
||||
collectFocusEdgeIds(graphRef, buildFocusSet(primaryNodeId)).forEach((edgeId) => impacted.add(edgeId));
|
||||
collectFocusEdgeIds(graphRef, buildFocusSetInGraph(graphRef, primaryNodeId)).forEach((edgeId) => impacted.add(edgeId));
|
||||
}
|
||||
|
||||
if (interactionState.selectedEdgeId) {
|
||||
@@ -299,27 +480,48 @@ export function collectInteractionRefreshTargets(
|
||||
};
|
||||
}
|
||||
|
||||
export function getEdgeWeightBetween(source: string, target: string): number {
|
||||
function logGroupedGraphSkip(kind: "focus" | "neighbors", graphRef: GraphRef, nodeId: string) {
|
||||
if (!DEBUG_GRAPH_SCENE_STATE || !nodeId.startsWith(COMMUNITY_NODE_PREFIX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug("[graphSceneState]", `${kind}-skipped-missing-display-node`, {
|
||||
nodeId,
|
||||
order: graphRef.order,
|
||||
size: graphRef.size,
|
||||
});
|
||||
}
|
||||
|
||||
export function getEdgeWeightBetweenInGraph(graphRef: GraphRef, source: string, target: string): number {
|
||||
let weight = 0;
|
||||
|
||||
forEachDirectedEdgeBetween(graph, source, target, (_edgeId, attrs) => {
|
||||
forEachDirectedEdgeBetween(graphRef, source, target, (_edgeId, attrs) => {
|
||||
weight = Math.max(weight, Number(attrs?.weight ?? 0));
|
||||
});
|
||||
|
||||
forEachDirectedEdgeBetween(graph, target, source, (_edgeId, attrs) => {
|
||||
forEachDirectedEdgeBetween(graphRef, target, source, (_edgeId, attrs) => {
|
||||
weight = Math.max(weight, Number(attrs?.weight ?? 0));
|
||||
});
|
||||
|
||||
return weight;
|
||||
}
|
||||
|
||||
export function rankNeighbors(nodeId: string): string[] {
|
||||
return graph
|
||||
export function getEdgeWeightBetween(source: string, target: string): number {
|
||||
return getEdgeWeightBetweenInGraph(graph, source, target);
|
||||
}
|
||||
|
||||
export function rankNeighborsInGraph(graphRef: GraphRef, nodeId: string): string[] {
|
||||
if (!nodeId || !graphRef.hasNode(nodeId)) {
|
||||
logGroupedGraphSkip("neighbors", graphRef, nodeId);
|
||||
return [];
|
||||
}
|
||||
|
||||
return graphRef
|
||||
.neighbors(nodeId)
|
||||
.map((neighborId) => ({
|
||||
id: neighborId,
|
||||
weight: getEdgeWeightBetween(nodeId, neighborId),
|
||||
degree: graph.degree(neighborId),
|
||||
weight: getEdgeWeightBetweenInGraph(graphRef, nodeId, neighborId),
|
||||
degree: graphRef.hasNode(neighborId) ? graphRef.degree(neighborId) : 0,
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (right.weight !== left.weight) {
|
||||
@@ -333,11 +535,24 @@ export function rankNeighbors(nodeId: string): string[] {
|
||||
.map((item) => item.id);
|
||||
}
|
||||
|
||||
export function buildFocusSet(nodeId: string): Set<string> {
|
||||
const ranked = rankNeighbors(nodeId).slice(0, MAX_FOCUS_NEIGHBORS);
|
||||
export function rankNeighbors(nodeId: string): string[] {
|
||||
return rankNeighborsInGraph(graph, nodeId);
|
||||
}
|
||||
|
||||
export function buildFocusSetInGraph(graphRef: GraphRef, nodeId: string): Set<string> {
|
||||
if (!nodeId || !graphRef.hasNode(nodeId)) {
|
||||
logGroupedGraphSkip("focus", graphRef, nodeId);
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const ranked = rankNeighborsInGraph(graphRef, nodeId).slice(0, MAX_FOCUS_NEIGHBORS);
|
||||
return new Set<string>([nodeId, ...ranked]);
|
||||
}
|
||||
|
||||
export function buildFocusSet(nodeId: string): Set<string> {
|
||||
return buildFocusSetInGraph(graph, nodeId);
|
||||
}
|
||||
|
||||
export function isEdgeInteractable(
|
||||
graphRef: GraphRef,
|
||||
interactionState: GraphInteractionState,
|
||||
@@ -361,7 +576,7 @@ export function isEdgeInteractable(
|
||||
return true;
|
||||
}
|
||||
|
||||
const focusIds = buildFocusSet(primaryNodeId);
|
||||
const focusIds = buildFocusSetInGraph(graphRef, primaryNodeId);
|
||||
return focusIds.has(source) && focusIds.has(target);
|
||||
}
|
||||
|
||||
@@ -381,6 +596,7 @@ function resolveNodeColor(
|
||||
fallbackColor?: string,
|
||||
) {
|
||||
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
|
||||
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
|
||||
const overviewTint = state === "neighbor"
|
||||
? theme.palette.overview.nodeTintMix + 0.09
|
||||
: theme.palette.overview.nodeTintMix;
|
||||
@@ -406,9 +622,16 @@ function resolveNodeColor(
|
||||
if (zoomTier === "overview") {
|
||||
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
|
||||
const boostedCore = blendHex(overviewCore, semanticColor, 0.3 + 0.26 * presenceBoost);
|
||||
return withAlpha(boostedCore, Math.min(0.98, theme.palette.overview.nodeCoreAlpha + presenceBoost * 0.18));
|
||||
const overviewAlpha = Math.min(
|
||||
0.98,
|
||||
theme.palette.overview.nodeCoreAlpha + presenceBoost * 0.18,
|
||||
);
|
||||
return withAlpha(
|
||||
boostedCore,
|
||||
isCommunityGroup ? Math.min(overviewAlpha, theme.grouped.style.fillAlpha) : overviewAlpha,
|
||||
);
|
||||
}
|
||||
return semanticColor;
|
||||
return isCommunityGroup ? withAlpha(semanticColor, theme.grouped.style.fillAlpha) : semanticColor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,6 +644,7 @@ function resolveNodeShellColor(
|
||||
fallbackColor?: string,
|
||||
) {
|
||||
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
|
||||
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
|
||||
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
|
||||
const overviewShell = blendHex(
|
||||
theme.palette.overview.nodeBase,
|
||||
@@ -429,7 +653,10 @@ function resolveNodeShellColor(
|
||||
);
|
||||
|
||||
if (zoomTier !== "overview") {
|
||||
return withAlpha(blendHex(theme.palette.overview.nodeBase, semanticColor, 0.26), 0.95);
|
||||
return withAlpha(
|
||||
blendHex(theme.palette.overview.nodeBase, semanticColor, 0.26),
|
||||
isCommunityGroup ? theme.grouped.style.shellAlpha : 0.95,
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "selected") {
|
||||
@@ -448,7 +675,10 @@ function resolveNodeShellColor(
|
||||
return withAlpha(theme.palette.overview.nodeMuted, 0.22);
|
||||
}
|
||||
|
||||
return withAlpha(overviewShell, theme.palette.overview.nodeShellAlpha);
|
||||
return withAlpha(
|
||||
overviewShell,
|
||||
isCommunityGroup ? theme.grouped.style.shellAlpha : theme.palette.overview.nodeShellAlpha,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNodeCoreScale(
|
||||
@@ -664,6 +894,10 @@ export function resolveEdgeVariant(state: GraphEdgeVisualState, attrs: EdgeAttri
|
||||
return "pathSignal";
|
||||
}
|
||||
|
||||
if (attrs.bundleKind === "community") {
|
||||
return "line";
|
||||
}
|
||||
|
||||
if ((attrs.parallelCount ?? 1) > 1) {
|
||||
return "parallelCurve";
|
||||
}
|
||||
@@ -767,11 +1001,14 @@ export function resolveNodeElementStyle(
|
||||
const stateConfig = theme.nodes.states[state];
|
||||
const nodeVariant = resolveNodeVariant(state, attrs);
|
||||
const variantConfig = theme.nodes.variants[nodeVariant];
|
||||
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
|
||||
const baseSize = Number(attrs.baseSize || attrs.size || 4);
|
||||
const labelPriority = Number(attrs.labelPriority ?? 0);
|
||||
const color = resolveNodeColor(theme, zoomTier, state, attrs, cameraRatio, attrs.color);
|
||||
const shellColor = resolveNodeShellColor(theme, zoomTier, state, attrs, cameraRatio, attrs.color);
|
||||
const sizeMultiplier = (state === "default" ? tierConfig.nodeScale : stateConfig.sizeMultiplier) * variantConfig.sizeMultiplier;
|
||||
const sizeMultiplier = (state === "default" ? tierConfig.nodeScale : stateConfig.sizeMultiplier)
|
||||
* variantConfig.sizeMultiplier
|
||||
* (isCommunityGroup ? theme.grouped.style.nodeSizeScale : 1);
|
||||
const overviewPresence = zoomTier === "overview" ? 1 + getOverviewPresenceBoost(cameraRatio) * 1.2 : 1;
|
||||
const forceLabel = shouldForceNodeLabel(theme, zoomTier, state, attrs, labelPriority);
|
||||
const badgeKind = attrs.badgeKind || variantConfig.badgeKind;
|
||||
@@ -804,7 +1041,12 @@ export function resolveNodeElementStyle(
|
||||
borderColor: resolveNodeBorderColor(theme, zoomTier, state, nodeVariant, attrs, color),
|
||||
borderSize: Math.max(
|
||||
0.4,
|
||||
Number(attrs.borderSize ?? 0.85) + strokeBase + stateConfig.borderBoost + variantConfig.borderBoost - 0.8,
|
||||
Number(attrs.borderSize ?? 0.85)
|
||||
+ strokeBase
|
||||
+ stateConfig.borderBoost
|
||||
+ variantConfig.borderBoost
|
||||
+ (isCommunityGroup ? theme.grouped.style.nodeBorderBoost : 0)
|
||||
- 0.8,
|
||||
),
|
||||
nodeVariant,
|
||||
badgeKind,
|
||||
@@ -814,7 +1056,10 @@ export function resolveNodeElementStyle(
|
||||
ringColor,
|
||||
ringSize,
|
||||
showHalo,
|
||||
haloColor: attrs.haloColor || attrs.glowColor || withAlpha(color, theme.overlays.hoverGlowAlpha + variantConfig.haloBoost),
|
||||
haloColor: attrs.haloColor || attrs.glowColor || withAlpha(
|
||||
color,
|
||||
(isCommunityGroup ? theme.grouped.style.glowAlpha : theme.overlays.hoverGlowAlpha) + variantConfig.haloBoost,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -885,6 +1130,7 @@ export function resolveEdgeElementStyle(
|
||||
const stateConfig = theme.edges.states[state];
|
||||
const edgeVariant = resolveEdgeVariant(state, attrs);
|
||||
const variantConfig = theme.edges.variants[edgeVariant];
|
||||
const isCommunityBundle = attrs.bundleKind === "community";
|
||||
const baseSize = Number(attrs.baseSize || attrs.size || 0.9);
|
||||
const visualPriority = Number(attrs.visualPriority ?? 0);
|
||||
const belowPriorityThreshold = state === "default"
|
||||
@@ -920,8 +1166,13 @@ export function resolveEdgeElementStyle(
|
||||
type: useCurvedRenderer
|
||||
? (straightType === "arrow" ? "curvedArrow" : "curve")
|
||||
: straightType,
|
||||
color: resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color),
|
||||
size: Math.max(baseSize * sizeMultiplier, stateConfig.minSize),
|
||||
color: isCommunityBundle
|
||||
? withAlpha(resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color), theme.grouped.style.edgeAlpha)
|
||||
: resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color),
|
||||
size: Math.max(
|
||||
baseSize * sizeMultiplier * (isCommunityBundle ? theme.grouped.style.edgeSizeScale : 1),
|
||||
stateConfig.minSize,
|
||||
),
|
||||
zIndex: stateConfig.zIndex,
|
||||
edgeVariant,
|
||||
arrowVisibilityPolicy: variantConfig.arrowPolicy,
|
||||
@@ -936,6 +1187,162 @@ function addNodeIfMissing(targetGraph: GraphRef, nodeId: string, attrs: NodeAttr
|
||||
}
|
||||
}
|
||||
|
||||
function truncateGroupedLabel(label: string | null | undefined): string {
|
||||
const value = String(label || "").trim();
|
||||
if (value.length <= 22) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, 21).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function estimateGroupedRingCapacity(radius: number): number {
|
||||
const circumference = 2 * Math.PI * Math.max(radius, 1);
|
||||
return Math.max(6, Math.floor(circumference / GRAPH_THEME.grouped.initialLayout.minNodeSpacing));
|
||||
}
|
||||
|
||||
function getGroupedCommunityNodeSize(memberCount: number): number {
|
||||
return Math.max(16, 10 + Math.log2(memberCount + 1) * 4.6);
|
||||
}
|
||||
|
||||
function assignGroupedCommunityPositions(
|
||||
communities: Array<{
|
||||
communityId: number;
|
||||
memberCount: number;
|
||||
centralityScore: number;
|
||||
connectivityWeight: number;
|
||||
radius: number;
|
||||
}>,
|
||||
): Map<number, {
|
||||
x: number;
|
||||
y: number;
|
||||
labelPriority: number;
|
||||
visualPriority: number;
|
||||
}> {
|
||||
const positioned = new Map<number, {
|
||||
x: number;
|
||||
y: number;
|
||||
labelPriority: number;
|
||||
visualPriority: number;
|
||||
}>();
|
||||
const ranked = communities
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftProminence = Math.log2(left.memberCount + 1) * 2.2 + Math.log2(left.connectivityWeight + 1) * 1.6 + left.centralityScore * 5;
|
||||
const rightProminence = Math.log2(right.memberCount + 1) * 2.2 + Math.log2(right.connectivityWeight + 1) * 1.6 + right.centralityScore * 5;
|
||||
if (rightProminence !== leftProminence) {
|
||||
return rightProminence - leftProminence;
|
||||
}
|
||||
return left.communityId - right.communityId;
|
||||
});
|
||||
|
||||
if (ranked.length === 0) {
|
||||
return positioned;
|
||||
}
|
||||
|
||||
const seeded = new Map<number, { x: number; y: number }>();
|
||||
|
||||
const primaryLabelCount = Math.min(GRAPH_THEME.grouped.initialLayout.primaryLabelCount, ranked.length);
|
||||
const normalizedVisual = (index: number) => Math.max(0.24, 1.24 - index * 0.055);
|
||||
const normalizedLabel = (index: number) => (index < primaryLabelCount ? Math.max(1.02, 1.42 - index * 0.05) : 0.56);
|
||||
|
||||
const centerPosition = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
seeded.set(ranked[0].communityId, centerPosition);
|
||||
positioned.set(ranked[0].communityId, {
|
||||
...centerPosition,
|
||||
labelPriority: normalizedLabel(0),
|
||||
visualPriority: normalizedVisual(0),
|
||||
});
|
||||
|
||||
let cursor = 1;
|
||||
let ring = 1;
|
||||
while (cursor < ranked.length) {
|
||||
const radius = GRAPH_THEME.grouped.initialLayout.innerRadius + (ring - 1) * GRAPH_THEME.grouped.initialLayout.ringSpacing;
|
||||
const capacity = estimateGroupedRingCapacity(radius);
|
||||
const count = Math.min(capacity, ranked.length - cursor);
|
||||
const angleOffset = ((hashString(`community-ring:${ring}`) % 360) * Math.PI) / 180;
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const entry = ranked[cursor + index];
|
||||
const angle = angleOffset + (Math.PI * 2 * index) / count;
|
||||
const seedPosition = {
|
||||
x: Number((radius * Math.cos(angle)).toFixed(3)),
|
||||
y: Number((radius * Math.sin(angle)).toFixed(3)),
|
||||
};
|
||||
seeded.set(entry.communityId, seedPosition);
|
||||
positioned.set(entry.communityId, {
|
||||
...seedPosition,
|
||||
labelPriority: normalizedLabel(cursor + index),
|
||||
visualPriority: normalizedVisual(cursor + index),
|
||||
});
|
||||
}
|
||||
|
||||
cursor += count;
|
||||
ring += 1;
|
||||
}
|
||||
|
||||
for (let iteration = 0; iteration < GRAPH_THEME.grouped.initialLayout.overlapIterations; iteration += 1) {
|
||||
let moved = false;
|
||||
|
||||
for (let leftIndex = 0; leftIndex < ranked.length; leftIndex += 1) {
|
||||
for (let rightIndex = leftIndex + 1; rightIndex < ranked.length; rightIndex += 1) {
|
||||
const leftCommunity = ranked[leftIndex];
|
||||
const rightCommunity = ranked[rightIndex];
|
||||
const leftPosition = positioned.get(leftCommunity.communityId);
|
||||
const rightPosition = positioned.get(rightCommunity.communityId);
|
||||
if (!leftPosition || !rightPosition) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dx = rightPosition.x - leftPosition.x;
|
||||
let dy = rightPosition.y - leftPosition.y;
|
||||
let distance = Math.hypot(dx, dy);
|
||||
if (distance < 0.001) {
|
||||
const angle = ((hashString(`grouped-collision:${leftCommunity.communityId}:${rightCommunity.communityId}`) % 360) * Math.PI) / 180;
|
||||
dx = Math.cos(angle);
|
||||
dy = Math.sin(angle);
|
||||
distance = 1;
|
||||
}
|
||||
|
||||
const minimumDistance = leftCommunity.radius + rightCommunity.radius + GRAPH_THEME.grouped.initialLayout.nodePadding;
|
||||
if (distance >= minimumDistance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlap = minimumDistance - distance;
|
||||
const ux = dx / distance;
|
||||
const uy = dy / distance;
|
||||
const leftMobility = rightCommunity.radius / (leftCommunity.radius + rightCommunity.radius);
|
||||
const rightMobility = leftCommunity.radius / (leftCommunity.radius + rightCommunity.radius);
|
||||
|
||||
leftPosition.x -= ux * overlap * 0.52 * leftMobility;
|
||||
leftPosition.y -= uy * overlap * 0.52 * leftMobility;
|
||||
rightPosition.x += ux * overlap * 0.52 * rightMobility;
|
||||
rightPosition.y += uy * overlap * 0.52 * rightMobility;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
|
||||
ranked.forEach((community) => {
|
||||
const position = positioned.get(community.communityId);
|
||||
const seedPosition = seeded.get(community.communityId);
|
||||
if (!position || !seedPosition) {
|
||||
return;
|
||||
}
|
||||
position.x += (seedPosition.x - position.x) * 0.08;
|
||||
position.y += (seedPosition.y - position.y) * 0.08;
|
||||
});
|
||||
|
||||
if (!moved) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return positioned;
|
||||
}
|
||||
|
||||
function buildCollapsedNeighborhoodState(
|
||||
nodeId: string,
|
||||
activePath: string[],
|
||||
@@ -1090,6 +1497,7 @@ function buildCommunityGroupedGraph(): GraphDisplayResult {
|
||||
const state = createEmptyDisplayState("", true);
|
||||
state.groupedViewAvailable = base.communitiesByNode.size > 0;
|
||||
if (base.communitiesByNode.size === 0) {
|
||||
state.groupedViewReason = "Grouped view is unavailable until communities can be detected.";
|
||||
return { graph: aggregateDisplayGraph(graph), state, meta: MIRRORED_DISPLAY_META };
|
||||
}
|
||||
|
||||
@@ -1109,6 +1517,17 @@ function buildCommunityGroupedGraph(): GraphDisplayResult {
|
||||
communityMembers.set(communityId, bucket);
|
||||
});
|
||||
|
||||
const communitySummaries = new Map<number, {
|
||||
memberIds: string[];
|
||||
rankedMembers: string[];
|
||||
anchorNodeId: string | null;
|
||||
anchorAttrs: NodeAttributes | null;
|
||||
dominantSemanticGroup: string;
|
||||
color: string;
|
||||
memberCount: number;
|
||||
centralityScore: number;
|
||||
}>();
|
||||
|
||||
communityMembers.forEach((memberIds, communityId) => {
|
||||
const rankedMembers = memberIds
|
||||
.slice()
|
||||
@@ -1130,38 +1549,15 @@ function buildCommunityGroupedGraph(): GraphDisplayResult {
|
||||
});
|
||||
const dominantSemanticGroup = [...semanticCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "entity";
|
||||
const color = anchorAttrs?.baseColor || anchorAttrs?.color || GRAPH_THEME.palette.semantic[communityId % GRAPH_THEME.palette.semantic.length];
|
||||
const communityNodeId = `${COMMUNITY_NODE_PREFIX}${communityId}`;
|
||||
addNodeIfMissing(grouped, communityNodeId, {
|
||||
label: anchorAttrs?.label || `Community ${communityId}`,
|
||||
content: anchorAttrs?.content || anchorAttrs?.label || `Community ${communityId}`,
|
||||
x: anchorAttrs?.x ?? 0,
|
||||
y: anchorAttrs?.y ?? 0,
|
||||
size: Math.max(14, 8 + Math.log2(memberIds.length + 1) * 5),
|
||||
baseSize: Math.max(14, 8 + Math.log2(memberIds.length + 1) * 5),
|
||||
color,
|
||||
baseColor: color,
|
||||
mutedColor: withAlpha(color, 0.32),
|
||||
glowColor: withAlpha(color, 0.22),
|
||||
nodeType: "community",
|
||||
semanticGroup: dominantSemanticGroup,
|
||||
properties: {
|
||||
__communityGroup: {
|
||||
communityId: String(communityId),
|
||||
memberCount: memberIds.length,
|
||||
memberNodeIds: memberIds,
|
||||
sampleNodeIds: rankedMembers.slice(0, GROUP_SAMPLE_MEMBERS),
|
||||
anchorNodeId,
|
||||
anchorLabel: anchorAttrs?.label || anchorNodeId || `Community ${communityId}`,
|
||||
dominantSemanticGroup,
|
||||
color,
|
||||
},
|
||||
},
|
||||
isCommunityGroup: true,
|
||||
communityId: String(communityId),
|
||||
memberCount: memberIds.length,
|
||||
communitySummaries.set(communityId, {
|
||||
memberIds,
|
||||
rankedMembers,
|
||||
anchorNodeId,
|
||||
labelPriority: Math.max(2, Math.log2(memberIds.length + 1)),
|
||||
visualPriority: Math.max(1, Math.log2(memberIds.length + 1)),
|
||||
anchorAttrs,
|
||||
dominantSemanticGroup,
|
||||
color,
|
||||
memberCount: memberIds.length,
|
||||
centralityScore: anchorNodeId ? (base.centralityByNode.get(anchorNodeId)?.score ?? 0) : 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1197,10 +1593,121 @@ function buildCommunityGroupedGraph(): GraphDisplayResult {
|
||||
groupedEdges.set(key, bucket);
|
||||
});
|
||||
|
||||
const connectivityByCommunity = new Map<number, number>();
|
||||
groupedEdges.forEach((bundle) => {
|
||||
const sourceCommunityId = Number(bundle.sourceId.replace(COMMUNITY_NODE_PREFIX, ""));
|
||||
const targetCommunityId = Number(bundle.targetId.replace(COMMUNITY_NODE_PREFIX, ""));
|
||||
const weight = bundle.rawEdgeIds.length + bundle.weight * 0.35;
|
||||
connectivityByCommunity.set(sourceCommunityId, (connectivityByCommunity.get(sourceCommunityId) ?? 0) + weight);
|
||||
connectivityByCommunity.set(targetCommunityId, (connectivityByCommunity.get(targetCommunityId) ?? 0) + weight);
|
||||
});
|
||||
|
||||
const groupedEdgeStrength = new Map<string, number>();
|
||||
const incidentEdgeStrengths = new Map<string, Array<{ key: string; strength: number }>>();
|
||||
let strongestGroupedEdge = 0;
|
||||
groupedEdges.forEach((bundle, key) => {
|
||||
const strength = bundle.rawEdgeIds.length + bundle.weight * 0.35;
|
||||
groupedEdgeStrength.set(key, strength);
|
||||
strongestGroupedEdge = Math.max(strongestGroupedEdge, strength);
|
||||
|
||||
const sourceBucket = incidentEdgeStrengths.get(bundle.sourceId) ?? [];
|
||||
sourceBucket.push({ key, strength });
|
||||
incidentEdgeStrengths.set(bundle.sourceId, sourceBucket);
|
||||
|
||||
const targetBucket = incidentEdgeStrengths.get(bundle.targetId) ?? [];
|
||||
targetBucket.push({ key, strength });
|
||||
incidentEdgeStrengths.set(bundle.targetId, targetBucket);
|
||||
});
|
||||
|
||||
const visibleGroupedEdgeKeys = new Set<string>();
|
||||
const groupedEdgeStrengthCutoff = Math.max(1.25, strongestGroupedEdge * GRAPH_THEME.grouped.style.edgeVisibilityRatio);
|
||||
groupedEdges.forEach((_bundle, key) => {
|
||||
const strength = groupedEdgeStrength.get(key) ?? 0;
|
||||
if (strength >= groupedEdgeStrengthCutoff) {
|
||||
visibleGroupedEdgeKeys.add(key);
|
||||
}
|
||||
});
|
||||
incidentEdgeStrengths.forEach((entries) => {
|
||||
entries
|
||||
.slice()
|
||||
.sort((left, right) => right.strength - left.strength)
|
||||
.slice(0, GRAPH_THEME.grouped.style.topIncidentEdges)
|
||||
.forEach(({ key }) => visibleGroupedEdgeKeys.add(key));
|
||||
});
|
||||
if (visibleGroupedEdgeKeys.size === 0 && groupedEdges.size > 0) {
|
||||
const strongestKey = Array.from(groupedEdges.keys()).sort(
|
||||
(left, right) => (groupedEdgeStrength.get(right) ?? 0) - (groupedEdgeStrength.get(left) ?? 0),
|
||||
)[0];
|
||||
if (strongestKey) {
|
||||
visibleGroupedEdgeKeys.add(strongestKey);
|
||||
}
|
||||
}
|
||||
|
||||
const groupedPositions = assignGroupedCommunityPositions(
|
||||
Array.from(communitySummaries.entries()).map(([communityId, summary]) => ({
|
||||
communityId,
|
||||
memberCount: summary.memberCount,
|
||||
centralityScore: summary.centralityScore,
|
||||
connectivityWeight: connectivityByCommunity.get(communityId) ?? 0,
|
||||
radius: getGroupedCommunityNodeSize(summary.memberCount) * GRAPH_THEME.grouped.style.nodeSizeScale * 0.92,
|
||||
})),
|
||||
);
|
||||
|
||||
communitySummaries.forEach((summary, communityId) => {
|
||||
const communityNodeId = `${COMMUNITY_NODE_PREFIX}${communityId}`;
|
||||
const position = groupedPositions.get(communityId) ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
labelPriority: 0.56,
|
||||
visualPriority: 0.42,
|
||||
};
|
||||
const size = getGroupedCommunityNodeSize(summary.memberCount);
|
||||
const anchorLabel = summary.anchorAttrs?.label || summary.anchorNodeId || `Community ${communityId}`;
|
||||
addNodeIfMissing(grouped, communityNodeId, {
|
||||
label: truncateGroupedLabel(anchorLabel),
|
||||
content: summary.anchorAttrs?.content || anchorLabel,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
size,
|
||||
baseSize: size,
|
||||
color: summary.color,
|
||||
baseColor: summary.color,
|
||||
mutedColor: withAlpha(summary.color, 0.22),
|
||||
glowColor: withAlpha(summary.color, GRAPH_THEME.grouped.style.glowAlpha),
|
||||
borderColor: withAlpha(summary.color, 0.74),
|
||||
nodeType: "community",
|
||||
semanticGroup: summary.dominantSemanticGroup,
|
||||
labelVisibilityPolicy: position.labelPriority > 0.9 ? "priority" : "none",
|
||||
properties: {
|
||||
__communityGroup: {
|
||||
communityId: String(communityId),
|
||||
memberCount: summary.memberCount,
|
||||
memberNodeIds: summary.memberIds,
|
||||
sampleNodeIds: summary.rankedMembers.slice(0, GROUP_SAMPLE_MEMBERS),
|
||||
anchorNodeId: summary.anchorNodeId,
|
||||
anchorLabel,
|
||||
dominantSemanticGroup: summary.dominantSemanticGroup,
|
||||
color: summary.color,
|
||||
},
|
||||
},
|
||||
isCommunityGroup: true,
|
||||
communityId: String(communityId),
|
||||
memberCount: summary.memberCount,
|
||||
anchorNodeId: summary.anchorNodeId,
|
||||
labelPriority: position.labelPriority,
|
||||
visualPriority: position.visualPriority,
|
||||
});
|
||||
});
|
||||
|
||||
groupedEdges.forEach((bundle, key) => {
|
||||
if (!visibleGroupedEdgeKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
const dominantEdgeType = [...bundle.typeCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "related_to";
|
||||
const reverseKey = `${bundle.targetId}→${bundle.sourceId}`;
|
||||
const syntheticEdgeId = `${AGGREGATED_EDGE_PREFIX}${key}`;
|
||||
const aggregateCount = bundle.rawEdgeIds.length;
|
||||
const baseSize = Math.max(0.9, 0.72 + Math.log2(aggregateCount + 1) * 0.2);
|
||||
grouped.mergeDirectedEdgeWithKey(syntheticEdgeId, bundle.sourceId, bundle.targetId, {
|
||||
edgeId: syntheticEdgeId,
|
||||
familyId: syntheticEdgeId,
|
||||
@@ -1213,23 +1720,45 @@ function buildCommunityGroupedGraph(): GraphDisplayResult {
|
||||
properties: {},
|
||||
rawEdgeIds: bundle.rawEdgeIds,
|
||||
isAggregated: true,
|
||||
aggregateCount: bundle.rawEdgeIds.length,
|
||||
familySize: bundle.rawEdgeIds.length,
|
||||
parallelCount: bundle.rawEdgeIds.length,
|
||||
aggregateCount,
|
||||
familySize: aggregateCount,
|
||||
parallelCount: aggregateCount,
|
||||
isBidirectional: groupedEdges.has(reverseKey),
|
||||
bundleKind: "community",
|
||||
visualPriority: 2,
|
||||
edgeVariant: "line",
|
||||
arrowVisibilityPolicy: "hidden",
|
||||
baseSize,
|
||||
size: baseSize,
|
||||
color: withAlpha("#9BB7D6", GRAPH_THEME.grouped.style.edgeAlpha),
|
||||
mutedColor: withAlpha("#9BB7D6", Math.max(0.14, GRAPH_THEME.grouped.style.edgeAlpha * 0.4)),
|
||||
visualPriority: Math.min(1.05, 0.38 + Math.log2(aggregateCount + 1) * 0.14),
|
||||
});
|
||||
});
|
||||
|
||||
const groupedValidationError = validateGroupedDisplayGraph(grouped);
|
||||
if (groupedValidationError) {
|
||||
state.groupedViewAvailable = false;
|
||||
state.groupedViewReason = groupedValidationError;
|
||||
return {
|
||||
graph: aggregateDisplayGraph(graph),
|
||||
state,
|
||||
meta: MIRRORED_DISPLAY_META,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
graph: grouped,
|
||||
state: {
|
||||
aggregationEnabled: true,
|
||||
groupedViewAvailable: true,
|
||||
groupedViewReason: null,
|
||||
selectedRootNodeId: null,
|
||||
selectedVisibleNeighborIds: [],
|
||||
selectedCollapsedNeighborIds: [],
|
||||
selectedNodeKind: "none",
|
||||
canActivateFocused: false,
|
||||
resolvedFocusedNodeId: null,
|
||||
focusedUnavailableReason: null,
|
||||
},
|
||||
meta: OWNED_DISPLAY_META,
|
||||
};
|
||||
@@ -1243,6 +1772,10 @@ export function resolveDisplayStateSnapshot(
|
||||
aggregationEnabled?: boolean;
|
||||
collapsedNeighborhoodNodeIds?: Iterable<string>;
|
||||
groupedViewAvailable?: boolean;
|
||||
groupedViewReason?: string | null;
|
||||
selectedNodeKind?: GraphSelectedNodeKind;
|
||||
resolvedFocusedNodeId?: string | null;
|
||||
focusedUnavailableReason?: string | null;
|
||||
},
|
||||
): GraphDisplayStateSnapshot {
|
||||
const aggregationEnabled = options?.aggregationEnabled ?? true;
|
||||
@@ -1250,10 +1783,18 @@ export function resolveDisplayStateSnapshot(
|
||||
Array.from(options?.collapsedNeighborhoodNodeIds ?? []).filter((nodeId) => typeof nodeId === "string"),
|
||||
);
|
||||
const displayState = createEmptyDisplayState(selectedNodeId, aggregationEnabled);
|
||||
displayState.selectedNodeKind = options?.selectedNodeKind ?? (selectedNodeId ? "unavailable" : "none");
|
||||
displayState.resolvedFocusedNodeId = options?.resolvedFocusedNodeId ?? null;
|
||||
displayState.canActivateFocused = Boolean(displayState.resolvedFocusedNodeId);
|
||||
displayState.focusedUnavailableReason = displayState.canActivateFocused
|
||||
? null
|
||||
: (options?.focusedUnavailableReason ?? displayState.focusedUnavailableReason);
|
||||
displayState.groupedViewAvailable = options?.groupedViewAvailable ?? computeGraphAnalyticsBase(graph, {
|
||||
computeCommunities: true,
|
||||
computeCentrality: false,
|
||||
}).communitiesByNode.size > 0;
|
||||
displayState.groupedViewReason = options?.groupedViewReason
|
||||
?? (displayState.groupedViewAvailable ? null : "Grouped view is unavailable until communities can be detected.");
|
||||
|
||||
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
||||
return displayState;
|
||||
@@ -1421,6 +1962,11 @@ export function resolveDisplayGraph(
|
||||
selectedRootNodeId: displayState.selectedRootNodeId,
|
||||
selectedVisibleNeighborIds: displayState.selectedVisibleNeighborIds,
|
||||
selectedCollapsedNeighborIds: displayState.selectedCollapsedNeighborIds,
|
||||
groupedViewReason: grouped.state.groupedViewReason,
|
||||
selectedNodeKind: displayState.selectedNodeKind,
|
||||
canActivateFocused: displayState.canActivateFocused,
|
||||
resolvedFocusedNodeId: displayState.resolvedFocusedNodeId,
|
||||
focusedUnavailableReason: displayState.focusedUnavailableReason,
|
||||
},
|
||||
meta: grouped.meta,
|
||||
};
|
||||
@@ -1452,6 +1998,7 @@ export function resolveDisplayGraph(
|
||||
export function createInteractionState(
|
||||
hoveredNodeId: string | null,
|
||||
selectedNodeId: string,
|
||||
focusedNodeId: string,
|
||||
selectedEdgeId: string,
|
||||
activePath: string[],
|
||||
activePathEdgeIds: string[],
|
||||
@@ -1463,7 +2010,7 @@ export function createInteractionState(
|
||||
hoveredNodeId,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
focusedNodeId: selectedNodeId,
|
||||
focusedNodeId,
|
||||
activePath,
|
||||
activePathEdgeIds,
|
||||
viewMode,
|
||||
|
||||
@@ -191,6 +191,35 @@ export interface GraphTheme {
|
||||
motion: {
|
||||
cameraMs: number;
|
||||
};
|
||||
grouped: {
|
||||
initialLayout: {
|
||||
innerRadius: number;
|
||||
ringSpacing: number;
|
||||
minNodeSpacing: number;
|
||||
nodePadding: number;
|
||||
overlapIterations: number;
|
||||
primaryLabelCount: number;
|
||||
};
|
||||
style: {
|
||||
nodeSizeScale: number;
|
||||
nodeBorderBoost: number;
|
||||
fillAlpha: number;
|
||||
shellAlpha: number;
|
||||
edgeSizeScale: number;
|
||||
edgeAlpha: number;
|
||||
glowAlpha: number;
|
||||
edgeVisibilityRatio: number;
|
||||
topIncidentEdges: number;
|
||||
};
|
||||
layout: {
|
||||
iterations: number;
|
||||
gravity: number;
|
||||
scalingRatio: number;
|
||||
edgeWeightInfluence: number;
|
||||
slowDown: number;
|
||||
settleMs: number;
|
||||
};
|
||||
};
|
||||
effects: {
|
||||
pathPulse: {
|
||||
minZoomTier: GraphZoomTier;
|
||||
@@ -474,6 +503,35 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
motion: {
|
||||
cameraMs: 380,
|
||||
},
|
||||
grouped: {
|
||||
initialLayout: {
|
||||
innerRadius: 92,
|
||||
ringSpacing: 138,
|
||||
minNodeSpacing: 112,
|
||||
nodePadding: 28,
|
||||
overlapIterations: 18,
|
||||
primaryLabelCount: 6,
|
||||
},
|
||||
style: {
|
||||
nodeSizeScale: 0.9,
|
||||
nodeBorderBoost: 0.42,
|
||||
fillAlpha: 0.68,
|
||||
shellAlpha: 0.28,
|
||||
edgeSizeScale: 0.62,
|
||||
edgeAlpha: 0.32,
|
||||
glowAlpha: 0.14,
|
||||
edgeVisibilityRatio: 0.18,
|
||||
topIncidentEdges: 2,
|
||||
},
|
||||
layout: {
|
||||
iterations: 18,
|
||||
gravity: 0.06,
|
||||
scalingRatio: 18,
|
||||
edgeWeightInfluence: 0.08,
|
||||
slowDown: 34,
|
||||
settleMs: 1500,
|
||||
},
|
||||
},
|
||||
effects: {
|
||||
pathPulse: {
|
||||
minZoomTier: "structure",
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface GraphSceneProps extends GraphSceneEventMap {
|
||||
displayMeta: GraphDisplayMeta;
|
||||
displayState?: GraphDisplayStateSnapshot;
|
||||
selectedNodeId: string;
|
||||
focusedNodeId: string;
|
||||
selectedEdgeId: string;
|
||||
activePath?: string[];
|
||||
activePathEdgeIds?: string[];
|
||||
|
||||
@@ -12,6 +12,7 @@ export type GraphLoadPhase =
|
||||
export type GraphLoadProgressKind = "determinate" | "indeterminate";
|
||||
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphEdgeInteractionState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphSelectedNodeKind = "none" | "base" | "grouped" | "unavailable";
|
||||
|
||||
export interface GraphCameraState {
|
||||
x: number;
|
||||
@@ -34,9 +35,14 @@ export interface GraphInteractionState {
|
||||
export interface GraphDisplayStateSnapshot {
|
||||
aggregationEnabled: boolean;
|
||||
groupedViewAvailable: boolean;
|
||||
groupedViewReason: string | null;
|
||||
selectedRootNodeId: string | null;
|
||||
selectedVisibleNeighborIds: string[];
|
||||
selectedCollapsedNeighborIds: string[];
|
||||
selectedNodeKind: GraphSelectedNodeKind;
|
||||
canActivateFocused: boolean;
|
||||
resolvedFocusedNodeId: string | null;
|
||||
focusedUnavailableReason: string | null;
|
||||
}
|
||||
|
||||
export type GraphDisplayLayoutMode = "base" | "mirrored" | "owned";
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
batchMergeNodes,
|
||||
clearGraph,
|
||||
} from "../src/store/graphStore.ts";
|
||||
import { resolveDisplayGraph } from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
|
||||
import {
|
||||
checkGroupedViewAvailability,
|
||||
resolveDisplayGraph,
|
||||
resolveGroupedDisplayNodeId,
|
||||
resolveGroupedDisplayStateSnapshot,
|
||||
} from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
|
||||
|
||||
function addNode(id: string, semanticGroup = "entity") {
|
||||
batchMergeNodes([
|
||||
@@ -135,3 +140,120 @@ test("resolveDisplayGraph grouped view emits community nodes and edges", () => {
|
||||
|
||||
assert.equal(hasCommunityEdge, true);
|
||||
});
|
||||
|
||||
// ── resolveGroupedDisplayNodeId ──────────────────────────────────────────────
|
||||
|
||||
test("resolveGroupedDisplayNodeId returns null for empty nodeId", () => {
|
||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
||||
assert.equal(resolveGroupedDisplayNodeId(displayGraph, ""), null);
|
||||
});
|
||||
|
||||
test("resolveGroupedDisplayNodeId returns nodeId when it exists directly in display graph", () => {
|
||||
addNode("x");
|
||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
||||
assert.equal(resolveGroupedDisplayNodeId(displayGraph, "x"), "x");
|
||||
});
|
||||
|
||||
test("resolveGroupedDisplayNodeId resolves base node to its community node", () => {
|
||||
const left = ["a1", "a2", "a3", "a4"];
|
||||
const right = ["b1", "b2", "b3", "b4"];
|
||||
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
||||
|
||||
let edgeIndex = 0;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
for (let j = 0; j < left.length; j += 1) {
|
||||
if (i !== j) addEdge(`l-${edgeIndex++}`, left[i], left[j], 3);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < right.length; i += 1) {
|
||||
for (let j = 0; j < right.length; j += 1) {
|
||||
if (i !== j) addEdge(`r-${edgeIndex++}`, right[i], right[j], 3);
|
||||
}
|
||||
}
|
||||
addEdge("bridge-1", "a1", "b1", 0.1);
|
||||
|
||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
|
||||
const communityNodes = displayGraph.nodes().filter((n) => n.startsWith("__community__"));
|
||||
assert.ok(communityNodes.length >= 2, "expected community nodes");
|
||||
|
||||
const resolved = resolveGroupedDisplayNodeId(displayGraph, "a1");
|
||||
assert.ok(resolved !== null, "should resolve a1 to a community node");
|
||||
assert.ok(resolved!.startsWith("__community__"), "resolved id should be a community node");
|
||||
});
|
||||
|
||||
// ── resolveGroupedDisplayStateSnapshot ──────────────────────────────────────
|
||||
|
||||
test("resolveGroupedDisplayStateSnapshot returns none-kind when no node selected", () => {
|
||||
addNode("p");
|
||||
addNode("q");
|
||||
addEdge("e1", "p", "q");
|
||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
||||
const state = resolveGroupedDisplayStateSnapshot(displayGraph, "", {
|
||||
groupedViewAvailable: true,
|
||||
groupedViewReason: null,
|
||||
});
|
||||
assert.equal(state.selectedNodeKind, "none");
|
||||
assert.equal(state.selectedRootNodeId, null);
|
||||
});
|
||||
|
||||
test("resolveGroupedDisplayStateSnapshot maps selected base node to community in grouped graph", () => {
|
||||
const left = ["c1", "c2", "c3", "c4"];
|
||||
const right = ["d1", "d2", "d3", "d4"];
|
||||
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
||||
|
||||
let edgeIndex = 0;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
for (let j = 0; j < left.length; j += 1) {
|
||||
if (i !== j) addEdge(`lc-${edgeIndex++}`, left[i], left[j], 3);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < right.length; i += 1) {
|
||||
for (let j = 0; j < right.length; j += 1) {
|
||||
if (i !== j) addEdge(`rc-${edgeIndex++}`, right[i], right[j], 3);
|
||||
}
|
||||
}
|
||||
addEdge("bridge-c1", "c1", "d1", 0.1);
|
||||
addEdge("bridge-c2", "c2", "d2", 0.1);
|
||||
|
||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
|
||||
const state = resolveGroupedDisplayStateSnapshot(displayGraph, "c1", {
|
||||
groupedViewAvailable: true,
|
||||
groupedViewReason: null,
|
||||
selectedNodeKind: "grouped",
|
||||
});
|
||||
|
||||
assert.ok(state.selectedRootNodeId !== null, "should resolve to a community node");
|
||||
assert.ok(state.selectedRootNodeId!.startsWith("__community__"), "root should be a community node");
|
||||
assert.equal(state.groupedViewAvailable, true);
|
||||
});
|
||||
|
||||
// ── checkGroupedViewAvailability ─────────────────────────────────────────────
|
||||
|
||||
test("checkGroupedViewAvailability returns unavailable on empty graph", () => {
|
||||
const result = checkGroupedViewAvailability();
|
||||
assert.equal(result.available, false);
|
||||
assert.ok(typeof result.reason === "string" && result.reason.length > 0);
|
||||
});
|
||||
|
||||
test("checkGroupedViewAvailability returns available when communities exist", () => {
|
||||
const left = ["e1", "e2", "e3", "e4"];
|
||||
const right = ["f1", "f2", "f3", "f4"];
|
||||
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
||||
|
||||
let edgeIndex = 0;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
for (let j = 0; j < left.length; j += 1) {
|
||||
if (i !== j) addEdge(`le-${edgeIndex++}`, left[i], left[j], 3);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < right.length; i += 1) {
|
||||
for (let j = 0; j < right.length; j += 1) {
|
||||
if (i !== j) addEdge(`re-${edgeIndex++}`, right[i], right[j], 3);
|
||||
}
|
||||
}
|
||||
addEdge("bridge-e1", "e1", "f1", 0.1);
|
||||
|
||||
const result = checkGroupedViewAvailability();
|
||||
assert.equal(result.available, true);
|
||||
assert.equal(result.reason, null);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user