mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Merge pull request #493 from Hawksight-AI/feat/explorer-grouped-view
Feat/explorer grouped view
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useCallback, forwardRef, useImperativeHandle, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useCallback, forwardRef, useImperativeHandle, useState, type ReactNode } from "react";
|
||||||
import type Graph from "graphology";
|
|
||||||
import Sigma from "sigma";
|
import Sigma from "sigma";
|
||||||
import FA2Layout from "graphology-layout-forceatlas2/worker";
|
import FA2Layout from "graphology-layout-forceatlas2/worker";
|
||||||
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
||||||
@@ -20,7 +19,7 @@ import {
|
|||||||
zoomTierAtLeast,
|
zoomTierAtLeast,
|
||||||
} from "./graphTheme";
|
} from "./graphTheme";
|
||||||
import {
|
import {
|
||||||
buildFocusSet,
|
buildFocusSetInGraph,
|
||||||
buildEdgeEndpointSet,
|
buildEdgeEndpointSet,
|
||||||
buildPathEdgeSet,
|
buildPathEdgeSet,
|
||||||
collectInteractionRefreshTargets,
|
collectInteractionRefreshTargets,
|
||||||
@@ -82,6 +81,7 @@ export interface GraphCanvasProps {
|
|||||||
onNodeClick: (nodeId: string) => void;
|
onNodeClick: (nodeId: string) => void;
|
||||||
onEdgeClick?: (edgeId: string) => void;
|
onEdgeClick?: (edgeId: string) => void;
|
||||||
selectedNodeId: string;
|
selectedNodeId: string;
|
||||||
|
focusedNodeId: string;
|
||||||
selectedEdgeId: string;
|
selectedEdgeId: string;
|
||||||
activePath?: string[];
|
activePath?: string[];
|
||||||
activePathEdgeIds?: 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 = {
|
const SIGMA_SETTINGS = {
|
||||||
allowInvalidContainer: true,
|
allowInvalidContainer: true,
|
||||||
labelRenderedSizeThreshold: 6,
|
labelRenderedSizeThreshold: 6,
|
||||||
@@ -784,38 +799,96 @@ function drawNodeBadge(
|
|||||||
context.fillText(label, badgeX, badgeY + 0.5);
|
context.fillText(label, badgeX, badgeY + 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySceneState(
|
type ReducerSceneState = {
|
||||||
sigma: Sigma,
|
zoomTier: GraphZoomTier;
|
||||||
displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
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,
|
interactionState: GraphInteractionState,
|
||||||
analyticsSnapshot: GraphAnalyticsSnapshot | null,
|
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?: {
|
refreshTargets?: {
|
||||||
nodes?: string[];
|
nodes?: string[];
|
||||||
edges?: 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) => {
|
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 attrs = data as NodeAttributes;
|
||||||
const state = resolveNodeVisualState(
|
const state = resolveNodeVisualState(
|
||||||
node,
|
node,
|
||||||
zoomTier,
|
currentState.zoomTier,
|
||||||
hoveredNodeId,
|
currentState.hoveredNodeId,
|
||||||
selectedNodeId,
|
currentState.selectedNodeId,
|
||||||
selectedEdgeId,
|
currentState.selectedEdgeId,
|
||||||
focusIds,
|
currentState.focusIds,
|
||||||
edgeEndpointIds,
|
currentState.edgeEndpointIds,
|
||||||
pathNodeIds,
|
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 {
|
return {
|
||||||
...data,
|
...data,
|
||||||
@@ -835,22 +908,45 @@ function applySceneState(
|
|||||||
});
|
});
|
||||||
|
|
||||||
sigma.setSetting("edgeReducer", (edge, data) => {
|
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 attrs = data as EdgeAttributes;
|
||||||
const [source, target] = displayGraph.extremities(edge);
|
const [source, target] = currentGraph.extremities(edge);
|
||||||
const stableEdgeId = String(edge);
|
const stableEdgeId = String(edge);
|
||||||
const state = resolveEdgeVisualState(
|
const state = resolveEdgeVisualState(
|
||||||
stableEdgeId,
|
stableEdgeId,
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
zoomTier,
|
currentState.zoomTier,
|
||||||
hoveredNodeId,
|
currentState.hoveredNodeId,
|
||||||
selectedNodeId,
|
currentState.selectedNodeId,
|
||||||
selectedEdgeId,
|
currentState.selectedEdgeId,
|
||||||
focusIds,
|
currentState.focusIds,
|
||||||
pathEdgeIds,
|
currentState.pathEdgeIds,
|
||||||
overviewBackboneEdgeIds,
|
currentState.overviewBackboneEdgeIds,
|
||||||
|
);
|
||||||
|
const style = resolveEdgeElementStyle(
|
||||||
|
GRAPH_THEME,
|
||||||
|
currentState.zoomTier,
|
||||||
|
state,
|
||||||
|
attrs,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
);
|
);
|
||||||
const style = resolveEdgeElementStyle(GRAPH_THEME, zoomTier, state, attrs, source, target);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
@@ -899,12 +995,14 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
onNodeClick,
|
onNodeClick,
|
||||||
onEdgeClick,
|
onEdgeClick,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
|
focusedNodeId,
|
||||||
selectedEdgeId,
|
selectedEdgeId,
|
||||||
activePath = [],
|
activePath = [],
|
||||||
activePathEdgeIds = [],
|
activePathEdgeIds = [],
|
||||||
effectsState,
|
effectsState,
|
||||||
temporalState,
|
temporalState,
|
||||||
isLayoutRunning,
|
isLayoutRunning,
|
||||||
|
onLayoutRunningChange,
|
||||||
viewMode,
|
viewMode,
|
||||||
className,
|
className,
|
||||||
showFitViewButton = true,
|
showFitViewButton = true,
|
||||||
@@ -930,6 +1028,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
const displayMetaRef = useRef(displayMeta);
|
const displayMetaRef = useRef(displayMeta);
|
||||||
const graphVersionRef = useRef(graphVersion);
|
const graphVersionRef = useRef(graphVersion);
|
||||||
const selectedNodeIdRef = useRef(selectedNodeId);
|
const selectedNodeIdRef = useRef(selectedNodeId);
|
||||||
|
const focusedNodeIdRef = useRef(focusedNodeId);
|
||||||
const viewModeRef = useRef(viewMode);
|
const viewModeRef = useRef(viewMode);
|
||||||
const onNodeClickRef = useRef(onNodeClick);
|
const onNodeClickRef = useRef(onNodeClick);
|
||||||
const onEdgeClickRef = useRef(onEdgeClick);
|
const onEdgeClickRef = useRef(onEdgeClick);
|
||||||
@@ -943,12 +1042,18 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
const layoutSyncFrameRef = useRef<number | null>(null);
|
const layoutSyncFrameRef = useRef<number | null>(null);
|
||||||
const layoutSyncTickRef = useRef(0);
|
const layoutSyncTickRef = useRef(0);
|
||||||
const deferredFocusFrameRef = useRef<number | null>(null);
|
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;
|
displayGraphRef.current = displayGraph;
|
||||||
displayStateRef.current = displayState;
|
displayStateRef.current = displayState;
|
||||||
displayMetaRef.current = displayMeta;
|
displayMetaRef.current = displayMeta;
|
||||||
graphVersionRef.current = graphVersion;
|
graphVersionRef.current = graphVersion;
|
||||||
selectedNodeIdRef.current = selectedNodeId;
|
selectedNodeIdRef.current = selectedNodeId;
|
||||||
|
focusedNodeIdRef.current = focusedNodeId;
|
||||||
viewModeRef.current = viewMode;
|
viewModeRef.current = viewMode;
|
||||||
onNodeClickRef.current = onNodeClick;
|
onNodeClickRef.current = onNodeClick;
|
||||||
onEdgeClickRef.current = onEdgeClick;
|
onEdgeClickRef.current = onEdgeClick;
|
||||||
@@ -972,6 +1077,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
() => createInteractionState(
|
() => createInteractionState(
|
||||||
hoveredNodeId,
|
hoveredNodeId,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
|
focusedNodeId,
|
||||||
selectedEdgeId,
|
selectedEdgeId,
|
||||||
activePath,
|
activePath,
|
||||||
activePathEdgeIds,
|
activePathEdgeIds,
|
||||||
@@ -979,7 +1085,17 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
zoomTier,
|
zoomTier,
|
||||||
isLayoutRunning,
|
isLayoutRunning,
|
||||||
),
|
),
|
||||||
[activePath, activePathEdgeIds, hoveredNodeId, isLayoutRunning, selectedEdgeId, selectedNodeId, viewMode, zoomTier],
|
[
|
||||||
|
activePath,
|
||||||
|
activePathEdgeIds,
|
||||||
|
focusedNodeId,
|
||||||
|
hoveredNodeId,
|
||||||
|
isLayoutRunning,
|
||||||
|
selectedEdgeId,
|
||||||
|
selectedNodeId,
|
||||||
|
viewMode,
|
||||||
|
zoomTier,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const interactionStateRef = useRef<GraphInteractionState>(interactionState);
|
const interactionStateRef = useRef<GraphInteractionState>(interactionState);
|
||||||
interactionStateRef.current = interactionState;
|
interactionStateRef.current = interactionState;
|
||||||
@@ -993,6 +1109,12 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
}),
|
}),
|
||||||
[displayGraph, shouldComputeCentrality, shouldComputeCommunities],
|
[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>(() => ({
|
const displayFitSignature = useMemo<DisplayFitSignature>(() => ({
|
||||||
graphVersion,
|
graphVersion,
|
||||||
viewMode,
|
viewMode,
|
||||||
@@ -1175,6 +1297,63 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
});
|
});
|
||||||
}, [animateCameraToBounds, fitDisplayGraphInView]);
|
}, [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 focusNodeInView = useCallback((nodeId: string) => {
|
||||||
const sigma = sigmaRef.current;
|
const sigma = sigmaRef.current;
|
||||||
if (!sigma) {
|
if (!sigma) {
|
||||||
@@ -1236,12 +1415,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
onEdgeSelectionChange: (edgeId: string) => onEdgeClickRef.current?.(edgeId),
|
onEdgeSelectionChange: (edgeId: string) => onEdgeClickRef.current?.(edgeId),
|
||||||
focusNodeInView,
|
focusNodeInView,
|
||||||
centerSelectionInView,
|
centerSelectionInView,
|
||||||
|
centerGroupedSelectionInView,
|
||||||
fitCurrentView,
|
fitCurrentView,
|
||||||
dispatchAction,
|
dispatchAction,
|
||||||
};
|
};
|
||||||
behaviorContextRef.current = context;
|
behaviorContextRef.current = context;
|
||||||
return context;
|
return context;
|
||||||
}, [centerSelectionInView, dispatchAction, fitCurrentView, focusNodeInView]);
|
}, [centerGroupedSelectionInView, centerSelectionInView, dispatchAction, fitCurrentView, focusNodeInView]);
|
||||||
|
|
||||||
const dispatchToBehaviors = useCallback((
|
const dispatchToBehaviors = useCallback((
|
||||||
hook: "onNodeEnter" | "onNodeLeave" | "onNodeClick" | "onEdgeClick" | "onStageClick" | "onCameraChange",
|
hook: "onNodeEnter" | "onNodeLeave" | "onNodeClick" | "onEdgeClick" | "onStageClick" | "onCameraChange",
|
||||||
@@ -1462,6 +1642,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
window.cancelAnimationFrame(deferredFocusFrameRef.current);
|
window.cancelAnimationFrame(deferredFocusFrameRef.current);
|
||||||
deferredFocusFrameRef.current = null;
|
deferredFocusFrameRef.current = null;
|
||||||
}
|
}
|
||||||
|
reducerWarningStateRef.current.missingEdges.clear();
|
||||||
|
reducerWarningStateRef.current.missingNodes.clear();
|
||||||
sigma.setCustomBBox(null);
|
sigma.setCustomBBox(null);
|
||||||
sigma.setGraph(displayGraph);
|
sigma.setGraph(displayGraph);
|
||||||
appliedGraphVersionRef.current = graphVersion;
|
appliedGraphVersionRef.current = graphVersion;
|
||||||
@@ -1501,26 +1683,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
order: displayFitSignature.displayGraph.order,
|
order: displayFitSignature.displayGraph.order,
|
||||||
size: displayFitSignature.displayGraph.size,
|
size: displayFitSignature.displayGraph.size,
|
||||||
});
|
});
|
||||||
if (selectedNodeIdRef.current && viewModeRef.current === "focused") {
|
if (focusedNodeIdRef.current && viewModeRef.current === "focused") {
|
||||||
debugGraphRuntime("initial-focused-fit", {
|
debugGraphRuntime("initial-focused-fit", {
|
||||||
graphVersion,
|
graphVersion,
|
||||||
nodeId: selectedNodeIdRef.current,
|
nodeId: focusedNodeIdRef.current,
|
||||||
});
|
});
|
||||||
focusNodeInView(selectedNodeIdRef.current);
|
focusNodeInView(focusedNodeIdRef.current);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedNodeIdRef.current) {
|
|
||||||
debugGraphRuntime("initial-focus-node", {
|
|
||||||
graphVersion,
|
|
||||||
nodeId: selectedNodeIdRef.current,
|
|
||||||
});
|
|
||||||
centerSelectionInView(selectedNodeIdRef.current);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugGraphRuntime("initial-fit-view", {
|
debugGraphRuntime("initial-fit-view", {
|
||||||
graphVersion,
|
graphVersion,
|
||||||
|
selectedNodeId: selectedNodeIdRef.current || null,
|
||||||
|
viewMode: viewModeRef.current,
|
||||||
});
|
});
|
||||||
dispatchAction({ type: "fitView" });
|
dispatchAction({ type: "fitView" });
|
||||||
});
|
});
|
||||||
@@ -1607,9 +1782,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
? collectInteractionRefreshTargets(displayGraph, previousInteractionState, interactionState)
|
? collectInteractionRefreshTargets(displayGraph, previousInteractionState, interactionState)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
applySceneState(sigma, displayGraph, interactionState, analyticsSnapshot, refreshTargets);
|
applySceneState(sigma, reducerSceneStateRef, reducerWarningStateRef, refreshTargets);
|
||||||
previousInteractionStateRef.current = interactionState;
|
previousInteractionStateRef.current = interactionState;
|
||||||
}, [analyticsSnapshot, displayGraph, interactionState]);
|
}, [displayGraph, interactionState, reducerSceneStateRef]);
|
||||||
|
|
||||||
const drawOverlayFrame = useCallback(() => {
|
const drawOverlayFrame = useCallback(() => {
|
||||||
const sigma = sigmaRef.current;
|
const sigma = sigmaRef.current;
|
||||||
@@ -1637,7 +1812,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
context.clearRect(0, 0, rect.width, rect.height);
|
context.clearRect(0, 0, rect.width, rect.height);
|
||||||
|
|
||||||
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
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 edgeEndpointIds = buildEdgeEndpointSet(displayGraph, interactionState.selectedEdgeId);
|
||||||
const pathNodeIds = new Set(interactionState.activePath);
|
const pathNodeIds = new Set(interactionState.activePath);
|
||||||
const pathSegments = collectPathSegments(
|
const pathSegments = collectPathSegments(
|
||||||
@@ -1677,7 +1858,15 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
nodesToDecorate.forEach((nodeId) => {
|
nodesToDecorate.forEach((nodeId) => {
|
||||||
const displayData = sigma.getNodeDisplayData(nodeId);
|
const displayData = sigma.getNodeDisplayData(nodeId);
|
||||||
if (!displayData) return;
|
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(
|
const state = resolveNodeVisualState(
|
||||||
nodeId,
|
nodeId,
|
||||||
interactionState.zoomTier,
|
interactionState.zoomTier,
|
||||||
@@ -1774,6 +1963,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sigma = sigmaRef.current;
|
const sigma = sigmaRef.current;
|
||||||
const layoutTargetGraph = displayMeta.layoutMode === "owned" ? displayGraph : graph;
|
const layoutTargetGraph = displayMeta.layoutMode === "owned" ? displayGraph : graph;
|
||||||
|
const usingGroupedOwnedLayout = displayMeta.layoutMode === "owned" && viewMode === "grouped";
|
||||||
const targetKey = displayMeta.layoutMode === "owned"
|
const targetKey = displayMeta.layoutMode === "owned"
|
||||||
? `display:${graphVersion}:${viewMode}`
|
? `display:${graphVersion}:${viewMode}`
|
||||||
: `store:${displayMeta.layoutMode}`;
|
: `store:${displayMeta.layoutMode}`;
|
||||||
@@ -1781,6 +1971,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
|
|
||||||
if (!isLayoutRunning) {
|
if (!isLayoutRunning) {
|
||||||
fa2Ref.current?.stop();
|
fa2Ref.current?.stop();
|
||||||
|
if (groupedLayoutSettleTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
|
||||||
|
groupedLayoutSettleTimeoutRef.current = null;
|
||||||
|
}
|
||||||
if (layoutSyncFrameRef.current !== null) {
|
if (layoutSyncFrameRef.current !== null) {
|
||||||
window.cancelAnimationFrame(layoutSyncFrameRef.current);
|
window.cancelAnimationFrame(layoutSyncFrameRef.current);
|
||||||
layoutSyncFrameRef.current = null;
|
layoutSyncFrameRef.current = null;
|
||||||
@@ -1790,7 +1984,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
|
|
||||||
if (!fa2Ref.current || currentTargetKey !== targetKey) {
|
if (!fa2Ref.current || currentTargetKey !== targetKey) {
|
||||||
fa2Ref.current?.kill();
|
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;
|
nextLayout.__targetKey = targetKey;
|
||||||
fa2Ref.current = nextLayout;
|
fa2Ref.current = nextLayout;
|
||||||
debugGraphRuntime("layout-target-changed", {
|
debugGraphRuntime("layout-target-changed", {
|
||||||
@@ -1804,6 +2001,25 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
|
|
||||||
fa2Ref.current.start();
|
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) {
|
if (displayMeta.layoutMode === "mirrored" && sigma) {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
@@ -1833,6 +2049,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
if (groupedLayoutSettleTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
|
||||||
|
groupedLayoutSettleTimeoutRef.current = null;
|
||||||
|
}
|
||||||
if (layoutSyncFrameRef.current !== null) {
|
if (layoutSyncFrameRef.current !== null) {
|
||||||
window.cancelAnimationFrame(layoutSyncFrameRef.current);
|
window.cancelAnimationFrame(layoutSyncFrameRef.current);
|
||||||
layoutSyncFrameRef.current = null;
|
layoutSyncFrameRef.current = null;
|
||||||
@@ -1842,12 +2062,20 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (groupedLayoutSettleTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
|
||||||
|
groupedLayoutSettleTimeoutRef.current = null;
|
||||||
|
}
|
||||||
fa2Ref.current?.stop();
|
fa2Ref.current?.stop();
|
||||||
};
|
};
|
||||||
}, [displayGraph, displayMeta.layoutMode, graphVersion, isLayoutRunning, viewMode]);
|
}, [displayGraph, displayMeta.layoutMode, graphVersion, isLayoutRunning, viewMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
if (groupedLayoutSettleTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
|
||||||
|
groupedLayoutSettleTimeoutRef.current = null;
|
||||||
|
}
|
||||||
if (layoutSyncFrameRef.current !== null) {
|
if (layoutSyncFrameRef.current !== null) {
|
||||||
window.cancelAnimationFrame(layoutSyncFrameRef.current);
|
window.cancelAnimationFrame(layoutSyncFrameRef.current);
|
||||||
layoutSyncFrameRef.current = null;
|
layoutSyncFrameRef.current = null;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { CSSProperties } from "react";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { graph } from "../../store/graphStore";
|
import { graph } from "../../store/graphStore";
|
||||||
import { GRAPH_THEME } from "./graphTheme";
|
import { GRAPH_THEME } from "./graphTheme";
|
||||||
|
import type { GraphSelectedNodeKind } from "./types";
|
||||||
|
|
||||||
export type LinkPrediction = {
|
export type LinkPrediction = {
|
||||||
target: string;
|
target: string;
|
||||||
@@ -20,6 +21,10 @@ export type PathResponse = {
|
|||||||
|
|
||||||
export interface GraphInspectorPanelProps {
|
export interface GraphInspectorPanelProps {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
inspectableNodeId?: string | null;
|
||||||
|
selectedNodeKind?: GraphSelectedNodeKind;
|
||||||
|
canActivateFocused?: boolean;
|
||||||
|
focusedUnavailableReason?: string | null;
|
||||||
predictions: LinkPrediction[];
|
predictions: LinkPrediction[];
|
||||||
predictionType: string;
|
predictionType: string;
|
||||||
onPredictionTypeChange: (value: string) => void;
|
onPredictionTypeChange: (value: string) => void;
|
||||||
@@ -148,6 +153,10 @@ function PathFlowViz({
|
|||||||
|
|
||||||
export function GraphInspectorPanel({
|
export function GraphInspectorPanel({
|
||||||
nodeId,
|
nodeId,
|
||||||
|
inspectableNodeId,
|
||||||
|
selectedNodeKind = "none",
|
||||||
|
canActivateFocused = false,
|
||||||
|
focusedUnavailableReason = null,
|
||||||
predictions,
|
predictions,
|
||||||
predictionType,
|
predictionType,
|
||||||
onPredictionTypeChange,
|
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 (
|
return (
|
||||||
<div style={{ padding: 24, color: "#8b949e", fontSize: 13, lineHeight: 1.6 }}>
|
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
Selected item is not available for inspection in the current graph.
|
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||||
</div>
|
<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;
|
color?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
label?: 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={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
<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={{ 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>
|
</div>
|
||||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||||
{String(attributes?.label ?? nodeId)}
|
{String(attributes?.label ?? effectiveNodeId)}
|
||||||
</h3>
|
</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 }}>
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
|
||||||
{attributes?.valid_from || attributes?.valid_until ? (
|
{attributes?.valid_from || attributes?.valid_until ? (
|
||||||
<span style={subtleChipStyle}>temporal</span>
|
<span style={subtleChipStyle}>temporal</span>
|
||||||
@@ -234,7 +280,7 @@ export function GraphInspectorPanel({
|
|||||||
<button
|
<button
|
||||||
style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }}
|
style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }}
|
||||||
onClick={onRunPredictions}
|
onClick={onRunPredictions}
|
||||||
disabled={isRunningPredictions}
|
disabled={isRunningPredictions || !actionNodeId}
|
||||||
>
|
>
|
||||||
{isRunningPredictions ? (
|
{isRunningPredictions ? (
|
||||||
<Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} />
|
<Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} />
|
||||||
@@ -242,10 +288,10 @@ export function GraphInspectorPanel({
|
|||||||
{isRunningPredictions ? "Running…" : "Run Link Prediction"}
|
{isRunningPredictions ? "Running…" : "Run Link Prediction"}
|
||||||
</button>
|
</button>
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}>
|
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")} disabled={!actionNodeId}>
|
||||||
Provenance JSON
|
Provenance JSON
|
||||||
</button>
|
</button>
|
||||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")}>
|
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")} disabled={!actionNodeId}>
|
||||||
Provenance MD
|
Provenance MD
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,7 +313,7 @@ export function GraphInspectorPanel({
|
|||||||
placeholder="Target node ID"
|
placeholder="Target node ID"
|
||||||
style={inputStyle}
|
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 ? (
|
{pathResult?.path?.length ? (
|
||||||
<PathFlowViz
|
<PathFlowViz
|
||||||
@@ -386,6 +432,14 @@ const inputStyle: CSSProperties = {
|
|||||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
|
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 = {
|
const actionButtonStyle: CSSProperties = {
|
||||||
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
|
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
|
|||||||
@@ -464,6 +464,7 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
|
|||||||
displayState={displayResult.state}
|
displayState={displayResult.state}
|
||||||
selectedEdgeId=""
|
selectedEdgeId=""
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
|
focusedNodeId={viewMode === "focused" ? selectedNodeId : ""}
|
||||||
activePath={activePath}
|
activePath={activePath}
|
||||||
activePathEdgeIds={EMPTY_PATH}
|
activePathEdgeIds={EMPTY_PATH}
|
||||||
effectsState={STAGE_EFFECTS_STATE}
|
effectsState={STAGE_EFFECTS_STATE}
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import { useLoadGraph, useReloadGraph } from "./useLoadGraph";
|
|||||||
import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
|
import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
|
||||||
import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading";
|
import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading";
|
||||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||||
import { resolveDisplayGraph, resolveDisplayStateSnapshot } from "./graphSceneState";
|
import { checkGroupedViewAvailability, resolveDisplayGraph, resolveDisplayStateSnapshot, resolveGroupedDisplayNodeId, resolveGroupedDisplayStateSnapshot } from "./graphSceneState";
|
||||||
import { computeGraphAnalyticsBase } from "./graphAnalytics";
|
|
||||||
import {
|
import {
|
||||||
type GraphPlugin,
|
type GraphPlugin,
|
||||||
type GraphPluginActionRequest,
|
type GraphPluginActionRequest,
|
||||||
@@ -34,6 +33,7 @@ import type {
|
|||||||
GraphLoadProgress,
|
GraphLoadProgress,
|
||||||
GraphLoadSummary,
|
GraphLoadSummary,
|
||||||
GraphSelectedEdgeState,
|
GraphSelectedEdgeState,
|
||||||
|
GraphSelectedNodeKind,
|
||||||
GraphSelectedNodeState,
|
GraphSelectedNodeState,
|
||||||
GraphTemporalState,
|
GraphTemporalState,
|
||||||
GraphViewMode,
|
GraphViewMode,
|
||||||
@@ -542,6 +542,12 @@ function buildSelectedNodeState(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FocusResolution = {
|
||||||
|
kind: GraphSelectedNodeKind;
|
||||||
|
resolvedNodeId: string | null;
|
||||||
|
reason: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
function buildSelectedEdgeState(
|
function buildSelectedEdgeState(
|
||||||
edgeId: string,
|
edgeId: string,
|
||||||
displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||||
@@ -678,6 +684,8 @@ function collectPluginOverlays(
|
|||||||
|
|
||||||
export function GraphWorkspace() {
|
export function GraphWorkspace() {
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||||||
|
const [focusedNodeId, setFocusedNodeId] = useState("");
|
||||||
|
const [lastGroupedSelectedNodeId, setLastGroupedSelectedNodeId] = useState("");
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState("");
|
const [selectedEdgeId, setSelectedEdgeId] = useState("");
|
||||||
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
|
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
|
||||||
const [graphReady, setGraphReady] = useState(false);
|
const [graphReady, setGraphReady] = useState(false);
|
||||||
@@ -845,16 +853,27 @@ export function GraphWorkspace() {
|
|||||||
};
|
};
|
||||||
}, [debouncedTime, isLoading]);
|
}, [debouncedTime, isLoading]);
|
||||||
|
|
||||||
const resolveNodeIdForFocusedMode = useCallback((nodeId: string): string | null => {
|
const resolveNodeIdForFocusedMode = useCallback((
|
||||||
|
nodeId: string,
|
||||||
|
displayGraphCandidate?: GraphSceneRuntime["displayGraph"] | null,
|
||||||
|
): FocusResolution => {
|
||||||
if (!nodeId) {
|
if (!nodeId) {
|
||||||
return null;
|
return {
|
||||||
|
kind: "none",
|
||||||
|
resolvedNodeId: null,
|
||||||
|
reason: "Select a node to inspect in Focused mode.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (graph.hasNode(nodeId)) {
|
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)) {
|
if (currentDisplayGraph.hasNode(nodeId)) {
|
||||||
const displayAttrs = currentDisplayGraph.getNodeAttributes(nodeId) as NodeAttributes;
|
const displayAttrs = currentDisplayGraph.getNodeAttributes(nodeId) as NodeAttributes;
|
||||||
const communityGroup = displayAttrs.properties?.__communityGroup as
|
const communityGroup = displayAttrs.properties?.__communityGroup as
|
||||||
@@ -866,40 +885,131 @@ export function GraphWorkspace() {
|
|||||||
|
|
||||||
const anchorNodeId = communityGroup?.anchorNodeId || communityGroup?.sampleNodeIds?.[0] || "";
|
const anchorNodeId = communityGroup?.anchorNodeId || communityGroup?.sampleNodeIds?.[0] || "";
|
||||||
if (anchorNodeId && graph.hasNode(anchorNodeId)) {
|
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 focusedSelectionResolution = useMemo(
|
||||||
const canonicalNodeId = resolveNodeIdForFocusedMode(selectedNodeId);
|
() => resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph),
|
||||||
if (!canonicalNodeId) {
|
[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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canonicalNodeId !== selectedNodeId) {
|
if (nextViewMode === "grouped") {
|
||||||
setSelectedNodeId(canonicalNodeId);
|
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]);
|
setFocusedNodeId("");
|
||||||
const [canActivateFocusedMode, setCanActivateFocusedMode] = useState(false);
|
setSelectedNodeId((currentSelectedNodeId) => (
|
||||||
useEffect(() => {
|
currentSelectedNodeId && graph.hasNode(currentSelectedNodeId) ? currentSelectedNodeId : ""
|
||||||
setCanActivateFocusedMode(Boolean(resolveNodeIdForFocusedMode(selectedNodeId)));
|
));
|
||||||
}, [pluginRuntimeVersion, resolveNodeIdForFocusedMode, selectedNodeId]);
|
setViewMode("full");
|
||||||
|
}, [
|
||||||
|
aggregationEnabled,
|
||||||
|
collapsedNeighborhoodNodeIds,
|
||||||
|
focusedNodeId,
|
||||||
|
graphVersion,
|
||||||
|
groupedDisplayCandidate,
|
||||||
|
groupedViewAvailable,
|
||||||
|
groupedViewReason,
|
||||||
|
lastGroupedSelectedNodeId,
|
||||||
|
resolveNodeIdForFocusedMode,
|
||||||
|
selectedNodeId,
|
||||||
|
]);
|
||||||
|
|
||||||
const focusNode = useCallback((nodeId: string) => {
|
const focusNode = useCallback((nodeId: string) => {
|
||||||
if (!nodeId) {
|
if (!nodeId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedNodeId(nodeId);
|
const currentDisplayGraph = pluginRuntimeRef.current?.displayGraph ?? graph;
|
||||||
|
const nextSelectedNodeId = nodeId;
|
||||||
|
|
||||||
|
if (!graph.hasNode(nodeId) && currentDisplayGraph.hasNode(nodeId)) {
|
||||||
|
setLastGroupedSelectedNodeId(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedNodeId(nextSelectedNodeId);
|
||||||
setSelectedEdgeId("");
|
setSelectedEdgeId("");
|
||||||
setPathResult(null);
|
setPathResult(null);
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setSearchError("");
|
setSearchError("");
|
||||||
if (viewMode === "focused") {
|
if (viewMode === "focused" && graph.hasNode(nextSelectedNodeId)) {
|
||||||
|
setFocusedNodeId(nextSelectedNodeId);
|
||||||
setIsLayoutRunning(false);
|
setIsLayoutRunning(false);
|
||||||
}
|
}
|
||||||
}, [viewMode]);
|
}, [viewMode]);
|
||||||
@@ -931,14 +1041,14 @@ export function GraphWorkspace() {
|
|||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
const handleRunPredictions = useCallback(async () => {
|
const handleRunPredictions = useCallback(async () => {
|
||||||
if (!selectedNodeId) return;
|
if (!inspectableNodeId) return;
|
||||||
setIsRunningPredictions(true);
|
setIsRunningPredictions(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/enrich/links", {
|
const response = await fetch("/api/enrich/links", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
node_id: selectedNodeId,
|
node_id: inspectableNodeId,
|
||||||
top_n: 6,
|
top_n: 6,
|
||||||
candidate_type: predictionType || undefined,
|
candidate_type: predictionType || undefined,
|
||||||
min_score: 0,
|
min_score: 0,
|
||||||
@@ -955,13 +1065,13 @@ export function GraphWorkspace() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsRunningPredictions(false);
|
setIsRunningPredictions(false);
|
||||||
}
|
}
|
||||||
}, [predictionType, selectedNodeId]);
|
}, [inspectableNodeId, predictionType]);
|
||||||
|
|
||||||
const handleTracePath = useCallback(async () => {
|
const handleTracePath = useCallback(async () => {
|
||||||
if (!selectedNodeId || !pathTargetId.trim()) return;
|
if (!inspectableNodeId || !pathTargetId.trim()) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(`Path lookup failed with status ${response.status}`);
|
throw new Error(`Path lookup failed with status ${response.status}`);
|
||||||
@@ -978,12 +1088,12 @@ export function GraphWorkspace() {
|
|||||||
console.error("[GraphWorkspace] path trace failed", pathError);
|
console.error("[GraphWorkspace] path trace failed", pathError);
|
||||||
setPathResult(null);
|
setPathResult(null);
|
||||||
}
|
}
|
||||||
}, [pathTargetId, selectedNodeId]);
|
}, [inspectableNodeId, pathTargetId]);
|
||||||
|
|
||||||
const handleDownloadProvenance = useCallback(async (format: "json" | "markdown") => {
|
const handleDownloadProvenance = useCallback(async (format: "json" | "markdown") => {
|
||||||
if (!selectedNodeId) return;
|
if (!inspectableNodeId) return;
|
||||||
const suffix = format === "markdown" ? "markdown" : "json";
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(`Provenance report failed with status ${response.status}`);
|
throw new Error(`Provenance report failed with status ${response.status}`);
|
||||||
}
|
}
|
||||||
@@ -991,12 +1101,12 @@ export function GraphWorkspace() {
|
|||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement("a");
|
const anchor = document.createElement("a");
|
||||||
anchor.href = url;
|
anchor.href = url;
|
||||||
anchor.download = `${selectedNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
|
anchor.download = `${inspectableNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
|
||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
anchor.click();
|
anchor.click();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
document.body.removeChild(anchor);
|
document.body.removeChild(anchor);
|
||||||
}, [selectedNodeId]);
|
}, [inspectableNodeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
@@ -1050,6 +1160,8 @@ export function GraphWorkspace() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCollapsedNeighborhoodNodeIds([]);
|
setCollapsedNeighborhoodNodeIds([]);
|
||||||
|
setFocusedNodeId("");
|
||||||
|
setLastGroupedSelectedNodeId("");
|
||||||
}, [summary?.edgeCount, summary?.nodeCount]);
|
}, [summary?.edgeCount, summary?.nodeCount]);
|
||||||
|
|
||||||
const showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress));
|
const showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress));
|
||||||
@@ -1058,31 +1170,32 @@ export function GraphWorkspace() {
|
|||||||
const activePath = pathResult?.path ?? EMPTY_PATH;
|
const activePath = pathResult?.path ?? EMPTY_PATH;
|
||||||
const activePathEdgeIds = pathResult?.edge_ids ?? EMPTY_PATH;
|
const activePathEdgeIds = pathResult?.edge_ids ?? EMPTY_PATH;
|
||||||
const structuralSelectedNodeId = useMemo(() => {
|
const structuralSelectedNodeId = useMemo(() => {
|
||||||
|
if (viewMode === "focused") {
|
||||||
|
return focusedNodeId && graph.hasNode(focusedNodeId) ? focusedNodeId : "";
|
||||||
|
}
|
||||||
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (viewMode === "focused") {
|
|
||||||
return selectedNodeId;
|
|
||||||
}
|
|
||||||
return collapsedNeighborhoodNodeIds.includes(selectedNodeId) ? selectedNodeId : "";
|
return collapsedNeighborhoodNodeIds.includes(selectedNodeId) ? selectedNodeId : "";
|
||||||
}, [collapsedNeighborhoodNodeIds, selectedNodeId, viewMode]);
|
}, [collapsedNeighborhoodNodeIds, focusedNodeId, selectedNodeId, viewMode]);
|
||||||
const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH;
|
const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH;
|
||||||
const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH;
|
const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH;
|
||||||
const groupedViewAvailable = useMemo(
|
|
||||||
() => computeGraphAnalyticsBase(graph, { computeCommunities: true, computeCentrality: false }).communitiesByNode.size > 0,
|
|
||||||
[graphVersion],
|
|
||||||
);
|
|
||||||
const displayResult = useMemo(
|
const displayResult = useMemo(
|
||||||
() => resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
|
() => (
|
||||||
aggregationEnabled,
|
viewMode === "grouped"
|
||||||
collapsedNeighborhoodNodeIds,
|
? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
|
||||||
groupedViewAvailable,
|
aggregationEnabled,
|
||||||
}),
|
collapsedNeighborhoodNodeIds,
|
||||||
|
}))
|
||||||
|
: resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
|
||||||
|
aggregationEnabled,
|
||||||
|
collapsedNeighborhoodNodeIds,
|
||||||
|
})
|
||||||
|
),
|
||||||
[
|
[
|
||||||
aggregationEnabled,
|
aggregationEnabled,
|
||||||
collapsedNeighborhoodNodeIds,
|
collapsedNeighborhoodNodeIds,
|
||||||
graphVersion,
|
groupedDisplayCandidate,
|
||||||
groupedViewAvailable,
|
|
||||||
structuralActivePath,
|
structuralActivePath,
|
||||||
structuralActivePathEdgeIds,
|
structuralActivePathEdgeIds,
|
||||||
structuralSelectedNodeId,
|
structuralSelectedNodeId,
|
||||||
@@ -1090,14 +1203,59 @@ export function GraphWorkspace() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
const displayState = useMemo(
|
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,
|
aggregationEnabled,
|
||||||
collapsedNeighborhoodNodeIds,
|
collapsedNeighborhoodNodeIds,
|
||||||
|
displayResult.graph,
|
||||||
|
focusedSelectionResolution.kind,
|
||||||
|
focusedSelectionResolution.reason,
|
||||||
|
focusedSelectionResolution.resolvedNodeId,
|
||||||
groupedViewAvailable,
|
groupedViewAvailable,
|
||||||
}),
|
groupedViewReason,
|
||||||
[activePath, aggregationEnabled, collapsedNeighborhoodNodeIds, groupedViewAvailable, selectedNodeId, viewMode],
|
selectedNodeId,
|
||||||
|
viewMode,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const displayMeta = displayResult.meta;
|
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 previousDisplayGraphRef = useRef(displayResult.graph);
|
||||||
const previousDisplayStateRef = useRef(displayState);
|
const previousDisplayStateRef = useRef(displayState);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1128,7 +1286,7 @@ export function GraphWorkspace() {
|
|||||||
if (viewMode === "grouped") {
|
if (viewMode === "grouped") {
|
||||||
return displayState.groupedViewAvailable
|
return displayState.groupedViewAvailable
|
||||||
? "Communities compressed into grouped structure view"
|
? "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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1262,11 +1420,7 @@ export function GraphWorkspace() {
|
|||||||
focusNode(action.nodeId);
|
focusNode(action.nodeId);
|
||||||
return;
|
return;
|
||||||
case "setViewMode":
|
case "setViewMode":
|
||||||
if (action.viewMode === "focused") {
|
requestViewMode(action.viewMode);
|
||||||
activateFocusedMode();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setViewMode(action.viewMode);
|
|
||||||
return;
|
return;
|
||||||
case "collapseNeighborhood":
|
case "collapseNeighborhood":
|
||||||
if (!selectedNodeId) {
|
if (!selectedNodeId) {
|
||||||
@@ -1318,7 +1472,7 @@ export function GraphWorkspace() {
|
|||||||
setActiveDockPanelId((previous) => (previous === action.panelId ? null : previous));
|
setActiveDockPanelId((previous) => (previous === action.panelId ? null : previous));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [activateFocusedMode, focusNode, selectedNodeId, setEffectToggle]);
|
}, [focusNode, requestViewMode, selectedNodeId, setEffectToggle]);
|
||||||
|
|
||||||
const diagnosticsSnapshot = useMemo<GraphDiagnosticsSnapshot | null>(() => {
|
const diagnosticsSnapshot = useMemo<GraphDiagnosticsSnapshot | null>(() => {
|
||||||
if (!GRAPH_THEME.effects.diagnostics.enabledInDev || !graphDiagnosticsState) {
|
if (!GRAPH_THEME.effects.diagnostics.enabledInDev || !graphDiagnosticsState) {
|
||||||
@@ -1497,25 +1651,27 @@ export function GraphWorkspace() {
|
|||||||
label: "Full Graph",
|
label: "Full Graph",
|
||||||
title: "Return to the full graph context",
|
title: "Return to the full graph context",
|
||||||
active: viewMode === "full",
|
active: viewMode === "full",
|
||||||
onClick: () => setViewMode("full"),
|
onClick: () => requestViewMode("full"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "view-grouped",
|
id: "view-grouped",
|
||||||
label: "Grouped View",
|
label: "Grouped View",
|
||||||
title: displayState.groupedViewAvailable
|
title: displayState.groupedViewAvailable
|
||||||
? "Compress dense structure into detected communities"
|
? "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",
|
active: viewMode === "grouped",
|
||||||
disabled: !displayState.groupedViewAvailable,
|
disabled: !displayState.groupedViewAvailable,
|
||||||
onClick: () => setViewMode("grouped"),
|
onClick: () => requestViewMode("grouped"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "view-focused",
|
id: "view-focused",
|
||||||
label: "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",
|
active: viewMode === "focused",
|
||||||
disabled: viewMode !== "focused" && !canActivateFocusedMode,
|
disabled: viewMode !== "focused" && !canActivateFocusedMode,
|
||||||
onClick: activateFocusedMode,
|
onClick: () => requestViewMode("focused"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -1605,15 +1761,18 @@ export function GraphWorkspace() {
|
|||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
}, [
|
}, [
|
||||||
activateFocusedMode,
|
|
||||||
canActivateFocusedMode,
|
canActivateFocusedMode,
|
||||||
displayState.groupedViewAvailable,
|
displayState.groupedViewAvailable,
|
||||||
|
displayState.groupedViewReason,
|
||||||
handlePluginAction,
|
handlePluginAction,
|
||||||
hasGraphContent,
|
hasGraphContent,
|
||||||
isLayoutRunning,
|
isLayoutRunning,
|
||||||
pluginToolbarItems,
|
pluginToolbarItems,
|
||||||
reload,
|
reload,
|
||||||
|
requestViewMode,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
canActivateFocusedMode,
|
||||||
|
focusedSelectionResolution.reason,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
selectedNodeState,
|
selectedNodeState,
|
||||||
showLoadingOverlay,
|
showLoadingOverlay,
|
||||||
@@ -1629,6 +1788,7 @@ export function GraphWorkspace() {
|
|||||||
displayMeta,
|
displayMeta,
|
||||||
displayState,
|
displayState,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
|
focusedNodeId,
|
||||||
selectedEdgeId,
|
selectedEdgeId,
|
||||||
activePath,
|
activePath,
|
||||||
activePathEdgeIds,
|
activePathEdgeIds,
|
||||||
@@ -1882,6 +2042,10 @@ export function GraphWorkspace() {
|
|||||||
<Suspense fallback={<div style={inspectorFallbackStyle}>Loading inspector…</div>}>
|
<Suspense fallback={<div style={inspectorFallbackStyle}>Loading inspector…</div>}>
|
||||||
<LazyGraphInspectorPanel
|
<LazyGraphInspectorPanel
|
||||||
nodeId={selectedNodeId}
|
nodeId={selectedNodeId}
|
||||||
|
inspectableNodeId={inspectableNodeId || null}
|
||||||
|
selectedNodeKind={displayState.selectedNodeKind}
|
||||||
|
canActivateFocused={canActivateFocusedMode}
|
||||||
|
focusedUnavailableReason={displayState.focusedUnavailableReason}
|
||||||
predictions={predictions}
|
predictions={predictions}
|
||||||
predictionType={predictionType}
|
predictionType={predictionType}
|
||||||
onPredictionTypeChange={setPredictionType}
|
onPredictionTypeChange={setPredictionType}
|
||||||
|
|||||||
@@ -563,6 +563,19 @@ export function GraphWorkspaceShell() {
|
|||||||
return `${visibleSelectedNode.neighborCount} direct neighbors highlighted`;
|
return `${visibleSelectedNode.neighborCount} direct neighbors highlighted`;
|
||||||
}, [viewMode, visibleSelectedNode]);
|
}, [viewMode, visibleSelectedNode]);
|
||||||
|
|
||||||
|
const requestViewMode = useCallback((nextViewMode: GraphViewMode) => {
|
||||||
|
if (nextViewMode === "focused") {
|
||||||
|
if (!selectedNodeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setViewMode("focused");
|
||||||
|
setIsLayoutRunning(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewMode("full");
|
||||||
|
}, [selectedNodeId]);
|
||||||
|
|
||||||
const showLoadingOverlay =
|
const showLoadingOverlay =
|
||||||
isLoading
|
isLoading
|
||||||
|| isFetching
|
|| isFetching
|
||||||
@@ -639,8 +652,8 @@ export function GraphWorkspaceShell() {
|
|||||||
<div className="graph-toggle-cluster">
|
<div className="graph-toggle-cluster">
|
||||||
{selectedNodeId ? (
|
{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={() => 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={() => 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("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>
|
<span style={{ color: "#7f95b3", fontSize: 12 }}>Select a node to switch graph views</span>
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export const focusCameraBehavior: GraphBehavior = {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action.type === "centerGroupedSelection") {
|
||||||
|
context.centerGroupedSelectionInView(action.nodeId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,22 +2,35 @@ import type { GraphBehavior } from "./types";
|
|||||||
|
|
||||||
export function createSearchFocusBehavior(): GraphBehavior {
|
export function createSearchFocusBehavior(): GraphBehavior {
|
||||||
let lastSelectedNodeId = "";
|
let lastSelectedNodeId = "";
|
||||||
|
let lastViewMode = "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: "search-focus",
|
id: "search-focus",
|
||||||
attach: () => {},
|
attach: () => {},
|
||||||
detach: () => {
|
detach: () => {
|
||||||
lastSelectedNodeId = "";
|
lastSelectedNodeId = "";
|
||||||
|
lastViewMode = "";
|
||||||
},
|
},
|
||||||
onStateChange: (context, interactionState) => {
|
onStateChange: (context, interactionState) => {
|
||||||
const nextSelectedNodeId = interactionState.selectedNodeId;
|
const nextSelectedNodeId = interactionState.selectedNodeId;
|
||||||
|
const nextViewMode = interactionState.viewMode;
|
||||||
|
if (nextViewMode !== lastViewMode) {
|
||||||
|
lastViewMode = nextViewMode;
|
||||||
|
lastSelectedNodeId = nextSelectedNodeId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!nextSelectedNodeId || nextSelectedNodeId === lastSelectedNodeId) {
|
if (!nextSelectedNodeId || nextSelectedNodeId === lastSelectedNodeId) {
|
||||||
lastSelectedNodeId = nextSelectedNodeId;
|
lastSelectedNodeId = nextSelectedNodeId;
|
||||||
|
lastViewMode = nextViewMode;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSelectedNodeId = nextSelectedNodeId;
|
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 =
|
export type GraphBehaviorActionRequest =
|
||||||
| { type: "fitView" }
|
| { type: "fitView" }
|
||||||
| { type: "focusNode"; nodeId: string }
|
| { type: "focusNode"; nodeId: string }
|
||||||
| { type: "centerSelection"; nodeId: string };
|
| { type: "centerSelection"; nodeId: string }
|
||||||
|
| { type: "centerGroupedSelection"; nodeId: string };
|
||||||
|
|
||||||
export interface GraphBehaviorContext {
|
export interface GraphBehaviorContext {
|
||||||
sigma: Sigma;
|
sigma: Sigma;
|
||||||
@@ -19,6 +20,7 @@ export interface GraphBehaviorContext {
|
|||||||
onEdgeSelectionChange: (edgeId: string) => void;
|
onEdgeSelectionChange: (edgeId: string) => void;
|
||||||
focusNodeInView: (nodeId: string) => void;
|
focusNodeInView: (nodeId: string) => void;
|
||||||
centerSelectionInView: (nodeId: string) => void;
|
centerSelectionInView: (nodeId: string) => void;
|
||||||
|
centerGroupedSelectionInView: (nodeId: string) => void;
|
||||||
fitCurrentView: () => void;
|
fitCurrentView: () => void;
|
||||||
dispatchAction: (action: GraphBehaviorActionRequest) => void;
|
dispatchAction: (action: GraphBehaviorActionRequest) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ export function createViewModeSwitchBehavior(): GraphBehavior {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastViewMode = interactionState.viewMode;
|
lastViewMode = interactionState.viewMode;
|
||||||
const nextSelectedNodeId = interactionState.selectedNodeId;
|
const nextFocusedNodeId = interactionState.focusedNodeId;
|
||||||
|
|
||||||
if (interactionState.viewMode === "focused" && nextSelectedNodeId) {
|
if (interactionState.viewMode === "focused" && nextFocusedNodeId) {
|
||||||
context.dispatchAction({ type: "focusNode", nodeId: nextSelectedNodeId });
|
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,18 @@ import {
|
|||||||
type GraphNodeVisualState,
|
type GraphNodeVisualState,
|
||||||
type GraphTheme,
|
type GraphTheme,
|
||||||
type GraphZoomTier,
|
type GraphZoomTier,
|
||||||
|
hashString,
|
||||||
withAlpha,
|
withAlpha,
|
||||||
zoomTierAtLeast,
|
zoomTierAtLeast,
|
||||||
} from "./graphTheme";
|
} from "./graphTheme";
|
||||||
import { computeGraphAnalyticsBase } from "./graphAnalytics";
|
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 MAX_FOCUS_NEIGHBORS = GRAPH_THEME.focus.maxNeighbors;
|
||||||
const FOCUS_RING_CAPACITY = GRAPH_THEME.focus.ringCapacity;
|
const FOCUS_RING_CAPACITY = GRAPH_THEME.focus.ringCapacity;
|
||||||
@@ -28,6 +35,7 @@ const COLLAPSE_VISIBLE_NEIGHBORS = 8;
|
|||||||
const GROUP_SAMPLE_MEMBERS = 8;
|
const GROUP_SAMPLE_MEMBERS = 8;
|
||||||
const AGGREGATED_EDGE_PREFIX = "__agg__:";
|
const AGGREGATED_EDGE_PREFIX = "__agg__:";
|
||||||
const COMMUNITY_NODE_PREFIX = "__community__:";
|
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>;
|
type GraphRef = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||||
|
|
||||||
@@ -140,12 +148,185 @@ function createEmptyDisplayState(
|
|||||||
return {
|
return {
|
||||||
aggregationEnabled,
|
aggregationEnabled,
|
||||||
groupedViewAvailable: false,
|
groupedViewAvailable: false,
|
||||||
|
groupedViewReason: null,
|
||||||
selectedRootNodeId: selectedNodeId || null,
|
selectedRootNodeId: selectedNodeId || null,
|
||||||
selectedVisibleNeighborIds: [],
|
selectedVisibleNeighborIds: [],
|
||||||
selectedCollapsedNeighborIds: [],
|
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(
|
export function buildPathEdgeSet(
|
||||||
graphRef: GraphRef,
|
graphRef: GraphRef,
|
||||||
path: string[],
|
path: string[],
|
||||||
@@ -229,7 +410,7 @@ function collectImpactedNodeIds(
|
|||||||
const impacted = new Set<string>(interactionState.activePath);
|
const impacted = new Set<string>(interactionState.activePath);
|
||||||
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
||||||
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
|
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
|
||||||
buildFocusSet(primaryNodeId).forEach((nodeId) => impacted.add(nodeId));
|
buildFocusSetInGraph(graphRef, primaryNodeId).forEach((nodeId) => impacted.add(nodeId));
|
||||||
}
|
}
|
||||||
|
|
||||||
buildEdgeEndpointSet(graphRef, interactionState.selectedEdgeId)
|
buildEdgeEndpointSet(graphRef, interactionState.selectedEdgeId)
|
||||||
@@ -249,7 +430,7 @@ function collectImpactedEdgeKeys(
|
|||||||
const impacted = new Set<string>(buildPathEdgeSet(graphRef, interactionState.activePath, interactionState.activePathEdgeIds));
|
const impacted = new Set<string>(buildPathEdgeSet(graphRef, interactionState.activePath, interactionState.activePathEdgeIds));
|
||||||
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
||||||
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
|
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) {
|
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;
|
let weight = 0;
|
||||||
|
|
||||||
forEachDirectedEdgeBetween(graph, source, target, (_edgeId, attrs) => {
|
forEachDirectedEdgeBetween(graphRef, source, target, (_edgeId, attrs) => {
|
||||||
weight = Math.max(weight, Number(attrs?.weight ?? 0));
|
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));
|
weight = Math.max(weight, Number(attrs?.weight ?? 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
return weight;
|
return weight;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rankNeighbors(nodeId: string): string[] {
|
export function getEdgeWeightBetween(source: string, target: string): number {
|
||||||
return graph
|
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)
|
.neighbors(nodeId)
|
||||||
.map((neighborId) => ({
|
.map((neighborId) => ({
|
||||||
id: neighborId,
|
id: neighborId,
|
||||||
weight: getEdgeWeightBetween(nodeId, neighborId),
|
weight: getEdgeWeightBetweenInGraph(graphRef, nodeId, neighborId),
|
||||||
degree: graph.degree(neighborId),
|
degree: graphRef.hasNode(neighborId) ? graphRef.degree(neighborId) : 0,
|
||||||
}))
|
}))
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
if (right.weight !== left.weight) {
|
if (right.weight !== left.weight) {
|
||||||
@@ -333,11 +535,24 @@ export function rankNeighbors(nodeId: string): string[] {
|
|||||||
.map((item) => item.id);
|
.map((item) => item.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFocusSet(nodeId: string): Set<string> {
|
export function rankNeighbors(nodeId: string): string[] {
|
||||||
const ranked = rankNeighbors(nodeId).slice(0, MAX_FOCUS_NEIGHBORS);
|
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]);
|
return new Set<string>([nodeId, ...ranked]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildFocusSet(nodeId: string): Set<string> {
|
||||||
|
return buildFocusSetInGraph(graph, nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
export function isEdgeInteractable(
|
export function isEdgeInteractable(
|
||||||
graphRef: GraphRef,
|
graphRef: GraphRef,
|
||||||
interactionState: GraphInteractionState,
|
interactionState: GraphInteractionState,
|
||||||
@@ -361,7 +576,7 @@ export function isEdgeInteractable(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusIds = buildFocusSet(primaryNodeId);
|
const focusIds = buildFocusSetInGraph(graphRef, primaryNodeId);
|
||||||
return focusIds.has(source) && focusIds.has(target);
|
return focusIds.has(source) && focusIds.has(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,6 +596,7 @@ function resolveNodeColor(
|
|||||||
fallbackColor?: string,
|
fallbackColor?: string,
|
||||||
) {
|
) {
|
||||||
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
|
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
|
||||||
|
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
|
||||||
const overviewTint = state === "neighbor"
|
const overviewTint = state === "neighbor"
|
||||||
? theme.palette.overview.nodeTintMix + 0.09
|
? theme.palette.overview.nodeTintMix + 0.09
|
||||||
: theme.palette.overview.nodeTintMix;
|
: theme.palette.overview.nodeTintMix;
|
||||||
@@ -406,9 +622,16 @@ function resolveNodeColor(
|
|||||||
if (zoomTier === "overview") {
|
if (zoomTier === "overview") {
|
||||||
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
|
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
|
||||||
const boostedCore = blendHex(overviewCore, semanticColor, 0.3 + 0.26 * presenceBoost);
|
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,
|
fallbackColor?: string,
|
||||||
) {
|
) {
|
||||||
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
|
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
|
||||||
|
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
|
||||||
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
|
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
|
||||||
const overviewShell = blendHex(
|
const overviewShell = blendHex(
|
||||||
theme.palette.overview.nodeBase,
|
theme.palette.overview.nodeBase,
|
||||||
@@ -429,7 +653,10 @@ function resolveNodeShellColor(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (zoomTier !== "overview") {
|
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") {
|
if (state === "selected") {
|
||||||
@@ -448,7 +675,10 @@ function resolveNodeShellColor(
|
|||||||
return withAlpha(theme.palette.overview.nodeMuted, 0.22);
|
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(
|
function resolveNodeCoreScale(
|
||||||
@@ -664,6 +894,10 @@ export function resolveEdgeVariant(state: GraphEdgeVisualState, attrs: EdgeAttri
|
|||||||
return "pathSignal";
|
return "pathSignal";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attrs.bundleKind === "community") {
|
||||||
|
return "line";
|
||||||
|
}
|
||||||
|
|
||||||
if ((attrs.parallelCount ?? 1) > 1) {
|
if ((attrs.parallelCount ?? 1) > 1) {
|
||||||
return "parallelCurve";
|
return "parallelCurve";
|
||||||
}
|
}
|
||||||
@@ -767,11 +1001,14 @@ export function resolveNodeElementStyle(
|
|||||||
const stateConfig = theme.nodes.states[state];
|
const stateConfig = theme.nodes.states[state];
|
||||||
const nodeVariant = resolveNodeVariant(state, attrs);
|
const nodeVariant = resolveNodeVariant(state, attrs);
|
||||||
const variantConfig = theme.nodes.variants[nodeVariant];
|
const variantConfig = theme.nodes.variants[nodeVariant];
|
||||||
|
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
|
||||||
const baseSize = Number(attrs.baseSize || attrs.size || 4);
|
const baseSize = Number(attrs.baseSize || attrs.size || 4);
|
||||||
const labelPriority = Number(attrs.labelPriority ?? 0);
|
const labelPriority = Number(attrs.labelPriority ?? 0);
|
||||||
const color = resolveNodeColor(theme, zoomTier, state, attrs, cameraRatio, attrs.color);
|
const color = resolveNodeColor(theme, zoomTier, state, attrs, cameraRatio, attrs.color);
|
||||||
const shellColor = resolveNodeShellColor(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 overviewPresence = zoomTier === "overview" ? 1 + getOverviewPresenceBoost(cameraRatio) * 1.2 : 1;
|
||||||
const forceLabel = shouldForceNodeLabel(theme, zoomTier, state, attrs, labelPriority);
|
const forceLabel = shouldForceNodeLabel(theme, zoomTier, state, attrs, labelPriority);
|
||||||
const badgeKind = attrs.badgeKind || variantConfig.badgeKind;
|
const badgeKind = attrs.badgeKind || variantConfig.badgeKind;
|
||||||
@@ -804,7 +1041,12 @@ export function resolveNodeElementStyle(
|
|||||||
borderColor: resolveNodeBorderColor(theme, zoomTier, state, nodeVariant, attrs, color),
|
borderColor: resolveNodeBorderColor(theme, zoomTier, state, nodeVariant, attrs, color),
|
||||||
borderSize: Math.max(
|
borderSize: Math.max(
|
||||||
0.4,
|
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,
|
nodeVariant,
|
||||||
badgeKind,
|
badgeKind,
|
||||||
@@ -814,7 +1056,10 @@ export function resolveNodeElementStyle(
|
|||||||
ringColor,
|
ringColor,
|
||||||
ringSize,
|
ringSize,
|
||||||
showHalo,
|
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 stateConfig = theme.edges.states[state];
|
||||||
const edgeVariant = resolveEdgeVariant(state, attrs);
|
const edgeVariant = resolveEdgeVariant(state, attrs);
|
||||||
const variantConfig = theme.edges.variants[edgeVariant];
|
const variantConfig = theme.edges.variants[edgeVariant];
|
||||||
|
const isCommunityBundle = attrs.bundleKind === "community";
|
||||||
const baseSize = Number(attrs.baseSize || attrs.size || 0.9);
|
const baseSize = Number(attrs.baseSize || attrs.size || 0.9);
|
||||||
const visualPriority = Number(attrs.visualPriority ?? 0);
|
const visualPriority = Number(attrs.visualPriority ?? 0);
|
||||||
const belowPriorityThreshold = state === "default"
|
const belowPriorityThreshold = state === "default"
|
||||||
@@ -920,8 +1166,13 @@ export function resolveEdgeElementStyle(
|
|||||||
type: useCurvedRenderer
|
type: useCurvedRenderer
|
||||||
? (straightType === "arrow" ? "curvedArrow" : "curve")
|
? (straightType === "arrow" ? "curvedArrow" : "curve")
|
||||||
: straightType,
|
: straightType,
|
||||||
color: resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color),
|
color: isCommunityBundle
|
||||||
size: Math.max(baseSize * sizeMultiplier, stateConfig.minSize),
|
? 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,
|
zIndex: stateConfig.zIndex,
|
||||||
edgeVariant,
|
edgeVariant,
|
||||||
arrowVisibilityPolicy: variantConfig.arrowPolicy,
|
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(
|
function buildCollapsedNeighborhoodState(
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
activePath: string[],
|
activePath: string[],
|
||||||
@@ -1090,6 +1497,7 @@ function buildCommunityGroupedGraph(): GraphDisplayResult {
|
|||||||
const state = createEmptyDisplayState("", true);
|
const state = createEmptyDisplayState("", true);
|
||||||
state.groupedViewAvailable = base.communitiesByNode.size > 0;
|
state.groupedViewAvailable = base.communitiesByNode.size > 0;
|
||||||
if (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 };
|
return { graph: aggregateDisplayGraph(graph), state, meta: MIRRORED_DISPLAY_META };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1109,6 +1517,17 @@ function buildCommunityGroupedGraph(): GraphDisplayResult {
|
|||||||
communityMembers.set(communityId, bucket);
|
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) => {
|
communityMembers.forEach((memberIds, communityId) => {
|
||||||
const rankedMembers = memberIds
|
const rankedMembers = memberIds
|
||||||
.slice()
|
.slice()
|
||||||
@@ -1130,38 +1549,15 @@ function buildCommunityGroupedGraph(): GraphDisplayResult {
|
|||||||
});
|
});
|
||||||
const dominantSemanticGroup = [...semanticCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "entity";
|
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 color = anchorAttrs?.baseColor || anchorAttrs?.color || GRAPH_THEME.palette.semantic[communityId % GRAPH_THEME.palette.semantic.length];
|
||||||
const communityNodeId = `${COMMUNITY_NODE_PREFIX}${communityId}`;
|
communitySummaries.set(communityId, {
|
||||||
addNodeIfMissing(grouped, communityNodeId, {
|
memberIds,
|
||||||
label: anchorAttrs?.label || `Community ${communityId}`,
|
rankedMembers,
|
||||||
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,
|
|
||||||
anchorNodeId,
|
anchorNodeId,
|
||||||
labelPriority: Math.max(2, Math.log2(memberIds.length + 1)),
|
anchorAttrs,
|
||||||
visualPriority: Math.max(1, Math.log2(memberIds.length + 1)),
|
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);
|
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) => {
|
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 dominantEdgeType = [...bundle.typeCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "related_to";
|
||||||
const reverseKey = `${bundle.targetId}→${bundle.sourceId}`;
|
const reverseKey = `${bundle.targetId}→${bundle.sourceId}`;
|
||||||
const syntheticEdgeId = `${AGGREGATED_EDGE_PREFIX}${key}`;
|
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, {
|
grouped.mergeDirectedEdgeWithKey(syntheticEdgeId, bundle.sourceId, bundle.targetId, {
|
||||||
edgeId: syntheticEdgeId,
|
edgeId: syntheticEdgeId,
|
||||||
familyId: syntheticEdgeId,
|
familyId: syntheticEdgeId,
|
||||||
@@ -1213,23 +1720,45 @@ function buildCommunityGroupedGraph(): GraphDisplayResult {
|
|||||||
properties: {},
|
properties: {},
|
||||||
rawEdgeIds: bundle.rawEdgeIds,
|
rawEdgeIds: bundle.rawEdgeIds,
|
||||||
isAggregated: true,
|
isAggregated: true,
|
||||||
aggregateCount: bundle.rawEdgeIds.length,
|
aggregateCount,
|
||||||
familySize: bundle.rawEdgeIds.length,
|
familySize: aggregateCount,
|
||||||
parallelCount: bundle.rawEdgeIds.length,
|
parallelCount: aggregateCount,
|
||||||
isBidirectional: groupedEdges.has(reverseKey),
|
isBidirectional: groupedEdges.has(reverseKey),
|
||||||
bundleKind: "community",
|
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 {
|
return {
|
||||||
graph: grouped,
|
graph: grouped,
|
||||||
state: {
|
state: {
|
||||||
aggregationEnabled: true,
|
aggregationEnabled: true,
|
||||||
groupedViewAvailable: true,
|
groupedViewAvailable: true,
|
||||||
|
groupedViewReason: null,
|
||||||
selectedRootNodeId: null,
|
selectedRootNodeId: null,
|
||||||
selectedVisibleNeighborIds: [],
|
selectedVisibleNeighborIds: [],
|
||||||
selectedCollapsedNeighborIds: [],
|
selectedCollapsedNeighborIds: [],
|
||||||
|
selectedNodeKind: "none",
|
||||||
|
canActivateFocused: false,
|
||||||
|
resolvedFocusedNodeId: null,
|
||||||
|
focusedUnavailableReason: null,
|
||||||
},
|
},
|
||||||
meta: OWNED_DISPLAY_META,
|
meta: OWNED_DISPLAY_META,
|
||||||
};
|
};
|
||||||
@@ -1243,6 +1772,10 @@ export function resolveDisplayStateSnapshot(
|
|||||||
aggregationEnabled?: boolean;
|
aggregationEnabled?: boolean;
|
||||||
collapsedNeighborhoodNodeIds?: Iterable<string>;
|
collapsedNeighborhoodNodeIds?: Iterable<string>;
|
||||||
groupedViewAvailable?: boolean;
|
groupedViewAvailable?: boolean;
|
||||||
|
groupedViewReason?: string | null;
|
||||||
|
selectedNodeKind?: GraphSelectedNodeKind;
|
||||||
|
resolvedFocusedNodeId?: string | null;
|
||||||
|
focusedUnavailableReason?: string | null;
|
||||||
},
|
},
|
||||||
): GraphDisplayStateSnapshot {
|
): GraphDisplayStateSnapshot {
|
||||||
const aggregationEnabled = options?.aggregationEnabled ?? true;
|
const aggregationEnabled = options?.aggregationEnabled ?? true;
|
||||||
@@ -1250,10 +1783,18 @@ export function resolveDisplayStateSnapshot(
|
|||||||
Array.from(options?.collapsedNeighborhoodNodeIds ?? []).filter((nodeId) => typeof nodeId === "string"),
|
Array.from(options?.collapsedNeighborhoodNodeIds ?? []).filter((nodeId) => typeof nodeId === "string"),
|
||||||
);
|
);
|
||||||
const displayState = createEmptyDisplayState(selectedNodeId, aggregationEnabled);
|
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, {
|
displayState.groupedViewAvailable = options?.groupedViewAvailable ?? computeGraphAnalyticsBase(graph, {
|
||||||
computeCommunities: true,
|
computeCommunities: true,
|
||||||
computeCentrality: false,
|
computeCentrality: false,
|
||||||
}).communitiesByNode.size > 0;
|
}).communitiesByNode.size > 0;
|
||||||
|
displayState.groupedViewReason = options?.groupedViewReason
|
||||||
|
?? (displayState.groupedViewAvailable ? null : "Grouped view is unavailable until communities can be detected.");
|
||||||
|
|
||||||
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
||||||
return displayState;
|
return displayState;
|
||||||
@@ -1421,6 +1962,11 @@ export function resolveDisplayGraph(
|
|||||||
selectedRootNodeId: displayState.selectedRootNodeId,
|
selectedRootNodeId: displayState.selectedRootNodeId,
|
||||||
selectedVisibleNeighborIds: displayState.selectedVisibleNeighborIds,
|
selectedVisibleNeighborIds: displayState.selectedVisibleNeighborIds,
|
||||||
selectedCollapsedNeighborIds: displayState.selectedCollapsedNeighborIds,
|
selectedCollapsedNeighborIds: displayState.selectedCollapsedNeighborIds,
|
||||||
|
groupedViewReason: grouped.state.groupedViewReason,
|
||||||
|
selectedNodeKind: displayState.selectedNodeKind,
|
||||||
|
canActivateFocused: displayState.canActivateFocused,
|
||||||
|
resolvedFocusedNodeId: displayState.resolvedFocusedNodeId,
|
||||||
|
focusedUnavailableReason: displayState.focusedUnavailableReason,
|
||||||
},
|
},
|
||||||
meta: grouped.meta,
|
meta: grouped.meta,
|
||||||
};
|
};
|
||||||
@@ -1452,6 +1998,7 @@ export function resolveDisplayGraph(
|
|||||||
export function createInteractionState(
|
export function createInteractionState(
|
||||||
hoveredNodeId: string | null,
|
hoveredNodeId: string | null,
|
||||||
selectedNodeId: string,
|
selectedNodeId: string,
|
||||||
|
focusedNodeId: string,
|
||||||
selectedEdgeId: string,
|
selectedEdgeId: string,
|
||||||
activePath: string[],
|
activePath: string[],
|
||||||
activePathEdgeIds: string[],
|
activePathEdgeIds: string[],
|
||||||
@@ -1463,7 +2010,7 @@ export function createInteractionState(
|
|||||||
hoveredNodeId,
|
hoveredNodeId,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
selectedEdgeId,
|
selectedEdgeId,
|
||||||
focusedNodeId: selectedNodeId,
|
focusedNodeId,
|
||||||
activePath,
|
activePath,
|
||||||
activePathEdgeIds,
|
activePathEdgeIds,
|
||||||
viewMode,
|
viewMode,
|
||||||
|
|||||||
@@ -191,6 +191,35 @@ export interface GraphTheme {
|
|||||||
motion: {
|
motion: {
|
||||||
cameraMs: number;
|
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: {
|
effects: {
|
||||||
pathPulse: {
|
pathPulse: {
|
||||||
minZoomTier: GraphZoomTier;
|
minZoomTier: GraphZoomTier;
|
||||||
@@ -474,6 +503,35 @@ export const GRAPH_THEME: GraphTheme = {
|
|||||||
motion: {
|
motion: {
|
||||||
cameraMs: 380,
|
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: {
|
effects: {
|
||||||
pathPulse: {
|
pathPulse: {
|
||||||
minZoomTier: "structure",
|
minZoomTier: "structure",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface GraphSceneProps extends GraphSceneEventMap {
|
|||||||
displayMeta: GraphDisplayMeta;
|
displayMeta: GraphDisplayMeta;
|
||||||
displayState?: GraphDisplayStateSnapshot;
|
displayState?: GraphDisplayStateSnapshot;
|
||||||
selectedNodeId: string;
|
selectedNodeId: string;
|
||||||
|
focusedNodeId: string;
|
||||||
selectedEdgeId: string;
|
selectedEdgeId: string;
|
||||||
activePath?: string[];
|
activePath?: string[];
|
||||||
activePathEdgeIds?: string[];
|
activePathEdgeIds?: string[];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type GraphLoadPhase =
|
|||||||
export type GraphLoadProgressKind = "determinate" | "indeterminate";
|
export type GraphLoadProgressKind = "determinate" | "indeterminate";
|
||||||
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||||
export type GraphEdgeInteractionState = "default" | "backbone" | "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 {
|
export interface GraphCameraState {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -34,9 +35,14 @@ export interface GraphInteractionState {
|
|||||||
export interface GraphDisplayStateSnapshot {
|
export interface GraphDisplayStateSnapshot {
|
||||||
aggregationEnabled: boolean;
|
aggregationEnabled: boolean;
|
||||||
groupedViewAvailable: boolean;
|
groupedViewAvailable: boolean;
|
||||||
|
groupedViewReason: string | null;
|
||||||
selectedRootNodeId: string | null;
|
selectedRootNodeId: string | null;
|
||||||
selectedVisibleNeighborIds: string[];
|
selectedVisibleNeighborIds: string[];
|
||||||
selectedCollapsedNeighborIds: string[];
|
selectedCollapsedNeighborIds: string[];
|
||||||
|
selectedNodeKind: GraphSelectedNodeKind;
|
||||||
|
canActivateFocused: boolean;
|
||||||
|
resolvedFocusedNodeId: string | null;
|
||||||
|
focusedUnavailableReason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GraphDisplayLayoutMode = "base" | "mirrored" | "owned";
|
export type GraphDisplayLayoutMode = "base" | "mirrored" | "owned";
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import {
|
|||||||
batchMergeNodes,
|
batchMergeNodes,
|
||||||
clearGraph,
|
clearGraph,
|
||||||
} from "../src/store/graphStore.ts";
|
} 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") {
|
function addNode(id: string, semanticGroup = "entity") {
|
||||||
batchMergeNodes([
|
batchMergeNodes([
|
||||||
@@ -135,3 +140,120 @@ test("resolveDisplayGraph grouped view emits community nodes and edges", () => {
|
|||||||
|
|
||||||
assert.equal(hasCommunityEdge, true);
|
assert.equal(hasCommunityEdge, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── resolveGroupedDisplayNodeId ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("resolveGroupedDisplayNodeId returns null for empty nodeId", () => {
|
||||||
|
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
||||||
|
assert.equal(resolveGroupedDisplayNodeId(displayGraph, ""), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveGroupedDisplayNodeId returns nodeId when it exists directly in display graph", () => {
|
||||||
|
addNode("x");
|
||||||
|
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
||||||
|
assert.equal(resolveGroupedDisplayNodeId(displayGraph, "x"), "x");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveGroupedDisplayNodeId resolves base node to its community node", () => {
|
||||||
|
const left = ["a1", "a2", "a3", "a4"];
|
||||||
|
const right = ["b1", "b2", "b3", "b4"];
|
||||||
|
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
||||||
|
|
||||||
|
let edgeIndex = 0;
|
||||||
|
for (let i = 0; i < left.length; i += 1) {
|
||||||
|
for (let j = 0; j < left.length; j += 1) {
|
||||||
|
if (i !== j) addEdge(`l-${edgeIndex++}`, left[i], left[j], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < right.length; i += 1) {
|
||||||
|
for (let j = 0; j < right.length; j += 1) {
|
||||||
|
if (i !== j) addEdge(`r-${edgeIndex++}`, right[i], right[j], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addEdge("bridge-1", "a1", "b1", 0.1);
|
||||||
|
|
||||||
|
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
|
||||||
|
const communityNodes = displayGraph.nodes().filter((n) => n.startsWith("__community__"));
|
||||||
|
assert.ok(communityNodes.length >= 2, "expected community nodes");
|
||||||
|
|
||||||
|
const resolved = resolveGroupedDisplayNodeId(displayGraph, "a1");
|
||||||
|
assert.ok(resolved !== null, "should resolve a1 to a community node");
|
||||||
|
assert.ok(resolved!.startsWith("__community__"), "resolved id should be a community node");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── resolveGroupedDisplayStateSnapshot ──────────────────────────────────────
|
||||||
|
|
||||||
|
test("resolveGroupedDisplayStateSnapshot returns none-kind when no node selected", () => {
|
||||||
|
addNode("p");
|
||||||
|
addNode("q");
|
||||||
|
addEdge("e1", "p", "q");
|
||||||
|
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
||||||
|
const state = resolveGroupedDisplayStateSnapshot(displayGraph, "", {
|
||||||
|
groupedViewAvailable: true,
|
||||||
|
groupedViewReason: null,
|
||||||
|
});
|
||||||
|
assert.equal(state.selectedNodeKind, "none");
|
||||||
|
assert.equal(state.selectedRootNodeId, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveGroupedDisplayStateSnapshot maps selected base node to community in grouped graph", () => {
|
||||||
|
const left = ["c1", "c2", "c3", "c4"];
|
||||||
|
const right = ["d1", "d2", "d3", "d4"];
|
||||||
|
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
||||||
|
|
||||||
|
let edgeIndex = 0;
|
||||||
|
for (let i = 0; i < left.length; i += 1) {
|
||||||
|
for (let j = 0; j < left.length; j += 1) {
|
||||||
|
if (i !== j) addEdge(`lc-${edgeIndex++}`, left[i], left[j], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < right.length; i += 1) {
|
||||||
|
for (let j = 0; j < right.length; j += 1) {
|
||||||
|
if (i !== j) addEdge(`rc-${edgeIndex++}`, right[i], right[j], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addEdge("bridge-c1", "c1", "d1", 0.1);
|
||||||
|
addEdge("bridge-c2", "c2", "d2", 0.1);
|
||||||
|
|
||||||
|
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
|
||||||
|
const state = resolveGroupedDisplayStateSnapshot(displayGraph, "c1", {
|
||||||
|
groupedViewAvailable: true,
|
||||||
|
groupedViewReason: null,
|
||||||
|
selectedNodeKind: "grouped",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(state.selectedRootNodeId !== null, "should resolve to a community node");
|
||||||
|
assert.ok(state.selectedRootNodeId!.startsWith("__community__"), "root should be a community node");
|
||||||
|
assert.equal(state.groupedViewAvailable, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── checkGroupedViewAvailability ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("checkGroupedViewAvailability returns unavailable on empty graph", () => {
|
||||||
|
const result = checkGroupedViewAvailability();
|
||||||
|
assert.equal(result.available, false);
|
||||||
|
assert.ok(typeof result.reason === "string" && result.reason.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkGroupedViewAvailability returns available when communities exist", () => {
|
||||||
|
const left = ["e1", "e2", "e3", "e4"];
|
||||||
|
const right = ["f1", "f2", "f3", "f4"];
|
||||||
|
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
||||||
|
|
||||||
|
let edgeIndex = 0;
|
||||||
|
for (let i = 0; i < left.length; i += 1) {
|
||||||
|
for (let j = 0; j < left.length; j += 1) {
|
||||||
|
if (i !== j) addEdge(`le-${edgeIndex++}`, left[i], left[j], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < right.length; i += 1) {
|
||||||
|
for (let j = 0; j < right.length; j += 1) {
|
||||||
|
if (i !== j) addEdge(`re-${edgeIndex++}`, right[i], right[j], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addEdge("bridge-e1", "e1", "f1", 0.1);
|
||||||
|
|
||||||
|
const result = checkGroupedViewAvailability();
|
||||||
|
assert.equal(result.available, true);
|
||||||
|
assert.equal(result.reason, null);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user