Merge pull request #483 from ZohaibHassan16/feat/graph-declutter-and-calm

feat(explorer): calm and structurally declutter graph workspace
This commit is contained in:
Mohd Kaif
2026-04-20 17:50:00 +05:30
committed by GitHub
16 changed files with 1805 additions and 413 deletions
+11
View File
@@ -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`.
+855 -313
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -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"
+10
View File
@@ -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);
});