Merge pull request #493 from Hawksight-AI/feat/explorer-grouped-view

Feat/explorer grouped view
This commit is contained in:
Mohd Kaif
2026-04-25 15:44:04 +05:30
committed by GitHub
14 changed files with 1404 additions and 190 deletions
@@ -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";
+123 -1
View File
@@ -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);
});