mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Merge pull request #483 from ZohaibHassan16/feat/graph-declutter-and-calm
feat(explorer): calm and structurally declutter graph workspace
This commit is contained in:
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Feature: Graph Workspace declutter + calmer structural exploration** (PR #483 by @ZohaibHassan16, follow-up by @KaifAhmad1):
|
||||
- Added a calmer default presentation for dense graphs: reduced label pressure, stronger inactive-state muting, and tuned zoom-tier visibility to improve readability during overview and structure navigation.
|
||||
- Added display-edge aggregation with raw-edge bundle metadata retention, enabling cleaner visuals while preserving drill-down context for selected edges.
|
||||
- Added grouped community view and neighborhood collapse/expand controls for high-degree local structures in Graph Workspace and Neighborhood panel flows.
|
||||
- Extended graph selection/runtime state with display-state metadata (`groupedViewAvailable`, visible/collapsed neighbor counts, aggregated edge descriptors) for plugin and panel introspection.
|
||||
- Added regression coverage for `resolveDisplayGraph` behavior in `explorer/tests/graphSceneState.display.test.ts`:
|
||||
- parallel-edge aggregation in full view
|
||||
- collapse behavior preserving active-path neighbors
|
||||
- grouped community-node/community-edge projection behavior
|
||||
- Follow-up merge resolution synced the PR branch with `main` after Explorer path migration (`semantica-explorer` -> `explorer`) and preserved PR #483 behavior in conflicted Graph Workspace files.
|
||||
|
||||
- **Fix: DeepSeekProvider now uses OpenAI SDK instead of unmaintained deepseek SDK** (closes #482, PR #482 by @liling, review fixes by @KaifAhmad1):
|
||||
- **Root cause**: The `deepseek` PyPI package has no `deepseek.Client`, causing `AttributeError` on every `DeepSeekProvider` instantiation. The DeepSeek API is OpenAI-compatible, so the `openai` SDK is the correct client.
|
||||
- **`_init_client` rewritten**: Replaced `import deepseek; deepseek.Client(api_key=...)` with `from openai import OpenAI; OpenAI(api_key=..., base_url=self.base_url)`, matching the pattern already used by `NovitaProvider`.
|
||||
|
||||
Generated
+855
-313
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,8 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs"
|
||||
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
|
||||
"test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
@@ -43,6 +44,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^5.4.0"
|
||||
|
||||
@@ -42,6 +42,10 @@ export interface NodeAttributes {
|
||||
haloColor?: string;
|
||||
labelVisibilityPolicy?: GraphLabelVisibilityPolicy;
|
||||
highlighted?: boolean;
|
||||
communityId?: string;
|
||||
isCommunityGroup?: boolean;
|
||||
memberCount?: number;
|
||||
anchorNodeId?: string | null;
|
||||
|
||||
nodeType: string;
|
||||
content: string;
|
||||
@@ -74,6 +78,12 @@ export interface EdgeAttributes {
|
||||
parallelIndex?: number;
|
||||
parallelCount?: number;
|
||||
familySize?: number;
|
||||
rawEdgeIds?: string[];
|
||||
isAggregated?: boolean;
|
||||
aggregateCount?: number;
|
||||
dominantEdgeType?: string;
|
||||
representativeWeight?: number;
|
||||
bundleKind?: "parallel" | "bidirectional" | "community";
|
||||
|
||||
|
||||
edgeType: string;
|
||||
|
||||
@@ -84,6 +84,8 @@ export interface GraphCanvasProps {
|
||||
layoutSource?: string;
|
||||
onLayoutStatusChange?: (status: GraphLayoutStatus) => void;
|
||||
viewMode: GraphViewMode;
|
||||
aggregationEnabled?: boolean;
|
||||
collapsedNeighborhoodNodeIds?: string[];
|
||||
className?: string;
|
||||
showFitViewButton?: boolean;
|
||||
pluginOverlays?: ReactNode[];
|
||||
@@ -111,17 +113,15 @@ const FA2_SETTINGS = {
|
||||
|
||||
const SIGMA_SETTINGS = {
|
||||
allowInvalidContainer: true,
|
||||
labelRenderedSizeThreshold: 2,
|
||||
labelRenderedSizeThreshold: 6,
|
||||
defaultNodeType: "circle",
|
||||
defaultEdgeType: "line",
|
||||
hideLabelsOnMove: false,
|
||||
hideEdgesOnMove: false,
|
||||
enableEdgeEvents: true,
|
||||
renderEdgeLabels: true,
|
||||
edgeLabelSize: 10,
|
||||
edgeLabelColor: { color: "rgba(180, 210, 255, 0.72)" },
|
||||
labelDensity: 1.1,
|
||||
labelGridCellSize: 80,
|
||||
renderEdgeLabels: false,
|
||||
labelDensity: 0.7,
|
||||
labelGridCellSize: 140,
|
||||
zIndex: true,
|
||||
minCameraRatio: 0.04,
|
||||
maxCameraRatio: 8,
|
||||
@@ -581,6 +581,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
temporalState,
|
||||
isLayoutRunning,
|
||||
viewMode,
|
||||
aggregationEnabled = true,
|
||||
collapsedNeighborhoodNodeIds = [],
|
||||
className,
|
||||
showFitViewButton = true,
|
||||
pluginOverlays = [],
|
||||
@@ -616,8 +618,11 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
|
||||
const isFocusedView = viewMode === "focused" && Boolean(selectedNodeId) && graph.hasNode(selectedNodeId);
|
||||
const displayGraph = useMemo(
|
||||
() => resolveDisplayGraph(selectedNodeId, activePath, activePathEdgeIds, viewMode),
|
||||
[activePath, activePathEdgeIds, selectedNodeId, viewMode],
|
||||
() => resolveDisplayGraph(selectedNodeId, activePath, activePathEdgeIds, viewMode, {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
}).graph,
|
||||
[activePath, activePathEdgeIds, aggregationEnabled, collapsedNeighborhoodNodeIds, selectedNodeId, viewMode],
|
||||
);
|
||||
|
||||
const interactionState = useMemo(
|
||||
|
||||
@@ -67,6 +67,10 @@ function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
|
||||
valid_until: attributes.valid_until ?? null,
|
||||
properties: attributes.properties ?? {},
|
||||
neighborCount: graph.neighbors(nodeId).length,
|
||||
visibleNeighborCount: graph.neighbors(nodeId).length,
|
||||
collapsedNeighborCount: 0,
|
||||
isNeighborhoodCollapsed: false,
|
||||
canCollapseNeighborhood: graph.neighbors(nodeId).length > 8,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type Graph from "graphology";
|
||||
import { batchMergeEdges, batchMergeNodes, graph } from "../../store/graphStore";
|
||||
import { logEvent } from "../../store/registryStore";
|
||||
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
|
||||
@@ -11,6 +12,7 @@ import { useLoadGraph, useReloadGraph } from "./useLoadGraph";
|
||||
import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
|
||||
import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import { resolveDisplayGraph } from "./graphSceneState";
|
||||
import {
|
||||
type GraphPlugin,
|
||||
type GraphPluginActionRequest,
|
||||
@@ -23,6 +25,7 @@ import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
|
||||
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectToggle,
|
||||
GraphEffectsState,
|
||||
@@ -89,7 +92,7 @@ const DEFAULT_EFFECTS_STATE: GraphEffectsState = {
|
||||
pathFlowEnabled: false,
|
||||
lensEnabled: false,
|
||||
temporalEmphasisEnabled: false,
|
||||
semanticRegionsEnabled: true,
|
||||
semanticRegionsEnabled: false,
|
||||
contoursEnabled: false,
|
||||
pathfindingEnabled: false,
|
||||
communitiesEnabled: false,
|
||||
@@ -494,7 +497,10 @@ function buildRealtimeEdgeAttributes(payload: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
|
||||
function buildSelectedNodeState(
|
||||
nodeId: string,
|
||||
displayState: GraphDisplayStateSnapshot,
|
||||
): GraphSelectedNodeState | null {
|
||||
if (!nodeId || !graph.hasNode(nodeId)) {
|
||||
return null;
|
||||
}
|
||||
@@ -519,31 +525,58 @@ function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
|
||||
valid_until: attributes.valid_until ?? null,
|
||||
properties: attributes.properties ?? {},
|
||||
neighborCount: graph.neighbors(nodeId).length,
|
||||
visibleNeighborCount: displayState.selectedVisibleNeighborIds.length,
|
||||
collapsedNeighborCount: displayState.selectedCollapsedNeighborIds.length,
|
||||
isNeighborhoodCollapsed: displayState.selectedCollapsedNeighborIds.length > 0,
|
||||
canCollapseNeighborhood: graph.neighbors(nodeId).length > 8,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectedEdgeState(edgeId: string): GraphSelectedEdgeState | null {
|
||||
if (!edgeId || !graph.hasEdge(edgeId)) {
|
||||
function buildSelectedEdgeState(
|
||||
edgeId: string,
|
||||
displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
): GraphSelectedEdgeState | null {
|
||||
if (!edgeId || !displayGraph.hasEdge(edgeId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [sourceId, targetId] = graph.extremities(edgeId);
|
||||
const attributes = graph.getEdgeAttributes(edgeId) as {
|
||||
const [displaySourceId, displayTargetId] = displayGraph.extremities(edgeId);
|
||||
const attributes = displayGraph.getEdgeAttributes(edgeId) as {
|
||||
edgeType?: string;
|
||||
weight?: number;
|
||||
properties?: Record<string, unknown>;
|
||||
familyId?: string;
|
||||
rawEdgeIds?: string[];
|
||||
isAggregated?: boolean;
|
||||
aggregateCount?: number;
|
||||
bundleKind?: "parallel" | "bidirectional" | "community";
|
||||
dominantEdgeType?: string;
|
||||
representativeWeight?: number;
|
||||
};
|
||||
const sourceAttributes = graph.getNodeAttributes(sourceId) as { label?: string; content?: string };
|
||||
const targetAttributes = graph.getNodeAttributes(targetId) as { label?: string; content?: string };
|
||||
const rawEdgeIds = attributes.rawEdgeIds?.length ? attributes.rawEdgeIds.map((rawEdgeId) => String(rawEdgeId)) : [edgeId];
|
||||
const primaryRawEdgeId = rawEdgeIds.find((rawEdgeId) => graph.hasEdge(rawEdgeId)) ?? rawEdgeIds[0];
|
||||
let sourceId = displaySourceId;
|
||||
let targetId = displayTargetId;
|
||||
if (primaryRawEdgeId && graph.hasEdge(primaryRawEdgeId)) {
|
||||
[sourceId, targetId] = graph.extremities(primaryRawEdgeId);
|
||||
}
|
||||
const sourceAttributes = graph.hasNode(sourceId)
|
||||
? (graph.getNodeAttributes(sourceId) as { label?: string; content?: string })
|
||||
: ({ label: displaySourceId } as { label?: string; content?: string });
|
||||
const targetAttributes = graph.hasNode(targetId)
|
||||
? (graph.getNodeAttributes(targetId) as { label?: string; content?: string })
|
||||
: ({ label: displayTargetId } as { label?: string; content?: string });
|
||||
const properties = attributes.properties ?? {};
|
||||
const familyId = String(attributes.familyId || edgeId);
|
||||
let familySize = 0;
|
||||
let siblingCount = 0;
|
||||
graph.forEachEdge((candidateEdgeId, candidateAttrs) => {
|
||||
const edgeAttrs = candidateAttrs as { familyId?: string };
|
||||
const [candidateSource, candidateTarget] = graph.extremities(candidateEdgeId);
|
||||
if (String(edgeAttrs.familyId || candidateEdgeId) === familyId) {
|
||||
rawEdgeIds.forEach((rawEdgeId) => {
|
||||
if (!graph.hasEdge(rawEdgeId)) {
|
||||
return;
|
||||
}
|
||||
const candidateAttrs = graph.getEdgeAttributes(rawEdgeId) as { familyId?: string };
|
||||
const [candidateSource, candidateTarget] = graph.extremities(rawEdgeId);
|
||||
if (String(candidateAttrs.familyId || rawEdgeId) === familyId) {
|
||||
familySize += 1;
|
||||
}
|
||||
if (candidateSource === sourceId && candidateTarget === targetId) {
|
||||
@@ -551,6 +584,11 @@ function buildSelectedEdgeState(edgeId: string): GraphSelectedEdgeState | null {
|
||||
}
|
||||
});
|
||||
|
||||
if (attributes.isAggregated) {
|
||||
siblingCount = rawEdgeIds.filter((rawEdgeId) => graph.hasEdge(rawEdgeId)).length;
|
||||
familySize = Math.max(familySize, siblingCount);
|
||||
}
|
||||
|
||||
return {
|
||||
id: edgeId,
|
||||
familyId,
|
||||
@@ -564,6 +602,12 @@ function buildSelectedEdgeState(edgeId: string): GraphSelectedEdgeState | null {
|
||||
provenanceCount: getProvenanceCount(properties),
|
||||
familySize,
|
||||
siblingCount,
|
||||
isAggregated: Boolean(attributes.isAggregated),
|
||||
aggregateCount: Number(attributes.aggregateCount ?? rawEdgeIds.length),
|
||||
rawEdgeIds,
|
||||
bundleKind: attributes.bundleKind ?? null,
|
||||
dominantEdgeType: attributes.dominantEdgeType ?? null,
|
||||
representativeWeight: Number(attributes.representativeWeight ?? attributes.weight ?? 1),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -626,7 +670,9 @@ export function GraphWorkspace() {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState("");
|
||||
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<GraphViewMode>("focused");
|
||||
const [viewMode, setViewMode] = useState<GraphViewMode>("full");
|
||||
const [aggregationEnabled] = useState(true);
|
||||
const [collapsedNeighborhoodNodeIds, setCollapsedNeighborhoodNodeIds] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [searchError, setSearchError] = useState("");
|
||||
@@ -663,7 +709,7 @@ export function GraphWorkspace() {
|
||||
focusedNodeId: "",
|
||||
activePath: [],
|
||||
activePathEdgeIds: [],
|
||||
viewMode: "focused",
|
||||
viewMode: "full",
|
||||
zoomTier: "overview",
|
||||
isLayoutRunning: false,
|
||||
});
|
||||
@@ -778,6 +824,28 @@ export function GraphWorkspace() {
|
||||
}, [debouncedTime, isLoading]);
|
||||
|
||||
const focusNode = useCallback((nodeId: string) => {
|
||||
const currentDisplayGraph = pluginRuntimeRef.current?.displayGraph ?? graph;
|
||||
if (viewMode === "grouped" && currentDisplayGraph.hasNode(nodeId)) {
|
||||
const displayAttrs = currentDisplayGraph.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
const communityGroup = displayAttrs.properties?.__communityGroup as
|
||||
| {
|
||||
anchorNodeId?: string | null;
|
||||
sampleNodeIds?: string[];
|
||||
}
|
||||
| undefined;
|
||||
const anchorNodeId = communityGroup?.anchorNodeId || communityGroup?.sampleNodeIds?.[0] || "";
|
||||
if (anchorNodeId && graph.hasNode(anchorNodeId)) {
|
||||
setViewMode("focused");
|
||||
setSelectedNodeId(anchorNodeId);
|
||||
setSelectedEdgeId("");
|
||||
setPathResult(null);
|
||||
setSearchResults([]);
|
||||
setSearchError("");
|
||||
setIsLayoutRunning(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedNodeId(nodeId);
|
||||
setSelectedEdgeId("");
|
||||
setPathResult(null);
|
||||
@@ -786,7 +854,7 @@ export function GraphWorkspace() {
|
||||
if (nodeId) {
|
||||
setIsLayoutRunning(false);
|
||||
}
|
||||
}, []);
|
||||
}, [viewMode]);
|
||||
|
||||
const handleEdgeSelect = useCallback((edgeId: string) => {
|
||||
setSelectedEdgeId(edgeId);
|
||||
@@ -930,32 +998,56 @@ export function GraphWorkspace() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const focusedSummary = useMemo(() => {
|
||||
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const localNeighborCount = graph.neighbors(selectedNodeId).length;
|
||||
if (viewMode === "focused") {
|
||||
const visibleNeighbors = Math.min(localNeighborCount, 16);
|
||||
return `${visibleNeighbors + 1} nodes in focused view`;
|
||||
}
|
||||
|
||||
return `${localNeighborCount} direct neighbors highlighted`;
|
||||
}, [selectedNodeId, viewMode]);
|
||||
useEffect(() => {
|
||||
setCollapsedNeighborhoodNodeIds([]);
|
||||
}, [summary?.edgeCount, summary?.nodeCount]);
|
||||
|
||||
const showLoadingOverlay = isLoading || isFetching || loadingProgress?.phase === "stabilizing_layout";
|
||||
const hasGraphContent = Boolean(summary?.nodeCount);
|
||||
const activePath = pathResult?.path ?? [];
|
||||
const activePathEdgeIds = pathResult?.edge_ids ?? [];
|
||||
const displayResult = useMemo(
|
||||
() => resolveDisplayGraph(selectedNodeId, activePath, activePathEdgeIds, viewMode, {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
}),
|
||||
[activePath, activePathEdgeIds, aggregationEnabled, collapsedNeighborhoodNodeIds, selectedNodeId, viewMode],
|
||||
);
|
||||
const displayState = displayResult.state;
|
||||
const focusedSummary = useMemo(() => {
|
||||
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
||||
if (viewMode === "grouped") {
|
||||
return displayState.groupedViewAvailable
|
||||
? "Communities compressed into grouped structure view"
|
||||
: "Grouped view is unavailable for the current graph";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const localNeighborCount = graph.neighbors(selectedNodeId).length;
|
||||
if (viewMode === "focused") {
|
||||
const visibleNeighbors = displayState.selectedVisibleNeighborIds.length || Math.min(localNeighborCount, 16);
|
||||
return `${visibleNeighbors + 1} nodes in focused view`;
|
||||
}
|
||||
|
||||
if (viewMode === "grouped") {
|
||||
return "Grouped structure view with direct community drill-in";
|
||||
}
|
||||
|
||||
if (displayState.selectedCollapsedNeighborIds.length > 0) {
|
||||
return `${displayState.selectedVisibleNeighborIds.length} visible neighbors, ${displayState.selectedCollapsedNeighborIds.length} collapsed`;
|
||||
}
|
||||
|
||||
return `${localNeighborCount} direct neighbors highlighted`;
|
||||
}, [displayState, selectedNodeId, viewMode]);
|
||||
const graphSummary = summary as GraphLoadSummary | null;
|
||||
const selectedNodeState = useMemo(
|
||||
() => buildSelectedNodeState(selectedNodeId),
|
||||
[selectedNodeId, summary?.nodeCount, summary?.edgeCount],
|
||||
() => buildSelectedNodeState(selectedNodeId, displayState),
|
||||
[displayState, selectedNodeId, summary?.nodeCount, summary?.edgeCount],
|
||||
);
|
||||
const selectedEdgeState = useMemo(
|
||||
() => buildSelectedEdgeState(selectedEdgeId),
|
||||
[selectedEdgeId, summary?.nodeCount, summary?.edgeCount],
|
||||
() => buildSelectedEdgeState(selectedEdgeId, displayResult.graph),
|
||||
[displayResult.graph, selectedEdgeId, summary?.nodeCount, summary?.edgeCount],
|
||||
);
|
||||
const temporalState = useMemo(
|
||||
() => ({
|
||||
@@ -1063,6 +1155,20 @@ export function GraphWorkspace() {
|
||||
case "setViewMode":
|
||||
setViewMode(action.viewMode);
|
||||
return;
|
||||
case "collapseNeighborhood":
|
||||
if (!selectedNodeId) {
|
||||
return;
|
||||
}
|
||||
setCollapsedNeighborhoodNodeIds((current) => (
|
||||
current.includes(selectedNodeId) ? current : [...current, selectedNodeId]
|
||||
));
|
||||
return;
|
||||
case "expandNeighborhood":
|
||||
if (!selectedNodeId) {
|
||||
return;
|
||||
}
|
||||
setCollapsedNeighborhoodNodeIds((current) => current.filter((nodeId) => nodeId !== selectedNodeId));
|
||||
return;
|
||||
case "toggleEffect":
|
||||
setEffectToggle(action.effect, (current) => !current);
|
||||
return;
|
||||
@@ -1099,7 +1205,7 @@ export function GraphWorkspace() {
|
||||
setActiveDockPanelId((previous) => (previous === action.panelId ? null : previous));
|
||||
return;
|
||||
}
|
||||
}, [focusNode, setEffectToggle]);
|
||||
}, [focusNode, selectedNodeId, setEffectToggle]);
|
||||
|
||||
const diagnosticsSnapshot = useMemo<GraphDiagnosticsSnapshot | null>(() => {
|
||||
if (!GRAPH_THEME.effects.diagnostics.enabledInDev || !graphDiagnosticsState) {
|
||||
@@ -1139,9 +1245,11 @@ export function GraphWorkspace() {
|
||||
getEffectsState: () => effectsState,
|
||||
getDiagnosticsSnapshot: () => diagnosticsSnapshot,
|
||||
getAnalyticsSnapshot: () => graphAnalyticsState,
|
||||
getDisplayState: () => displayState,
|
||||
isPanelOpen: (panelId: string) => Boolean(pluginPanelState[panelId]),
|
||||
dispatchAction: handlePluginAction,
|
||||
}), [
|
||||
displayState,
|
||||
graphAnalyticsState,
|
||||
diagnosticsSnapshot,
|
||||
effectsState,
|
||||
@@ -1267,17 +1375,10 @@ export function GraphWorkspace() {
|
||||
const coreToolbarGroups = useMemo<GraphToolbarGroup[]>(() => {
|
||||
const groups: GraphToolbarGroup[] = [];
|
||||
|
||||
if (selectedNodeId) {
|
||||
if (hasGraphContent) {
|
||||
groups.push({
|
||||
id: "view-mode",
|
||||
items: [
|
||||
{
|
||||
id: "view-focused",
|
||||
label: "Focused",
|
||||
title: "Inspect the selected node in a focused local graph",
|
||||
active: viewMode === "focused",
|
||||
onClick: () => setViewMode("focused"),
|
||||
},
|
||||
{
|
||||
id: "view-full",
|
||||
label: "Full Graph",
|
||||
@@ -1285,6 +1386,46 @@ export function GraphWorkspace() {
|
||||
active: viewMode === "full",
|
||||
onClick: () => setViewMode("full"),
|
||||
},
|
||||
{
|
||||
id: "view-grouped",
|
||||
label: "Grouped View",
|
||||
title: displayState.groupedViewAvailable
|
||||
? "Compress dense structure into detected communities"
|
||||
: "Grouped view is unavailable until communities can be detected",
|
||||
active: viewMode === "grouped",
|
||||
disabled: !displayState.groupedViewAvailable,
|
||||
onClick: () => setViewMode("grouped"),
|
||||
},
|
||||
{
|
||||
id: "view-focused",
|
||||
label: "Focused",
|
||||
title: "Inspect the selected node in a focused local graph",
|
||||
active: viewMode === "focused",
|
||||
disabled: !selectedNodeId,
|
||||
onClick: () => setViewMode("focused"),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedNodeState) {
|
||||
groups.push({
|
||||
id: "local-structure",
|
||||
items: [
|
||||
{
|
||||
id: "collapse-neighborhood",
|
||||
label: "Collapse Neighborhood",
|
||||
title: "Hide lower-priority fanout around the selected node",
|
||||
disabled: !selectedNodeState.canCollapseNeighborhood || selectedNodeState.isNeighborhoodCollapsed,
|
||||
onClick: () => handlePluginAction({ type: "collapseNeighborhood" }),
|
||||
},
|
||||
{
|
||||
id: "expand-neighborhood",
|
||||
label: "Expand Neighborhood",
|
||||
title: "Restore the collapsed local neighborhood",
|
||||
disabled: !selectedNodeState.isNeighborhoodCollapsed,
|
||||
onClick: () => handlePluginAction({ type: "expandNeighborhood" }),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1362,7 +1503,19 @@ export function GraphWorkspace() {
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [isLayoutRunning, pluginToolbarItems, reload, searchQuery, selectedNodeId, showLoadingOverlay, viewMode]);
|
||||
}, [
|
||||
displayState.groupedViewAvailable,
|
||||
handlePluginAction,
|
||||
hasGraphContent,
|
||||
isLayoutRunning,
|
||||
pluginToolbarItems,
|
||||
reload,
|
||||
searchQuery,
|
||||
selectedNodeId,
|
||||
selectedNodeState,
|
||||
showLoadingOverlay,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const sceneAdapterProps = {
|
||||
onNodeSelect: focusNode,
|
||||
@@ -1375,6 +1528,8 @@ export function GraphWorkspace() {
|
||||
temporalState,
|
||||
isLayoutRunning,
|
||||
viewMode,
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
showFitViewButton: false,
|
||||
pluginOverlays: pluginOverlays.map((overlay) => overlay.element),
|
||||
onRuntimeChange: handleSceneRuntimeChange,
|
||||
@@ -1504,8 +1659,20 @@ export function GraphWorkspace() {
|
||||
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<MetricChip tone="warm">weight {selectedEdgeState.weight.toFixed(2)}</MetricChip>
|
||||
<MetricChip>{selectedEdgeState.siblingCount} parallel lane{selectedEdgeState.siblingCount === 1 ? "" : "s"}</MetricChip>
|
||||
{selectedEdgeState.isAggregated ? (
|
||||
<MetricChip tone="success">
|
||||
{selectedEdgeState.aggregateCount} bundled edge{selectedEdgeState.aggregateCount === 1 ? "" : "s"}
|
||||
</MetricChip>
|
||||
) : (
|
||||
<MetricChip>{selectedEdgeState.siblingCount} parallel lane{selectedEdgeState.siblingCount === 1 ? "" : "s"}</MetricChip>
|
||||
)}
|
||||
<MetricChip>{selectedEdgeState.familySize} family member{selectedEdgeState.familySize === 1 ? "" : "s"}</MetricChip>
|
||||
{selectedEdgeState.bundleKind ? (
|
||||
<MetricChip>{selectedEdgeState.bundleKind} bundle</MetricChip>
|
||||
) : null}
|
||||
{selectedEdgeState.dominantEdgeType ? (
|
||||
<MetricChip>{selectedEdgeState.dominantEdgeType}</MetricChip>
|
||||
) : null}
|
||||
{selectedEdgeState.provenanceCount > 0 ? (
|
||||
<MetricChip>{selectedEdgeState.provenanceCount} provenance fields</MetricChip>
|
||||
) : null}
|
||||
|
||||
@@ -139,6 +139,10 @@ function toSelectedNodeState(node: ApiNode, neighborCount: number, fallbackColor
|
||||
valid_until: node.valid_until ?? null,
|
||||
properties: node.properties ?? {},
|
||||
neighborCount,
|
||||
visibleNeighborCount: neighborCount,
|
||||
collapsedNeighborCount: 0,
|
||||
isNeighborhoodCollapsed: false,
|
||||
canCollapseNeighborhood: neighborCount > 8,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -427,6 +431,10 @@ export function GraphWorkspaceShell() {
|
||||
valid_until: null,
|
||||
properties: searchNode.properties ?? {},
|
||||
neighborCount: 0,
|
||||
visibleNeighborCount: 0,
|
||||
collapsedNeighborCount: 0,
|
||||
isNeighborhoodCollapsed: false,
|
||||
canCollapseNeighborhood: false,
|
||||
}
|
||||
: null;
|
||||
}, [neighborCountMap, searchResults, selectedNodeId, selectedNodeState, snapshot]);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
import type { GraphViewMode } from "../types";
|
||||
|
||||
export function createViewModeSwitchBehavior(): GraphBehavior {
|
||||
let lastViewMode: "focused" | "full" | null = null;
|
||||
let lastViewMode: GraphViewMode | null = null;
|
||||
|
||||
return {
|
||||
id: "view-mode-switch",
|
||||
|
||||
@@ -17,12 +17,24 @@ import {
|
||||
withAlpha,
|
||||
zoomTierAtLeast,
|
||||
} from "./graphTheme";
|
||||
import type { GraphInteractionState, GraphViewMode } from "./types";
|
||||
import { computeGraphAnalyticsBase } from "./graphAnalytics";
|
||||
import type { GraphDisplayStateSnapshot, GraphInteractionState, GraphViewMode } from "./types";
|
||||
|
||||
const MAX_FOCUS_NEIGHBORS = GRAPH_THEME.focus.maxNeighbors;
|
||||
const FOCUS_RING_CAPACITY = GRAPH_THEME.focus.ringCapacity;
|
||||
const FOCUS_RING_GAP = GRAPH_THEME.focus.ringGap;
|
||||
const FOCUS_PRIMARY_LABELS = GRAPH_THEME.focus.primaryLabels;
|
||||
const COLLAPSE_VISIBLE_NEIGHBORS = 8;
|
||||
const GROUP_SAMPLE_MEMBERS = 8;
|
||||
const AGGREGATED_EDGE_PREFIX = "__agg__:";
|
||||
const COMMUNITY_NODE_PREFIX = "__community__:";
|
||||
|
||||
type GraphRef = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
||||
|
||||
export type GraphDisplayResult = {
|
||||
graph: GraphRef;
|
||||
state: GraphDisplayStateSnapshot;
|
||||
};
|
||||
|
||||
function getOverviewPresenceBoost(cameraRatio: number) {
|
||||
return clamp(0, Math.log2(Math.max(cameraRatio, 1)) / 1.85, 1);
|
||||
@@ -65,7 +77,7 @@ export type ResolvedEdgeStyle = {
|
||||
};
|
||||
|
||||
function forEachDirectedEdgeBetween(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
graphRef: GraphRef,
|
||||
source: string,
|
||||
target: string,
|
||||
callback: (edgeId: string, attrs: EdgeAttributes) => void,
|
||||
@@ -76,7 +88,7 @@ function forEachDirectedEdgeBetween(
|
||||
}
|
||||
|
||||
function collectDirectedEdgeIdsBetween(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
graphRef: GraphRef,
|
||||
source: string,
|
||||
target: string,
|
||||
): string[] {
|
||||
@@ -87,13 +99,52 @@ function collectDirectedEdgeIdsBetween(
|
||||
return edgeIds;
|
||||
}
|
||||
|
||||
function isAggregatedEdgeAttributes(attrs: EdgeAttributes | undefined): boolean {
|
||||
return Boolean(attrs?.isAggregated || (attrs?.rawEdgeIds?.length ?? 0) > 1);
|
||||
}
|
||||
|
||||
function collectRawEdgeIds(attrs: EdgeAttributes | undefined, fallbackEdgeId?: string): string[] {
|
||||
const rawEdgeIds = attrs?.rawEdgeIds?.map((edgeId) => String(edgeId)).filter(Boolean) ?? [];
|
||||
if (rawEdgeIds.length > 0) {
|
||||
return rawEdgeIds;
|
||||
}
|
||||
return fallbackEdgeId ? [String(fallbackEdgeId)] : [];
|
||||
}
|
||||
|
||||
function createEmptyDisplayState(
|
||||
selectedNodeId: string,
|
||||
aggregationEnabled: boolean,
|
||||
): GraphDisplayStateSnapshot {
|
||||
return {
|
||||
aggregationEnabled,
|
||||
groupedViewAvailable: false,
|
||||
selectedRootNodeId: selectedNodeId || null,
|
||||
selectedVisibleNeighborIds: [],
|
||||
selectedCollapsedNeighborIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPathEdgeSet(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
graphRef: GraphRef,
|
||||
path: string[],
|
||||
pathEdgeIds: string[] = [],
|
||||
): Set<string> {
|
||||
if (pathEdgeIds.length > 0) {
|
||||
return new Set<string>(pathEdgeIds.filter((edgeId) => graphRef.hasEdge(edgeId)));
|
||||
const requested = new Set(pathEdgeIds.map((edgeId) => String(edgeId)));
|
||||
const matched = new Set<string>();
|
||||
graphRef.forEachEdge((edgeId, attrs) => {
|
||||
const stableEdgeId = String(edgeId);
|
||||
if (requested.has(stableEdgeId)) {
|
||||
matched.add(stableEdgeId);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawEdgeIds = collectRawEdgeIds(attrs as EdgeAttributes, stableEdgeId);
|
||||
if (rawEdgeIds.some((rawEdgeId) => requested.has(rawEdgeId))) {
|
||||
matched.add(stableEdgeId);
|
||||
}
|
||||
});
|
||||
return matched;
|
||||
}
|
||||
|
||||
const edgeIds = new Set<string>();
|
||||
@@ -104,7 +155,7 @@ export function buildPathEdgeSet(
|
||||
}
|
||||
|
||||
export function buildEdgeEndpointSet(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
graphRef: GraphRef,
|
||||
...edgeIds: Array<string | null | undefined>
|
||||
): Set<string> {
|
||||
const nodeIds = new Set<string>();
|
||||
@@ -123,7 +174,7 @@ export function buildEdgeEndpointSet(
|
||||
}
|
||||
|
||||
function collectFocusEdgeIds(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
graphRef: GraphRef,
|
||||
nodeIds: Set<string>,
|
||||
): Set<string> {
|
||||
const edgeIds = new Set<string>();
|
||||
@@ -187,10 +238,24 @@ function collectImpactedEdgeKeys(
|
||||
}
|
||||
|
||||
function resolveDisplayEdgeIds(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
graphRef: GraphRef,
|
||||
stableEdgeIds: Set<string>,
|
||||
): string[] {
|
||||
return Array.from(stableEdgeIds).filter((edgeId) => graphRef.hasEdge(edgeId));
|
||||
const displayEdgeIds = new Set<string>();
|
||||
graphRef.forEachEdge((edgeId, attrs) => {
|
||||
const stableEdgeId = String(edgeId);
|
||||
if (stableEdgeIds.has(stableEdgeId)) {
|
||||
displayEdgeIds.add(stableEdgeId);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawEdgeIds = collectRawEdgeIds(attrs as EdgeAttributes, stableEdgeId);
|
||||
if (rawEdgeIds.some((rawEdgeId) => stableEdgeIds.has(rawEdgeId))) {
|
||||
displayEdgeIds.add(stableEdgeId);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(displayEdgeIds);
|
||||
}
|
||||
|
||||
export function collectInteractionRefreshTargets(
|
||||
@@ -252,7 +317,7 @@ export function buildFocusSet(nodeId: string): Set<string> {
|
||||
}
|
||||
|
||||
export function isEdgeInteractable(
|
||||
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
graphRef: GraphRef,
|
||||
interactionState: GraphInteractionState,
|
||||
edgeId: string,
|
||||
source: string,
|
||||
@@ -264,6 +329,10 @@ export function isEdgeInteractable(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attrs.isAggregated && (interactionState.viewMode === "grouped" || interactionState.zoomTier === "inspection")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const primaryNodeId = interactionState.selectedNodeId;
|
||||
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
|
||||
if (source === primaryNodeId || target === primaryNodeId) {
|
||||
@@ -507,7 +576,7 @@ export function resolveNodeVisualState(
|
||||
return "neighbor";
|
||||
}
|
||||
if (hoveredNodeId || selectedNodeId || selectedEdgeId || pathNodeIds.size > 0) {
|
||||
if (zoomTier === "overview") {
|
||||
if (zoomTier !== "inspection") {
|
||||
return "default";
|
||||
}
|
||||
return "muted";
|
||||
@@ -839,10 +908,315 @@ export function resolveEdgeElementStyle(
|
||||
};
|
||||
}
|
||||
|
||||
function addNodeIfMissing(targetGraph: GraphRef, nodeId: string, attrs: NodeAttributes) {
|
||||
if (!targetGraph.hasNode(nodeId)) {
|
||||
targetGraph.addNode(nodeId, attrs);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCollapsedNeighborhoodState(
|
||||
nodeId: string,
|
||||
activePath: string[],
|
||||
): Pick<GraphDisplayStateSnapshot, "selectedVisibleNeighborIds" | "selectedCollapsedNeighborIds"> {
|
||||
if (!nodeId || !graph.hasNode(nodeId)) {
|
||||
return {
|
||||
selectedVisibleNeighborIds: [],
|
||||
selectedCollapsedNeighborIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const rankedNeighbors = rankNeighbors(nodeId);
|
||||
const forcedVisible = new Set(activePath.filter((candidateId) => candidateId !== nodeId && graph.hasNode(candidateId)));
|
||||
const visible = new Set<string>();
|
||||
rankedNeighbors.forEach((neighborId, index) => {
|
||||
if (index < COLLAPSE_VISIBLE_NEIGHBORS || forcedVisible.has(neighborId)) {
|
||||
visible.add(neighborId);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
selectedVisibleNeighborIds: Array.from(visible),
|
||||
selectedCollapsedNeighborIds: rankedNeighbors.filter((neighborId) => !visible.has(neighborId)),
|
||||
};
|
||||
}
|
||||
|
||||
function createCollapsedNeighborhoodGraph(
|
||||
nodeId: string,
|
||||
activePath: string[],
|
||||
): Graph<NodeAttributes, EdgeAttributes> {
|
||||
const collapsedState = buildCollapsedNeighborhoodState(nodeId, activePath);
|
||||
const hiddenNeighbors = new Set(collapsedState.selectedCollapsedNeighborIds);
|
||||
if (hiddenNeighbors.size === 0) {
|
||||
return graph.copy() as Graph<NodeAttributes, EdgeAttributes>;
|
||||
}
|
||||
|
||||
const collapsedGraph = graph.copy() as Graph<NodeAttributes, EdgeAttributes>;
|
||||
hiddenNeighbors.forEach((neighborId) => {
|
||||
if (!collapsedGraph.hasNode(neighborId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const incidentEdges = collapsedGraph.edges(neighborId).map((edgeId) => String(edgeId));
|
||||
incidentEdges.forEach((edgeId) => {
|
||||
if (!collapsedGraph.hasEdge(edgeId)) {
|
||||
return;
|
||||
}
|
||||
const [sourceId, targetId] = collapsedGraph.extremities(edgeId);
|
||||
const touchesSelectedPair =
|
||||
(sourceId === nodeId && targetId === neighborId)
|
||||
|| (sourceId === neighborId && targetId === nodeId);
|
||||
if (touchesSelectedPair) {
|
||||
collapsedGraph.dropEdge(edgeId);
|
||||
}
|
||||
});
|
||||
|
||||
if (collapsedGraph.hasNode(neighborId) && collapsedGraph.degree(neighborId) === 0) {
|
||||
collapsedGraph.dropNode(neighborId);
|
||||
}
|
||||
});
|
||||
|
||||
return collapsedGraph;
|
||||
}
|
||||
|
||||
function aggregateDisplayGraph(graphRef: GraphRef): Graph<NodeAttributes, EdgeAttributes> {
|
||||
const aggregated = new Graph<NodeAttributes, EdgeAttributes>({
|
||||
type: "directed",
|
||||
multi: true,
|
||||
allowSelfLoops: false,
|
||||
});
|
||||
|
||||
graphRef.forEachNode((nodeId, attrs) => {
|
||||
aggregated.addNode(nodeId, { ...(attrs as NodeAttributes) });
|
||||
});
|
||||
|
||||
const groupedEdges = new Map<string, Array<{ edgeId: string; attrs: EdgeAttributes }>>();
|
||||
graphRef.forEachEdge((edgeId, attrs, sourceId, targetId) => {
|
||||
const key = `${sourceId}→${targetId}`;
|
||||
const bucket = groupedEdges.get(key) ?? [];
|
||||
bucket.push({ edgeId: String(edgeId), attrs: attrs as EdgeAttributes });
|
||||
groupedEdges.set(key, bucket);
|
||||
});
|
||||
|
||||
groupedEdges.forEach((entries, key) => {
|
||||
const [sourceId, targetId] = key.split("→");
|
||||
if (entries.length === 1) {
|
||||
const [{ edgeId, attrs }] = entries;
|
||||
aggregated.mergeDirectedEdgeWithKey(edgeId, sourceId, targetId, {
|
||||
...attrs,
|
||||
rawEdgeIds: collectRawEdgeIds(attrs, edgeId),
|
||||
isAggregated: isAggregatedEdgeAttributes(attrs),
|
||||
aggregateCount: attrs.aggregateCount ?? collectRawEdgeIds(attrs, edgeId).length,
|
||||
dominantEdgeType: attrs.dominantEdgeType ?? attrs.edgeType,
|
||||
representativeWeight: attrs.representativeWeight ?? Number(attrs.weight ?? 1),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = entries
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const weightDelta = Number(right.attrs.weight ?? 0) - Number(left.attrs.weight ?? 0);
|
||||
if (weightDelta !== 0) {
|
||||
return weightDelta;
|
||||
}
|
||||
const priorityDelta = Number(right.attrs.visualPriority ?? 0) - Number(left.attrs.visualPriority ?? 0);
|
||||
if (priorityDelta !== 0) {
|
||||
return priorityDelta;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
});
|
||||
const representative = sorted[0];
|
||||
const rawEdgeIds = entries.flatMap(({ edgeId, attrs }) => collectRawEdgeIds(attrs, edgeId));
|
||||
const typeCounts = new Map<string, number>();
|
||||
entries.forEach(({ attrs }) => {
|
||||
const edgeType = String(attrs.edgeType ?? "related_to");
|
||||
typeCounts.set(edgeType, (typeCounts.get(edgeType) ?? 0) + 1);
|
||||
});
|
||||
const dominantEdgeType = [...typeCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? representative.attrs.edgeType ?? "related_to";
|
||||
const reverseKey = `${targetId}→${sourceId}`;
|
||||
const isBidirectionalBundle = groupedEdges.has(reverseKey);
|
||||
const syntheticEdgeId = `${AGGREGATED_EDGE_PREFIX}${sourceId}::${targetId}`;
|
||||
|
||||
aggregated.mergeDirectedEdgeWithKey(syntheticEdgeId, sourceId, targetId, {
|
||||
...representative.attrs,
|
||||
edgeId: syntheticEdgeId,
|
||||
familyId: syntheticEdgeId,
|
||||
sourceId,
|
||||
targetId,
|
||||
rawEdgeIds,
|
||||
isAggregated: true,
|
||||
aggregateCount: rawEdgeIds.length,
|
||||
dominantEdgeType: String(dominantEdgeType),
|
||||
representativeWeight: Number(representative.attrs.weight ?? 1),
|
||||
weight: Number(representative.attrs.weight ?? 1),
|
||||
edgeType: String(representative.attrs.edgeType ?? dominantEdgeType ?? "related_to"),
|
||||
parallelCount: rawEdgeIds.length,
|
||||
familySize: rawEdgeIds.length,
|
||||
bundleKind: isBidirectionalBundle ? "bidirectional" : "parallel",
|
||||
isBidirectional: isBidirectionalBundle,
|
||||
});
|
||||
});
|
||||
|
||||
return aggregated;
|
||||
}
|
||||
|
||||
function buildCommunityGroupedGraph(): GraphDisplayResult {
|
||||
const base = computeGraphAnalyticsBase(graph, {
|
||||
computeCommunities: true,
|
||||
computeCentrality: true,
|
||||
});
|
||||
const state = createEmptyDisplayState("", true);
|
||||
state.groupedViewAvailable = base.communitiesByNode.size > 0;
|
||||
if (base.communitiesByNode.size === 0) {
|
||||
return { graph: aggregateDisplayGraph(graph), state };
|
||||
}
|
||||
|
||||
const grouped = new Graph<NodeAttributes, EdgeAttributes>({
|
||||
type: "directed",
|
||||
multi: true,
|
||||
allowSelfLoops: false,
|
||||
});
|
||||
const communityMembers = new Map<number, string[]>();
|
||||
graph.forEachNode((nodeId) => {
|
||||
const communityId = base.communitiesByNode.get(nodeId);
|
||||
if (communityId === undefined) {
|
||||
return;
|
||||
}
|
||||
const bucket = communityMembers.get(communityId) ?? [];
|
||||
bucket.push(nodeId);
|
||||
communityMembers.set(communityId, bucket);
|
||||
});
|
||||
|
||||
communityMembers.forEach((memberIds, communityId) => {
|
||||
const rankedMembers = memberIds
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftScore = base.centralityByNode.get(left)?.score ?? 0;
|
||||
const rightScore = base.centralityByNode.get(right)?.score ?? 0;
|
||||
if (rightScore !== leftScore) {
|
||||
return rightScore - leftScore;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
const anchorNodeId = rankedMembers[0] ?? null;
|
||||
const anchorAttrs = anchorNodeId ? (graph.getNodeAttributes(anchorNodeId) as NodeAttributes) : null;
|
||||
const semanticCounts = new Map<string, number>();
|
||||
memberIds.forEach((memberId) => {
|
||||
const memberAttrs = graph.getNodeAttributes(memberId) as NodeAttributes;
|
||||
const semanticGroup = String(memberAttrs.semanticGroup || memberAttrs.nodeType || "entity");
|
||||
semanticCounts.set(semanticGroup, (semanticCounts.get(semanticGroup) ?? 0) + 1);
|
||||
});
|
||||
const dominantSemanticGroup = [...semanticCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "entity";
|
||||
const color = anchorAttrs?.baseColor || anchorAttrs?.color || GRAPH_THEME.palette.semantic[communityId % GRAPH_THEME.palette.semantic.length];
|
||||
const communityNodeId = `${COMMUNITY_NODE_PREFIX}${communityId}`;
|
||||
addNodeIfMissing(grouped, communityNodeId, {
|
||||
label: anchorAttrs?.label || `Community ${communityId}`,
|
||||
content: anchorAttrs?.content || anchorAttrs?.label || `Community ${communityId}`,
|
||||
x: anchorAttrs?.x ?? 0,
|
||||
y: anchorAttrs?.y ?? 0,
|
||||
size: Math.max(14, 8 + Math.log2(memberIds.length + 1) * 5),
|
||||
baseSize: Math.max(14, 8 + Math.log2(memberIds.length + 1) * 5),
|
||||
color,
|
||||
baseColor: color,
|
||||
mutedColor: withAlpha(color, 0.32),
|
||||
glowColor: withAlpha(color, 0.22),
|
||||
nodeType: "community",
|
||||
semanticGroup: dominantSemanticGroup,
|
||||
properties: {
|
||||
__communityGroup: {
|
||||
communityId: String(communityId),
|
||||
memberCount: memberIds.length,
|
||||
memberNodeIds: memberIds,
|
||||
sampleNodeIds: rankedMembers.slice(0, GROUP_SAMPLE_MEMBERS),
|
||||
anchorNodeId,
|
||||
anchorLabel: anchorAttrs?.label || anchorNodeId || `Community ${communityId}`,
|
||||
dominantSemanticGroup,
|
||||
color,
|
||||
},
|
||||
},
|
||||
isCommunityGroup: true,
|
||||
communityId: String(communityId),
|
||||
memberCount: memberIds.length,
|
||||
anchorNodeId,
|
||||
labelPriority: Math.max(2, Math.log2(memberIds.length + 1)),
|
||||
visualPriority: Math.max(1, Math.log2(memberIds.length + 1)),
|
||||
});
|
||||
});
|
||||
|
||||
const groupedEdges = new Map<string, {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
rawEdgeIds: string[];
|
||||
weight: number;
|
||||
typeCounts: Map<string, number>;
|
||||
}>();
|
||||
|
||||
graph.forEachEdge((edgeId, attrs, sourceId, targetId) => {
|
||||
const sourceCommunity = base.communitiesByNode.get(sourceId);
|
||||
const targetCommunity = base.communitiesByNode.get(targetId);
|
||||
if (sourceCommunity === undefined || targetCommunity === undefined || sourceCommunity === targetCommunity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupedSourceId = `${COMMUNITY_NODE_PREFIX}${sourceCommunity}`;
|
||||
const groupedTargetId = `${COMMUNITY_NODE_PREFIX}${targetCommunity}`;
|
||||
const key = `${groupedSourceId}→${groupedTargetId}`;
|
||||
const bucket = groupedEdges.get(key) ?? {
|
||||
sourceId: groupedSourceId,
|
||||
targetId: groupedTargetId,
|
||||
rawEdgeIds: [],
|
||||
weight: 0,
|
||||
typeCounts: new Map<string, number>(),
|
||||
};
|
||||
bucket.rawEdgeIds.push(String(edgeId));
|
||||
bucket.weight = Math.max(bucket.weight, Number((attrs as EdgeAttributes).weight ?? 1));
|
||||
const edgeType = String((attrs as EdgeAttributes).edgeType ?? "related_to");
|
||||
bucket.typeCounts.set(edgeType, (bucket.typeCounts.get(edgeType) ?? 0) + 1);
|
||||
groupedEdges.set(key, bucket);
|
||||
});
|
||||
|
||||
groupedEdges.forEach((bundle, key) => {
|
||||
const dominantEdgeType = [...bundle.typeCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "related_to";
|
||||
const reverseKey = `${bundle.targetId}→${bundle.sourceId}`;
|
||||
const syntheticEdgeId = `${AGGREGATED_EDGE_PREFIX}${key}`;
|
||||
grouped.mergeDirectedEdgeWithKey(syntheticEdgeId, bundle.sourceId, bundle.targetId, {
|
||||
edgeId: syntheticEdgeId,
|
||||
familyId: syntheticEdgeId,
|
||||
sourceId: bundle.sourceId,
|
||||
targetId: bundle.targetId,
|
||||
edgeType: dominantEdgeType,
|
||||
dominantEdgeType,
|
||||
weight: bundle.weight,
|
||||
representativeWeight: bundle.weight,
|
||||
properties: {},
|
||||
rawEdgeIds: bundle.rawEdgeIds,
|
||||
isAggregated: true,
|
||||
aggregateCount: bundle.rawEdgeIds.length,
|
||||
familySize: bundle.rawEdgeIds.length,
|
||||
parallelCount: bundle.rawEdgeIds.length,
|
||||
isBidirectional: groupedEdges.has(reverseKey),
|
||||
bundleKind: "community",
|
||||
visualPriority: 2,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
graph: grouped,
|
||||
state: {
|
||||
aggregationEnabled: true,
|
||||
groupedViewAvailable: true,
|
||||
selectedRootNodeId: null,
|
||||
selectedVisibleNeighborIds: [],
|
||||
selectedCollapsedNeighborIds: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createFocusedGraph(
|
||||
nodeId: string,
|
||||
activePath: string[],
|
||||
activePathEdgeIds: string[] = [],
|
||||
collapseNeighborhood = false,
|
||||
): Graph<NodeAttributes, EdgeAttributes> {
|
||||
const focused = new Graph<NodeAttributes, EdgeAttributes>({
|
||||
type: "directed",
|
||||
@@ -851,8 +1225,15 @@ export function createFocusedGraph(
|
||||
});
|
||||
|
||||
const rankedNeighbors = rankNeighbors(nodeId).slice(0, MAX_FOCUS_NEIGHBORS);
|
||||
const focusIds = new Set<string>([nodeId, ...rankedNeighbors]);
|
||||
const labelledNeighborIds = new Set(rankedNeighbors.slice(0, FOCUS_PRIMARY_LABELS));
|
||||
const collapsedState = collapseNeighborhood
|
||||
? buildCollapsedNeighborhoodState(nodeId, activePath)
|
||||
: {
|
||||
selectedVisibleNeighborIds: rankedNeighbors,
|
||||
selectedCollapsedNeighborIds: [],
|
||||
};
|
||||
const visibleNeighborIds = rankedNeighbors.filter((neighborId) => collapsedState.selectedVisibleNeighborIds.includes(neighborId));
|
||||
const focusIds = new Set<string>([nodeId, ...visibleNeighborIds]);
|
||||
const labelledNeighborIds = new Set(visibleNeighborIds.slice(0, FOCUS_PRIMARY_LABELS));
|
||||
const pathNodeIds = new Set(activePath);
|
||||
const pathEdgeIds = buildPathEdgeSet(graph, activePath, activePathEdgeIds);
|
||||
|
||||
@@ -875,7 +1256,7 @@ export function createFocusedGraph(
|
||||
label: selectedState.label,
|
||||
});
|
||||
|
||||
rankedNeighbors.forEach((neighborId, index) => {
|
||||
visibleNeighborIds.forEach((neighborId, index) => {
|
||||
const baseAttrs = graph.getNodeAttributes(neighborId) as NodeAttributes;
|
||||
const ring = Math.floor(index / FOCUS_RING_CAPACITY);
|
||||
const ringIndex = index % FOCUS_RING_CAPACITY;
|
||||
@@ -940,7 +1321,7 @@ export function createFocusedGraph(
|
||||
}
|
||||
}
|
||||
|
||||
return focused;
|
||||
return aggregateDisplayGraph(focused);
|
||||
}
|
||||
|
||||
export function resolveDisplayGraph(
|
||||
@@ -948,9 +1329,64 @@ export function resolveDisplayGraph(
|
||||
activePath: string[],
|
||||
activePathEdgeIds: string[],
|
||||
viewMode: GraphViewMode,
|
||||
) {
|
||||
options?: {
|
||||
aggregationEnabled?: boolean;
|
||||
collapsedNeighborhoodNodeIds?: Iterable<string>;
|
||||
},
|
||||
): GraphDisplayResult {
|
||||
const aggregationEnabled = options?.aggregationEnabled ?? true;
|
||||
const collapsedNeighborhoodNodeIds = new Set(
|
||||
Array.from(options?.collapsedNeighborhoodNodeIds ?? []).filter((nodeId) => typeof nodeId === "string"),
|
||||
);
|
||||
const displayState = createEmptyDisplayState(selectedNodeId, aggregationEnabled);
|
||||
displayState.groupedViewAvailable = computeGraphAnalyticsBase(graph, {
|
||||
computeCommunities: true,
|
||||
computeCentrality: false,
|
||||
}).communitiesByNode.size > 0;
|
||||
const isFocusedView = viewMode === "focused" && Boolean(selectedNodeId) && graph.hasNode(selectedNodeId);
|
||||
return isFocusedView && selectedNodeId ? createFocusedGraph(selectedNodeId, activePath, activePathEdgeIds) : graph;
|
||||
const isGroupedView = viewMode === "grouped";
|
||||
const shouldCollapseNeighborhood = Boolean(selectedNodeId && collapsedNeighborhoodNodeIds.has(selectedNodeId));
|
||||
|
||||
if (selectedNodeId && graph.hasNode(selectedNodeId)) {
|
||||
const collapsedState = shouldCollapseNeighborhood
|
||||
? buildCollapsedNeighborhoodState(selectedNodeId, activePath)
|
||||
: {
|
||||
selectedVisibleNeighborIds: rankNeighbors(selectedNodeId),
|
||||
selectedCollapsedNeighborIds: [],
|
||||
};
|
||||
displayState.selectedRootNodeId = selectedNodeId;
|
||||
displayState.selectedVisibleNeighborIds = collapsedState.selectedVisibleNeighborIds;
|
||||
displayState.selectedCollapsedNeighborIds = collapsedState.selectedCollapsedNeighborIds;
|
||||
}
|
||||
|
||||
if (isGroupedView) {
|
||||
const grouped = buildCommunityGroupedGraph();
|
||||
return {
|
||||
graph: grouped.graph,
|
||||
state: {
|
||||
...grouped.state,
|
||||
selectedRootNodeId: displayState.selectedRootNodeId,
|
||||
selectedVisibleNeighborIds: displayState.selectedVisibleNeighborIds,
|
||||
selectedCollapsedNeighborIds: displayState.selectedCollapsedNeighborIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isFocusedView && selectedNodeId) {
|
||||
return {
|
||||
graph: createFocusedGraph(selectedNodeId, activePath, activePathEdgeIds, shouldCollapseNeighborhood),
|
||||
state: displayState,
|
||||
};
|
||||
}
|
||||
|
||||
const baseGraph = shouldCollapseNeighborhood && selectedNodeId
|
||||
? createCollapsedNeighborhoodGraph(selectedNodeId, activePath)
|
||||
: graph;
|
||||
|
||||
return {
|
||||
graph: aggregationEnabled ? aggregateDisplayGraph(baseGraph) : baseGraph,
|
||||
state: displayState,
|
||||
};
|
||||
}
|
||||
|
||||
export function createInteractionState(
|
||||
|
||||
@@ -9,6 +9,7 @@ export type GraphBadgeKind = "inferred" | "temporal" | "provenance";
|
||||
|
||||
type GraphNodeColorMode = "base" | "selected" | "hovered" | "path" | "muted";
|
||||
type GraphEdgeColorMode = "overview" | "backbone" | "structure" | "inspection" | "hover" | "path" | "focus" | "muted";
|
||||
const IS_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV);
|
||||
|
||||
export interface GraphTheme {
|
||||
palette: {
|
||||
@@ -305,10 +306,10 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
zoomTiers: {
|
||||
overview: {
|
||||
maxRatio: Number.POSITIVE_INFINITY,
|
||||
nodeScale: 0.88,
|
||||
labelThreshold: 0.92,
|
||||
labelBudget: 28,
|
||||
edgePriorityThreshold: 0.55,
|
||||
nodeScale: 0.72,
|
||||
labelThreshold: 0.995,
|
||||
labelBudget: 4,
|
||||
edgePriorityThreshold: 0.72,
|
||||
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
||||
edgeSizeScale: 0.62,
|
||||
showBadges: false,
|
||||
@@ -317,21 +318,21 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
structure: {
|
||||
maxRatio: 1.2,
|
||||
nodeScale: 1.02,
|
||||
labelThreshold: 0.82,
|
||||
labelBudget: 60,
|
||||
edgePriorityThreshold: 0.3,
|
||||
arrowPriorityThreshold: 0.65,
|
||||
edgeSizeScale: 1.05,
|
||||
showBadges: true,
|
||||
showCurves: true,
|
||||
showContextualArrows: true,
|
||||
nodeScale: 0.94,
|
||||
labelThreshold: 0.93,
|
||||
labelBudget: 18,
|
||||
edgePriorityThreshold: 0.4,
|
||||
arrowPriorityThreshold: 0.75,
|
||||
edgeSizeScale: 0.92,
|
||||
showBadges: false,
|
||||
showCurves: false,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
inspection: {
|
||||
maxRatio: 0.5,
|
||||
nodeScale: 1.08,
|
||||
labelThreshold: 0.6,
|
||||
labelBudget: 120,
|
||||
nodeScale: 1,
|
||||
labelThreshold: 0.8,
|
||||
labelBudget: 40,
|
||||
edgePriorityThreshold: 0,
|
||||
arrowPriorityThreshold: 0.45,
|
||||
edgeSizeScale: 1.18,
|
||||
@@ -341,7 +342,7 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
forceVisibleStates: ["hovered", "selected", "neighbor", "path"],
|
||||
forceVisibleStates: ["hovered", "selected", "path"],
|
||||
policies: {
|
||||
none: { minZoomTier: "inspection" },
|
||||
priority: { minZoomTier: "overview" },
|
||||
@@ -391,26 +392,26 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
nodes: {
|
||||
backgroundScale: 0.52,
|
||||
mutedAlpha: 0.08,
|
||||
mutedAlpha: 0.16,
|
||||
strokeHierarchy: {
|
||||
overview: { base: 0.05, emphasis: 0.34, muted: 0.02 },
|
||||
structure: { base: 1.05, emphasis: 1.45, muted: 0.55 },
|
||||
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
|
||||
},
|
||||
states: {
|
||||
default: { color: "base", sizeMultiplier: 0.92, minSize: 3.5, forceLabel: false, zIndex: 0, borderBoost: -0.18 },
|
||||
hovered: { color: "hovered", sizeMultiplier: 1.28, minSize: 13.5, forceLabel: true, zIndex: 4, borderBoost: 0.28 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.14, minSize: 11.5, forceLabel: true, zIndex: 3, borderBoost: 0.24 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.96, minSize: 5.5, forceLabel: true, zIndex: 2, borderBoost: 0.04 },
|
||||
path: { color: "path", sizeMultiplier: 1.08, minSize: 7.0, forceLabel: true, zIndex: 2, borderBoost: 0.12 },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 },
|
||||
muted: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 },
|
||||
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
hovered: { color: "hovered", sizeMultiplier: 1.1, minSize: 10.8, forceLabel: true, zIndex: 4, borderBoost: 0.18 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.4, forceLabel: true, zIndex: 3, borderBoost: 0.18 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.78, minSize: 4.2, forceLabel: false, zIndex: 2, borderBoost: -0.12 },
|
||||
path: { color: "path", sizeMultiplier: 0.97, minSize: 5.8, forceLabel: true, zIndex: 2, borderBoost: 0.06 },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
muted: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
},
|
||||
variants: {
|
||||
default: { sizeMultiplier: 1, borderBoost: 0, haloBoost: 0, badgeVisibleFrom: "inspection" },
|
||||
temporal: { sizeMultiplier: 1.02, borderBoost: 0.12, haloBoost: 0.1, badgeKind: "temporal", badgeVisibleFrom: "structure" },
|
||||
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "structure" },
|
||||
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "structure" },
|
||||
temporal: { sizeMultiplier: 1.02, borderBoost: 0.12, haloBoost: 0.1, badgeKind: "temporal", badgeVisibleFrom: "inspection" },
|
||||
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "inspection" },
|
||||
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "inspection" },
|
||||
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
|
||||
},
|
||||
selectedRing: {
|
||||
@@ -530,7 +531,7 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
maxGroups: 8,
|
||||
},
|
||||
diagnostics: {
|
||||
enabledInDev: import.meta.env.DEV,
|
||||
enabledInDev: IS_DEV,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
|
||||
}
|
||||
|
||||
const selected = context.getSelectedNodeState();
|
||||
const displayState = context.getDisplayState();
|
||||
if (!selected) {
|
||||
return {
|
||||
id: NEIGHBORHOOD_PANEL_ID,
|
||||
@@ -82,6 +83,11 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
|
||||
return left.label.localeCompare(right.label);
|
||||
})
|
||||
.slice(0, MAX_NEIGHBORS);
|
||||
const hiddenNeighborCount = displayState.selectedCollapsedNeighborIds.length;
|
||||
const aggregatedEdgeCount = context.displayGraph
|
||||
.edges()
|
||||
.map((edgeId) => context.displayGraph.getEdgeAttributes(edgeId) as { isAggregated?: boolean })
|
||||
.filter((attrs) => attrs.isAggregated).length;
|
||||
|
||||
return {
|
||||
id: NEIGHBORHOOD_PANEL_ID,
|
||||
@@ -97,6 +103,34 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
|
||||
<div style={summaryStyle}>
|
||||
{selected.neighborCount.toLocaleString()} direct neighbors in the full graph
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => context.dispatchAction({ type: "collapseNeighborhood" })}
|
||||
disabled={!selected.canCollapseNeighborhood || selected.isNeighborhoodCollapsed}
|
||||
style={controlButtonStyle}
|
||||
>
|
||||
Collapse Neighborhood
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => context.dispatchAction({ type: "expandNeighborhood" })}
|
||||
disabled={!selected.isNeighborhoodCollapsed}
|
||||
style={controlButtonStyle}
|
||||
>
|
||||
Expand Neighborhood
|
||||
</button>
|
||||
</div>
|
||||
{hiddenNeighborCount > 0 ? (
|
||||
<div style={summaryStyle}>
|
||||
{hiddenNeighborCount.toLocaleString()} lower-priority neighbors are collapsed in the current view.
|
||||
</div>
|
||||
) : null}
|
||||
{aggregatedEdgeCount > 0 ? (
|
||||
<div style={summaryStyle}>
|
||||
{aggregatedEdgeCount.toLocaleString()} aggregated structural bundle{aggregatedEdgeCount === 1 ? "" : "s"} visible.
|
||||
</div>
|
||||
) : null}
|
||||
{neighbors.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{neighbors.map((neighbor) => (
|
||||
@@ -159,6 +193,16 @@ const neighborButtonStyle: CSSProperties = {
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const controlButtonStyle: CSSProperties = {
|
||||
padding: "7px 10px",
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
borderRadius: 10,
|
||||
color: "#dce7f4",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const swatchStyle: CSSProperties = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { GraphTheme } from "../graphTheme";
|
||||
import type { GraphSceneRuntime } from "../scene";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphEffectToggle,
|
||||
@@ -31,6 +32,8 @@ export type GraphPluginActionRequest =
|
||||
| { type: "focusNode"; nodeId: string }
|
||||
| { type: "selectNode"; nodeId: string }
|
||||
| { type: "setViewMode"; viewMode: GraphViewMode }
|
||||
| { type: "collapseNeighborhood" }
|
||||
| { type: "expandNeighborhood" }
|
||||
| { type: "toggleEffect"; effect: GraphEffectToggle }
|
||||
| { type: "setEffect"; effect: GraphEffectToggle; enabled: boolean }
|
||||
| { type: "togglePanel"; panelId: string }
|
||||
@@ -77,6 +80,7 @@ export interface GraphPluginContext {
|
||||
getEffectsState: () => GraphEffectsState;
|
||||
getDiagnosticsSnapshot: () => GraphDiagnosticsSnapshot | null;
|
||||
getAnalyticsSnapshot: () => GraphAnalyticsSnapshot | null;
|
||||
getDisplayState: () => GraphDisplayStateSnapshot;
|
||||
isPanelOpen: (panelId: string) => boolean;
|
||||
dispatchAction: (action: GraphPluginActionRequest) => void;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface GraphSceneProps extends GraphSceneEventMap {
|
||||
layoutSource?: GraphLayoutSource;
|
||||
onLayoutStatusChange?: (status: GraphLayoutStatus) => void;
|
||||
viewMode: GraphViewMode;
|
||||
aggregationEnabled?: boolean;
|
||||
collapsedNeighborhoodNodeIds?: string[];
|
||||
className?: string;
|
||||
showFitViewButton?: boolean;
|
||||
pluginOverlays?: ReactNode[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type GraphViewMode = "focused" | "full";
|
||||
export type GraphViewMode = "focused" | "full" | "grouped";
|
||||
export type GraphLayoutSource = "provided" | "carried" | "runtime";
|
||||
export type GraphLayoutState = "idle" | "bootstrapping" | "running" | "stabilized" | "interactive" | "failed";
|
||||
export type GraphLoadPhase =
|
||||
@@ -31,6 +31,14 @@ export interface GraphInteractionState {
|
||||
isLayoutRunning: boolean;
|
||||
}
|
||||
|
||||
export interface GraphDisplayStateSnapshot {
|
||||
aggregationEnabled: boolean;
|
||||
groupedViewAvailable: boolean;
|
||||
selectedRootNodeId: string | null;
|
||||
selectedVisibleNeighborIds: string[];
|
||||
selectedCollapsedNeighborIds: string[];
|
||||
}
|
||||
|
||||
export type GraphEffectToggle =
|
||||
| "pathPulseEnabled"
|
||||
| "pathFlowEnabled"
|
||||
@@ -245,6 +253,10 @@ export interface GraphSelectedNodeState {
|
||||
valid_until?: string | null;
|
||||
properties: Record<string, unknown>;
|
||||
neighborCount: number;
|
||||
visibleNeighborCount: number;
|
||||
collapsedNeighborCount: number;
|
||||
isNeighborhoodCollapsed: boolean;
|
||||
canCollapseNeighborhood: boolean;
|
||||
}
|
||||
|
||||
export interface GraphSelectedEdgeState {
|
||||
@@ -260,6 +272,12 @@ export interface GraphSelectedEdgeState {
|
||||
provenanceCount: number;
|
||||
familySize: number;
|
||||
siblingCount: number;
|
||||
isAggregated: boolean;
|
||||
aggregateCount: number;
|
||||
rawEdgeIds: string[];
|
||||
bundleKind: "parallel" | "bidirectional" | "community" | null;
|
||||
dominantEdgeType: string | null;
|
||||
representativeWeight: number;
|
||||
}
|
||||
|
||||
export interface GraphStageHandle {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
batchMergeEdges,
|
||||
batchMergeNodes,
|
||||
clearGraph,
|
||||
} from "../src/store/graphStore.ts";
|
||||
import { resolveDisplayGraph } from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
|
||||
|
||||
function addNode(id: string, semanticGroup = "entity") {
|
||||
batchMergeNodes([
|
||||
{
|
||||
id,
|
||||
attributes: {
|
||||
label: id,
|
||||
content: id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 8,
|
||||
color: "#63E6FF",
|
||||
baseColor: "#63E6FF",
|
||||
nodeType: semanticGroup,
|
||||
semanticGroup,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function addEdge(id: string, source: string, target: string, weight = 1) {
|
||||
batchMergeEdges([
|
||||
{
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
attributes: {
|
||||
edgeType: "related_to",
|
||||
weight,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
clearGraph();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
clearGraph();
|
||||
});
|
||||
|
||||
test("resolveDisplayGraph bundles parallel edges in full view", () => {
|
||||
addNode("a");
|
||||
addNode("b");
|
||||
addEdge("e1", "a", "b", 1);
|
||||
addEdge("e2", "a", "b", 2);
|
||||
|
||||
const { graph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: true });
|
||||
assert.equal(graph.size, 1);
|
||||
|
||||
const edgeId = graph.edges()[0];
|
||||
const attrs = graph.getEdgeAttributes(edgeId) as {
|
||||
isAggregated?: boolean;
|
||||
aggregateCount?: number;
|
||||
rawEdgeIds?: string[];
|
||||
bundleKind?: string;
|
||||
};
|
||||
|
||||
assert.equal(attrs.isAggregated, true);
|
||||
assert.equal(attrs.aggregateCount, 2);
|
||||
assert.deepEqual(new Set(attrs.rawEdgeIds ?? []), new Set(["e1", "e2"]));
|
||||
assert.equal(attrs.bundleKind, "parallel");
|
||||
});
|
||||
|
||||
test("resolveDisplayGraph collapse keeps path neighbor visible", () => {
|
||||
addNode("center");
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const neighbor = `n${index}`;
|
||||
addNode(neighbor);
|
||||
addEdge(`edge-${index}`, "center", neighbor, 1);
|
||||
}
|
||||
|
||||
const { state } = resolveDisplayGraph("center", ["center", "n9"], [], "full", {
|
||||
aggregationEnabled: false,
|
||||
collapsedNeighborhoodNodeIds: ["center"],
|
||||
});
|
||||
|
||||
assert.equal(state.selectedRootNodeId, "center");
|
||||
assert.equal(state.selectedVisibleNeighborIds.includes("n9"), true);
|
||||
assert.equal(state.selectedVisibleNeighborIds.length, 9);
|
||||
assert.equal(state.selectedCollapsedNeighborIds.length, 1);
|
||||
});
|
||||
|
||||
test("resolveDisplayGraph grouped view emits community nodes and edges", () => {
|
||||
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);
|
||||
addEdge("bridge-2", "a2", "b2", 0.1);
|
||||
|
||||
const { graph, state } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
|
||||
|
||||
assert.equal(state.groupedViewAvailable, true);
|
||||
|
||||
const communityNodes = graph.nodes().filter((nodeId) => nodeId.startsWith("__community__"));
|
||||
assert.ok(communityNodes.length >= 2);
|
||||
|
||||
const hasCommunityEdge = graph
|
||||
.edges()
|
||||
.map((edgeId) => graph.getEdgeAttributes(edgeId) as { bundleKind?: string; isAggregated?: boolean; aggregateCount?: number })
|
||||
.some((attrs) => attrs.bundleKind === "community" && attrs.isAggregated === true && Number(attrs.aggregateCount ?? 0) > 0);
|
||||
|
||||
assert.equal(hasCommunityEdge, true);
|
||||
});
|
||||
Reference in New Issue
Block a user