From 7c8dfbd3c0802763de6f3410433bc8f565b61fcf Mon Sep 17 00:00:00 2001 From: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:40:51 +0500 Subject: [PATCH] feat(explorer): stabilize and refine grouped graph view --- .../workspaces/GraphWorkspace/GraphCanvas.tsx | 328 +++++++-- .../GraphWorkspace/GraphInspectorPanel.tsx | 78 ++- .../GraphWorkspace/GraphRuntimeStage.tsx | 1 + .../GraphWorkspace/GraphWorkspace.tsx | 324 +++++++-- .../GraphWorkspace/GraphWorkspaceShell.tsx | 17 +- .../behaviors/focusCameraBehavior.ts | 5 + .../behaviors/searchFocusBehavior.ts | 15 +- .../GraphWorkspace/behaviors/types.ts | 4 +- .../behaviors/viewModeSwitchBehavior.ts | 6 +- .../GraphWorkspace/graphSceneState.ts | 654 ++++++++++++++++-- .../workspaces/GraphWorkspace/graphTheme.ts | 58 ++ .../src/workspaces/GraphWorkspace/scene.ts | 1 + .../src/workspaces/GraphWorkspace/types.ts | 6 + 13 files changed, 1328 insertions(+), 169 deletions(-) diff --git a/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx b/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx index e621424a..9f2b9398 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx @@ -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, +type ReducerSceneState = { + zoomTier: GraphZoomTier; + hoveredNodeId: string | null; + selectedNodeId: string; + selectedEdgeId: string; + activePath: string[]; + activePathEdgeIds: string[]; + focusIds: Set; + edgeEndpointIds: Set; + pathNodeIds: Set; + pathEdgeIds: Set; + overviewBackboneEdgeIds: Set; +}; + +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() + ) + : new Set(); + + 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; missingNodes: Set } }, 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(); - 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( onNodeClick, onEdgeClick, selectedNodeId, + focusedNodeId, selectedEdgeId, activePath = [], activePathEdgeIds = [], effectsState, temporalState, isLayoutRunning, + onLayoutRunningChange, viewMode, className, showFitViewButton = true, @@ -930,6 +1028,7 @@ export const GraphCanvas = forwardRef( 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( const layoutSyncFrameRef = useRef(null); const layoutSyncTickRef = useRef(0); const deferredFocusFrameRef = useRef(null); + const groupedLayoutSettleTimeoutRef = useRef(null); + const reducerWarningStateRef = useRef<{ missingEdges: Set; missingNodes: Set }>({ + missingEdges: new Set(), + missingNodes: new Set(), + }); 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( () => createInteractionState( hoveredNodeId, selectedNodeId, + focusedNodeId, selectedEdgeId, activePath, activePathEdgeIds, @@ -979,7 +1085,17 @@ export const GraphCanvas = forwardRef( zoomTier, isLayoutRunning, ), - [activePath, activePathEdgeIds, hoveredNodeId, isLayoutRunning, selectedEdgeId, selectedNodeId, viewMode, zoomTier], + [ + activePath, + activePathEdgeIds, + focusedNodeId, + hoveredNodeId, + isLayoutRunning, + selectedEdgeId, + selectedNodeId, + viewMode, + zoomTier, + ], ); const interactionStateRef = useRef(interactionState); interactionStateRef.current = interactionState; @@ -993,6 +1109,12 @@ export const GraphCanvas = forwardRef( }), [displayGraph, shouldComputeCentrality, shouldComputeCommunities], ); + const reducerSceneState = useMemo( + () => buildReducerSceneState(displayGraph, interactionState, analyticsSnapshot), + [analyticsSnapshot, displayGraph, interactionState], + ); + const reducerSceneStateRef = useRef(reducerSceneState); + reducerSceneStateRef.current = reducerSceneState; const displayFitSignature = useMemo(() => ({ graphVersion, viewMode, @@ -1175,6 +1297,63 @@ export const GraphCanvas = forwardRef( }); }, [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( 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( 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( 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( ? 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( context.clearRect(0, 0, rect.width, rect.height); const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId; - const focusIds = primaryNodeId && graph.hasNode(primaryNodeId) ? buildFocusSet(primaryNodeId) : new Set(); + const focusIds = primaryNodeId + ? ( + displayGraph.hasNode(primaryNodeId) + ? buildFocusSetInGraph(displayGraph, primaryNodeId) + : new Set() + ) + : new Set(); const edgeEndpointIds = buildEdgeEndpointSet(displayGraph, interactionState.selectedEdgeId); const pathNodeIds = new Set(interactionState.activePath); const pathSegments = collectPathSegments( @@ -1677,7 +1858,15 @@ export const GraphCanvas = forwardRef( 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( 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( 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( 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( 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( 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( } 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; diff --git a/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx b/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx index df30b0e5..7b7103a1 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx @@ -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,7 +182,38 @@ export function GraphInspectorPanel({ ); } - const attributes = graph.getNodeAttributes(nodeId) as { + 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 ( + + ); + } + + const attributes = graph.getNodeAttributes(effectiveNodeId) as { color?: string; content?: string; label?: string; @@ -196,12 +236,26 @@ export function GraphInspectorPanel({
- {attributes?.nodeType || "Entity"} + + {groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")} +

- {String(attributes?.label ?? nodeId)} + {String(attributes?.label ?? effectiveNodeId)}

-
{nodeId}
+
+ {groupedDisplaySelection ? nodeId : effectiveNodeId} +
+ {groupedDisplaySelection ? ( +
+
This grouped item stays display-level until you explicitly enter Focused mode.
+
+ {canActivateFocused + ? `Canonical node available: ${effectiveNodeId}` + : (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")} +
+
+ ) : null}
{attributes?.valid_from || attributes?.valid_until ? ( temporal @@ -226,7 +280,7 @@ export function GraphInspectorPanel({
- -
@@ -259,7 +313,7 @@ export function GraphInspectorPanel({ placeholder="Target node ID" style={inputStyle} /> - + {pathResult?.path?.length ? ( { + 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 + || communityGroup.sampleNodeIds?.includes(nodeId) + || communityGroup.memberNodeIds?.includes(nodeId) + ) { + resolvedNodeId = candidateId; + } + }); + + return resolvedNodeId; +} + function buildSelectedEdgeState( edgeId: string, displayGraph: typeof graph | Graph, @@ -677,6 +725,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); @@ -844,9 +894,28 @@ export function GraphWorkspace() { }; }, [debouncedTime, isLoading]); - const focusNode = useCallback((nodeId: string) => { - const currentDisplayGraph = pluginRuntimeRef.current?.displayGraph ?? graph; - if (viewMode === "grouped" && currentDisplayGraph.hasNode(nodeId)) { + const resolveNodeIdForFocusedMode = useCallback(( + nodeId: string, + displayGraphCandidate?: GraphSceneRuntime["displayGraph"] | null, + ): FocusResolution => { + if (!nodeId) { + return { + kind: "none", + resolvedNodeId: null, + reason: "Select a node to inspect in Focused mode.", + }; + } + + if (graph.hasNode(nodeId)) { + return { + kind: "base", + resolvedNodeId: nodeId, + reason: null, + }; + } + + const currentDisplayGraph = displayGraphCandidate ?? pluginRuntimeRef.current?.displayGraph ?? graph; + if (currentDisplayGraph.hasNode(nodeId)) { const displayAttrs = currentDisplayGraph.getNodeAttributes(nodeId) as NodeAttributes; const communityGroup = displayAttrs.properties?.__communityGroup as | { @@ -856,23 +925,125 @@ export function GraphWorkspace() { | undefined; const anchorNodeId = communityGroup?.anchorNodeId || communityGroup?.sampleNodeIds?.[0] || ""; if (anchorNodeId && graph.hasNode(anchorNodeId)) { - setViewMode("focused"); - setSelectedNodeId(anchorNodeId); - setSelectedEdgeId(""); - setPathResult(null); - setSearchResults([]); - setSearchError(""); - setIsLayoutRunning(false); - return; + return { + kind: "grouped", + resolvedNodeId: anchorNodeId, + reason: null, + }; } + + return { + kind: "grouped", + resolvedNodeId: null, + reason: "Focused mode is unavailable for this grouped selection.", + }; } - setSelectedNodeId(nodeId); + return { + kind: "unavailable", + resolvedNodeId: null, + reason: "Selected item is not available in the current graph.", + }; + }, []); + + const focusedSelectionResolution = useMemo( + () => resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph), + [pluginRuntimeVersion, resolveNodeIdForFocusedMode, selectedNodeId, viewMode], + ); + const inspectableNodeId = focusedSelectionResolution.resolvedNodeId ?? ""; + const canActivateFocusedMode = Boolean(focusedSelectionResolution.resolvedNodeId); + const groupedDisplayCandidate = useMemo( + () => resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", { + aggregationEnabled, + collapsedNeighborhoodNodeIds, + }), + [aggregationEnabled, collapsedNeighborhoodNodeIds, graphVersion], + ); + const groupedViewAvailable = groupedDisplayCandidate.state.groupedViewAvailable; + const groupedViewReason = groupedDisplayCandidate.state.groupedViewReason; + + 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 (nextViewMode === "grouped") { + if (!groupedViewAvailable) { + debugGraphWorkspace("grouped-view-unavailable", { + reason: groupedViewReason, + graphVersion, + }); + return; + } + + const groupedDisplayGraph = groupedDisplayCandidate.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; + } + + setFocusedNodeId(""); + setSelectedNodeId((currentSelectedNodeId) => ( + currentSelectedNodeId && graph.hasNode(currentSelectedNodeId) ? currentSelectedNodeId : "" + )); + setViewMode("full"); + }, [ + aggregationEnabled, + collapsedNeighborhoodNodeIds, + focusedNodeId, + graphVersion, + groupedDisplayCandidate.graph, + groupedViewAvailable, + groupedViewReason, + lastGroupedSelectedNodeId, + resolveNodeIdForFocusedMode, + selectedNodeId, + ]); + + const focusNode = useCallback((nodeId: string) => { + if (!nodeId) { + return; + } + + const currentDisplayGraph = pluginRuntimeRef.current?.displayGraph ?? graph; + const nextSelectedNodeId = viewMode === "focused" && graph.hasNode(nodeId) + ? nodeId + : nodeId; + + if (!graph.hasNode(nodeId) && currentDisplayGraph.hasNode(nodeId)) { + setLastGroupedSelectedNodeId(nodeId); + } + + setSelectedNodeId(nextSelectedNodeId); setSelectedEdgeId(""); setPathResult(null); setSearchResults([]); setSearchError(""); - if (nodeId && viewMode === "focused") { + if (viewMode === "focused" && graph.hasNode(nextSelectedNodeId)) { + setFocusedNodeId(nextSelectedNodeId); setIsLayoutRunning(false); } }, [viewMode]); @@ -904,14 +1075,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, @@ -928,13 +1099,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}`); @@ -951,12 +1122,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}`); } @@ -964,12 +1135,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:"; @@ -1023,6 +1194,8 @@ export function GraphWorkspace() { useEffect(() => { setCollapsedNeighborhoodNodeIds([]); + setFocusedNodeId(""); + setLastGroupedSelectedNodeId(""); }, [summary?.edgeCount, summary?.nodeCount]); const showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress)); @@ -1031,25 +1204,29 @@ 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 displayResult = useMemo( - () => resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, { - aggregationEnabled, - collapsedNeighborhoodNodeIds, - }), + () => ( + viewMode === "grouped" + ? groupedDisplayCandidate + : resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, { + aggregationEnabled, + collapsedNeighborhoodNodeIds, + }) + ), [ aggregationEnabled, collapsedNeighborhoodNodeIds, - graphVersion, + groupedDisplayCandidate, structuralActivePath, structuralActivePathEdgeIds, structuralSelectedNodeId, @@ -1057,13 +1234,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, - }), - [activePath, aggregationEnabled, collapsedNeighborhoodNodeIds, graphVersion, selectedNodeId, viewMode], + displayResult.graph, + focusedSelectionResolution.kind, + focusedSelectionResolution.reason, + focusedSelectionResolution.resolvedNodeId, + groupedViewAvailable, + 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(() => { @@ -1094,7 +1317,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; } @@ -1228,7 +1451,7 @@ export function GraphWorkspace() { focusNode(action.nodeId); return; case "setViewMode": - setViewMode(action.viewMode); + requestViewMode(action.viewMode); return; case "collapseNeighborhood": if (!selectedNodeId) { @@ -1280,7 +1503,7 @@ export function GraphWorkspace() { setActiveDockPanelId((previous) => (previous === action.panelId ? null : previous)); return; } - }, [focusNode, selectedNodeId, setEffectToggle]); + }, [focusNode, requestViewMode, selectedNodeId, setEffectToggle]); const diagnosticsSnapshot = useMemo(() => { if (!GRAPH_THEME.effects.diagnostics.enabledInDev || !graphDiagnosticsState) { @@ -1459,25 +1682,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: !selectedNodeId, - onClick: () => setViewMode("focused"), + disabled: viewMode !== "focused" && !canActivateFocusedMode, + onClick: () => requestViewMode("focused"), }, ], }); @@ -1568,12 +1793,16 @@ export function GraphWorkspace() { return groups; }, [ displayState.groupedViewAvailable, + displayState.groupedViewReason, handlePluginAction, hasGraphContent, isLayoutRunning, pluginToolbarItems, reload, + requestViewMode, searchQuery, + canActivateFocusedMode, + focusedSelectionResolution.reason, selectedNodeId, selectedNodeState, showLoadingOverlay, @@ -1589,6 +1818,7 @@ export function GraphWorkspace() { displayMeta, displayState, selectedNodeId, + focusedNodeId, selectedEdgeId, activePath, activePathEdgeIds, @@ -1842,6 +2072,10 @@ export function GraphWorkspace() { Loading inspector…
}> { + 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() {
{selectedNodeId ? ( <> - - + + ) : ( Select a node to switch graph views diff --git a/explorer/src/workspaces/GraphWorkspace/behaviors/focusCameraBehavior.ts b/explorer/src/workspaces/GraphWorkspace/behaviors/focusCameraBehavior.ts index d5ee9799..d004c631 100644 --- a/explorer/src/workspaces/GraphWorkspace/behaviors/focusCameraBehavior.ts +++ b/explorer/src/workspaces/GraphWorkspace/behaviors/focusCameraBehavior.ts @@ -15,6 +15,11 @@ export const focusCameraBehavior: GraphBehavior = { return true; } + if (action.type === "centerGroupedSelection") { + context.centerGroupedSelectionInView(action.nodeId); + return true; + } + return false; }, }; diff --git a/explorer/src/workspaces/GraphWorkspace/behaviors/searchFocusBehavior.ts b/explorer/src/workspaces/GraphWorkspace/behaviors/searchFocusBehavior.ts index 13bffcb6..8cadb890 100644 --- a/explorer/src/workspaces/GraphWorkspace/behaviors/searchFocusBehavior.ts +++ b/explorer/src/workspaces/GraphWorkspace/behaviors/searchFocusBehavior.ts @@ -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, + }); }, }; } diff --git a/explorer/src/workspaces/GraphWorkspace/behaviors/types.ts b/explorer/src/workspaces/GraphWorkspace/behaviors/types.ts index 2f34aac1..4633a3e7 100644 --- a/explorer/src/workspaces/GraphWorkspace/behaviors/types.ts +++ b/explorer/src/workspaces/GraphWorkspace/behaviors/types.ts @@ -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; } diff --git a/explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts b/explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts index d86e4a89..fc607426 100644 --- a/explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts +++ b/explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts @@ -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; } diff --git a/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts b/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts index bea7f64c..54f6470f 100644 --- a/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts +++ b/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts @@ -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 = import.meta.env.DEV; type GraphRef = typeof graph | Graph; @@ -140,12 +148,173 @@ 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, }; } +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; +} + +function rankGroupedNeighbors( + displayGraph: GraphRef, + nodeId: string, +): string[] { + const resolvedNodeId = resolveGroupedDisplayNodeId(displayGraph, nodeId); + if (!resolvedNodeId) { + return []; + } + + const scoredNeighbors = new Map(); + 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): 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 +398,7 @@ function collectImpactedNodeIds( const impacted = new Set(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 +418,7 @@ function collectImpactedEdgeKeys( const impacted = new Set(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 +468,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 +523,24 @@ export function rankNeighbors(nodeId: string): string[] { .map((item) => item.id); } -export function buildFocusSet(nodeId: string): Set { - 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 { + if (!nodeId || !graphRef.hasNode(nodeId)) { + logGroupedGraphSkip("focus", graphRef, nodeId); + return new Set(); + } + + const ranked = rankNeighborsInGraph(graphRef, nodeId).slice(0, MAX_FOCUS_NEIGHBORS); return new Set([nodeId, ...ranked]); } +export function buildFocusSet(nodeId: string): Set { + return buildFocusSetInGraph(graph, nodeId); +} + export function isEdgeInteractable( graphRef: GraphRef, interactionState: GraphInteractionState, @@ -361,7 +564,7 @@ export function isEdgeInteractable( return true; } - const focusIds = buildFocusSet(primaryNodeId); + const focusIds = buildFocusSetInGraph(graphRef, primaryNodeId); return focusIds.has(source) && focusIds.has(target); } @@ -381,6 +584,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 +610,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 +632,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 +641,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 +663,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 +882,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 +989,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 +1029,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 +1044,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 +1118,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 +1154,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 +1175,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 { + const positioned = new Map(); + 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(); + + 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 +1485,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 +1505,17 @@ function buildCommunityGroupedGraph(): GraphDisplayResult { communityMembers.set(communityId, bucket); }); + const communitySummaries = new Map(); + communityMembers.forEach((memberIds, communityId) => { const rankedMembers = memberIds .slice() @@ -1130,38 +1537,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 +1581,121 @@ function buildCommunityGroupedGraph(): GraphDisplayResult { groupedEdges.set(key, bucket); }); + const connectivityByCommunity = new Map(); + 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(); + const incidentEdgeStrengths = new Map>(); + 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(); + 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 +1708,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, }; @@ -1242,6 +1759,11 @@ export function resolveDisplayStateSnapshot( options?: { aggregationEnabled?: boolean; collapsedNeighborhoodNodeIds?: Iterable; + groupedViewAvailable?: boolean; + groupedViewReason?: string | null; + selectedNodeKind?: GraphSelectedNodeKind; + resolvedFocusedNodeId?: string | null; + focusedUnavailableReason?: string | null; }, ): GraphDisplayStateSnapshot { const aggregationEnabled = options?.aggregationEnabled ?? true; @@ -1249,10 +1771,18 @@ export function resolveDisplayStateSnapshot( Array.from(options?.collapsedNeighborhoodNodeIds ?? []).filter((nodeId) => typeof nodeId === "string"), ); const displayState = createEmptyDisplayState(selectedNodeId, aggregationEnabled); - displayState.groupedViewAvailable = computeGraphAnalyticsBase(graph, { + 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; @@ -1418,6 +1948,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, }; @@ -1449,6 +1984,7 @@ export function resolveDisplayGraph( export function createInteractionState( hoveredNodeId: string | null, selectedNodeId: string, + focusedNodeId: string, selectedEdgeId: string, activePath: string[], activePathEdgeIds: string[], @@ -1460,7 +1996,7 @@ export function createInteractionState( hoveredNodeId, selectedNodeId, selectedEdgeId, - focusedNodeId: selectedNodeId, + focusedNodeId, activePath, activePathEdgeIds, viewMode, diff --git a/explorer/src/workspaces/GraphWorkspace/graphTheme.ts b/explorer/src/workspaces/GraphWorkspace/graphTheme.ts index 6d6e8968..a96429a0 100644 --- a/explorer/src/workspaces/GraphWorkspace/graphTheme.ts +++ b/explorer/src/workspaces/GraphWorkspace/graphTheme.ts @@ -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", diff --git a/explorer/src/workspaces/GraphWorkspace/scene.ts b/explorer/src/workspaces/GraphWorkspace/scene.ts index ae70de08..07dbf515 100644 --- a/explorer/src/workspaces/GraphWorkspace/scene.ts +++ b/explorer/src/workspaces/GraphWorkspace/scene.ts @@ -47,6 +47,7 @@ export interface GraphSceneProps extends GraphSceneEventMap { displayMeta: GraphDisplayMeta; displayState?: GraphDisplayStateSnapshot; selectedNodeId: string; + focusedNodeId: string; selectedEdgeId: string; activePath?: string[]; activePathEdgeIds?: string[]; diff --git a/explorer/src/workspaces/GraphWorkspace/types.ts b/explorer/src/workspaces/GraphWorkspace/types.ts index 97065bb0..ecb5bbf2 100644 --- a/explorer/src/workspaces/GraphWorkspace/types.ts +++ b/explorer/src/workspaces/GraphWorkspace/types.ts @@ -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";