From 7f93eb710446042ebd3306d0154c2df413fe5d77 Mon Sep 17 00:00:00 2001 From: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:13:37 +0500 Subject: [PATCH 1/3] feat(explorer): calm and structurally declutter graph workspace --- semantica-explorer/src/store/graphStore.ts | 10 + .../workspaces/GraphWorkspace/GraphCanvas.tsx | 17 +- .../GraphWorkspace/GraphRuntimeStage.tsx | 4 + .../GraphWorkspace/GraphWorkspace.tsx | 253 ++++++++-- .../GraphWorkspace/GraphWorkspaceShell.tsx | 8 + .../behaviors/viewModeSwitchBehavior.ts | 3 +- .../GraphWorkspace/graphSceneState.ts | 470 +++++++++++++++++- .../workspaces/GraphWorkspace/graphTheme.ts | 44 +- .../plugins/neighborhoodPanelPlugin.tsx | 44 ++ .../GraphWorkspace/plugins/types.ts | 4 + .../src/workspaces/GraphWorkspace/scene.ts | 2 + .../src/workspaces/GraphWorkspace/types.ts | 20 +- 12 files changed, 790 insertions(+), 89 deletions(-) diff --git a/semantica-explorer/src/store/graphStore.ts b/semantica-explorer/src/store/graphStore.ts index 7371978e..f2663746 100644 --- a/semantica-explorer/src/store/graphStore.ts +++ b/semantica-explorer/src/store/graphStore.ts @@ -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; diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx b/semantica-explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx index ecb3b951..ab3092d8 100644 --- a/semantica-explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx +++ b/semantica-explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx @@ -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,15 +113,15 @@ const FA2_SETTINGS = { const SIGMA_SETTINGS = { allowInvalidContainer: true, - labelRenderedSizeThreshold: 4, + labelRenderedSizeThreshold: 6, defaultNodeType: "circle", defaultEdgeType: "line", hideLabelsOnMove: true, hideEdgesOnMove: true, enableEdgeEvents: true, renderEdgeLabels: false, - labelDensity: 0.86, - labelGridCellSize: 100, + labelDensity: 0.7, + labelGridCellSize: 140, zIndex: true, webGLTarget: "webgl2" as const, nodeProgramClasses: SEMANTICA_NODE_PROGRAM_CLASSES, @@ -577,6 +579,8 @@ export const GraphCanvas = forwardRef( temporalState, isLayoutRunning, viewMode, + aggregationEnabled = true, + collapsedNeighborhoodNodeIds = [], className, showFitViewButton = true, pluginOverlays = [], @@ -612,8 +616,11 @@ export const GraphCanvas = forwardRef( 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( diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/GraphRuntimeStage.tsx b/semantica-explorer/src/workspaces/GraphWorkspace/GraphRuntimeStage.tsx index f1dac367..bbb60115 100644 --- a/semantica-explorer/src/workspaces/GraphWorkspace/GraphRuntimeStage.tsx +++ b/semantica-explorer/src/workspaces/GraphWorkspace/GraphRuntimeStage.tsx @@ -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, }; } diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx b/semantica-explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx index a01dd32c..833a6954 100644 --- a/semantica-explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx +++ b/semantica-explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx @@ -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 type { EdgeAttributes, NodeAttributes } from "../../store/graphStore"; import { curveGroupForPair } from "../../store/edgePairKeys.js"; @@ -10,6 +11,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, @@ -22,6 +24,7 @@ import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel"; import type { GraphSceneHandle, GraphSceneRuntime } from "./scene"; import type { GraphAnalyticsSnapshot, + GraphDisplayStateSnapshot, GraphDiagnosticsSnapshot, GraphEffectToggle, GraphEffectsState, @@ -88,7 +91,7 @@ const DEFAULT_EFFECTS_STATE: GraphEffectsState = { pathFlowEnabled: false, lensEnabled: false, temporalEmphasisEnabled: false, - semanticRegionsEnabled: true, + semanticRegionsEnabled: false, contoursEnabled: false, pathfindingEnabled: false, communitiesEnabled: false, @@ -493,7 +496,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; } @@ -518,31 +524,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, +): 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; 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) { @@ -550,6 +583,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, @@ -563,6 +601,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), }; } @@ -625,7 +669,9 @@ export function GraphWorkspace() { const [selectedNodeId, setSelectedNodeId] = useState(""); const [selectedEdgeId, setSelectedEdgeId] = useState(""); const [isLayoutRunning, setIsLayoutRunning] = useState(false); - const [viewMode, setViewMode] = useState("focused"); + const [viewMode, setViewMode] = useState("full"); + const [aggregationEnabled] = useState(true); + const [collapsedNeighborhoodNodeIds, setCollapsedNeighborhoodNodeIds] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [searchError, setSearchError] = useState(""); @@ -661,7 +707,7 @@ export function GraphWorkspace() { focusedNodeId: "", activePath: [], activePathEdgeIds: [], - viewMode: "focused", + viewMode: "full", zoomTier: "overview", isLayoutRunning: false, }); @@ -776,6 +822,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); @@ -784,7 +852,7 @@ export function GraphWorkspace() { if (nodeId) { setIsLayoutRunning(false); } - }, []); + }, [viewMode]); const handleEdgeSelect = useCallback((edgeId: string) => { setSelectedEdgeId(edgeId); @@ -923,32 +991,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( () => ({ @@ -1056,6 +1148,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; @@ -1092,7 +1198,7 @@ export function GraphWorkspace() { setActiveDockPanelId((previous) => (previous === action.panelId ? null : previous)); return; } - }, [focusNode, setEffectToggle]); + }, [focusNode, selectedNodeId, setEffectToggle]); const diagnosticsSnapshot = useMemo(() => { if (!GRAPH_THEME.effects.diagnostics.enabledInDev || !graphDiagnosticsState) { @@ -1132,9 +1238,11 @@ export function GraphWorkspace() { getEffectsState: () => effectsState, getDiagnosticsSnapshot: () => diagnosticsSnapshot, getAnalyticsSnapshot: () => graphAnalyticsState, + getDisplayState: () => displayState, isPanelOpen: (panelId: string) => Boolean(pluginPanelState[panelId]), dispatchAction: handlePluginAction, }), [ + displayState, graphAnalyticsState, diagnosticsSnapshot, effectsState, @@ -1260,17 +1368,10 @@ export function GraphWorkspace() { const coreToolbarGroups = useMemo(() => { 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", @@ -1278,6 +1379,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" }), + }, ], }); } @@ -1331,7 +1472,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, @@ -1344,6 +1497,8 @@ export function GraphWorkspace() { temporalState, isLayoutRunning, viewMode, + aggregationEnabled, + collapsedNeighborhoodNodeIds, showFitViewButton: false, pluginOverlays: pluginOverlays.map((overlay) => overlay.element), onRuntimeChange: handleSceneRuntimeChange, @@ -1473,8 +1628,20 @@ export function GraphWorkspace() {
weight {selectedEdgeState.weight.toFixed(2)} - {selectedEdgeState.siblingCount} parallel lane{selectedEdgeState.siblingCount === 1 ? "" : "s"} + {selectedEdgeState.isAggregated ? ( + + {selectedEdgeState.aggregateCount} bundled edge{selectedEdgeState.aggregateCount === 1 ? "" : "s"} + + ) : ( + {selectedEdgeState.siblingCount} parallel lane{selectedEdgeState.siblingCount === 1 ? "" : "s"} + )} {selectedEdgeState.familySize} family member{selectedEdgeState.familySize === 1 ? "" : "s"} + {selectedEdgeState.bundleKind ? ( + {selectedEdgeState.bundleKind} bundle + ) : null} + {selectedEdgeState.dominantEdgeType ? ( + {selectedEdgeState.dominantEdgeType} + ) : null} {selectedEdgeState.provenanceCount > 0 ? ( {selectedEdgeState.provenanceCount} provenance fields ) : null} diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx b/semantica-explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx index 7e32fcca..b01f2713 100644 --- a/semantica-explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx +++ b/semantica-explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx @@ -137,6 +137,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, }; } @@ -425,6 +429,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]); diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts b/semantica-explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts index b6fd493c..c7d30335 100644 --- a/semantica-explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts +++ b/semantica-explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts @@ -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", diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/graphSceneState.ts b/semantica-explorer/src/workspaces/GraphWorkspace/graphSceneState.ts index a4d066a0..8a7bc15f 100644 --- a/semantica-explorer/src/workspaces/GraphWorkspace/graphSceneState.ts +++ b/semantica-explorer/src/workspaces/GraphWorkspace/graphSceneState.ts @@ -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; + +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, + graphRef: GraphRef, source: string, target: string, callback: (edgeId: string, attrs: EdgeAttributes) => void, @@ -76,7 +88,7 @@ function forEachDirectedEdgeBetween( } function collectDirectedEdgeIdsBetween( - graphRef: typeof graph | Graph, + 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, + graphRef: GraphRef, path: string[], pathEdgeIds: string[] = [], ): Set { if (pathEdgeIds.length > 0) { - return new Set(pathEdgeIds.filter((edgeId) => graphRef.hasEdge(edgeId))); + const requested = new Set(pathEdgeIds.map((edgeId) => String(edgeId))); + const matched = new Set(); + 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(); @@ -104,7 +155,7 @@ export function buildPathEdgeSet( } export function buildEdgeEndpointSet( - graphRef: typeof graph | Graph, + graphRef: GraphRef, ...edgeIds: Array ): Set { const nodeIds = new Set(); @@ -123,7 +174,7 @@ export function buildEdgeEndpointSet( } function collectFocusEdgeIds( - graphRef: typeof graph | Graph, + graphRef: GraphRef, nodeIds: Set, ): Set { const edgeIds = new Set(); @@ -187,10 +238,24 @@ function collectImpactedEdgeKeys( } function resolveDisplayEdgeIds( - graphRef: typeof graph | Graph, + graphRef: GraphRef, stableEdgeIds: Set, ): string[] { - return Array.from(stableEdgeIds).filter((edgeId) => graphRef.hasEdge(edgeId)); + const displayEdgeIds = new Set(); + 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 { } export function isEdgeInteractable( - graphRef: typeof graph | Graph, + 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 { + 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(); + 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 { + const collapsedState = buildCollapsedNeighborhoodState(nodeId, activePath); + const hiddenNeighbors = new Set(collapsedState.selectedCollapsedNeighborIds); + if (hiddenNeighbors.size === 0) { + return graph.copy() as Graph; + } + + const collapsedGraph = graph.copy() as Graph; + 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 { + const aggregated = new Graph({ + type: "directed", + multi: true, + allowSelfLoops: false, + }); + + graphRef.forEachNode((nodeId, attrs) => { + aggregated.addNode(nodeId, { ...(attrs as NodeAttributes) }); + }); + + const groupedEdges = new Map>(); + 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(); + 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({ + type: "directed", + multi: true, + allowSelfLoops: false, + }); + const communityMembers = new Map(); + 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(); + 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; + }>(); + + 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(), + }; + 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 { const focused = new Graph({ type: "directed", @@ -851,8 +1225,15 @@ export function createFocusedGraph( }); const rankedNeighbors = rankNeighbors(nodeId).slice(0, MAX_FOCUS_NEIGHBORS); - const focusIds = new Set([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([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; + }, +): 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( diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/graphTheme.ts b/semantica-explorer/src/workspaces/GraphWorkspace/graphTheme.ts index bce23092..815722f4 100644 --- a/semantica-explorer/src/workspaces/GraphWorkspace/graphTheme.ts +++ b/semantica-explorer/src/workspaces/GraphWorkspace/graphTheme.ts @@ -305,9 +305,9 @@ export const GRAPH_THEME: GraphTheme = { zoomTiers: { overview: { maxRatio: Number.POSITIVE_INFINITY, - nodeScale: 0.66, - labelThreshold: 0.985, - labelBudget: 10, + nodeScale: 0.72, + labelThreshold: 0.995, + labelBudget: 4, edgePriorityThreshold: 0.72, arrowPriorityThreshold: Number.POSITIVE_INFINITY, edgeSizeScale: 0.34, @@ -317,21 +317,21 @@ export const GRAPH_THEME: GraphTheme = { }, structure: { maxRatio: 1.2, - nodeScale: 0.98, - labelThreshold: 0.88, - labelBudget: 36, + nodeScale: 0.94, + labelThreshold: 0.93, + labelBudget: 18, edgePriorityThreshold: 0.4, arrowPriorityThreshold: 0.75, edgeSizeScale: 0.92, - showBadges: true, - showCurves: true, - showContextualArrows: true, + showBadges: false, + showCurves: false, + showContextualArrows: false, }, inspection: { maxRatio: 0.5, nodeScale: 1, - labelThreshold: 0.7, - labelBudget: 80, + labelThreshold: 0.8, + labelBudget: 40, edgePriorityThreshold: 0, arrowPriorityThreshold: 0.58, edgeSizeScale: 1.04, @@ -341,7 +341,7 @@ export const GRAPH_THEME: GraphTheme = { }, }, labels: { - forceVisibleStates: ["hovered", "selected", "neighbor", "path"], + forceVisibleStates: ["hovered", "selected", "path"], policies: { none: { minZoomTier: "inspection" }, priority: { minZoomTier: "overview" }, @@ -391,7 +391,7 @@ 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 }, @@ -399,18 +399,18 @@ export const GRAPH_THEME: GraphTheme = { }, states: { default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 }, - hovered: { color: "hovered", sizeMultiplier: 1.18, minSize: 12.5, forceLabel: true, zIndex: 4, borderBoost: 0.22 }, - selected: { color: "selected", sizeMultiplier: 1.06, minSize: 10.5, forceLabel: true, zIndex: 3, borderBoost: 0.2 }, - neighbor: { color: "base", sizeMultiplier: 0.84, minSize: 4.8, forceLabel: true, zIndex: 2, borderBoost: -0.08 }, - path: { color: "path", sizeMultiplier: 1.01, minSize: 6.2, forceLabel: true, zIndex: 2, borderBoost: 0.08 }, - inactive: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 }, - muted: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, 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: { diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/plugins/neighborhoodPanelPlugin.tsx b/semantica-explorer/src/workspaces/GraphWorkspace/plugins/neighborhoodPanelPlugin.tsx index 2fb45de8..9cc2d323 100644 --- a/semantica-explorer/src/workspaces/GraphWorkspace/plugins/neighborhoodPanelPlugin.tsx +++ b/semantica-explorer/src/workspaces/GraphWorkspace/plugins/neighborhoodPanelPlugin.tsx @@ -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 = {
{selected.neighborCount.toLocaleString()} direct neighbors in the full graph
+
+ + +
+ {hiddenNeighborCount > 0 ? ( +
+ {hiddenNeighborCount.toLocaleString()} lower-priority neighbors are collapsed in the current view. +
+ ) : null} + {aggregatedEdgeCount > 0 ? ( +
+ {aggregatedEdgeCount.toLocaleString()} aggregated structural bundle{aggregatedEdgeCount === 1 ? "" : "s"} visible. +
+ ) : null} {neighbors.length ? (
{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, diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/plugins/types.ts b/semantica-explorer/src/workspaces/GraphWorkspace/plugins/types.ts index 8814512f..1292ee69 100644 --- a/semantica-explorer/src/workspaces/GraphWorkspace/plugins/types.ts +++ b/semantica-explorer/src/workspaces/GraphWorkspace/plugins/types.ts @@ -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; } diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/scene.ts b/semantica-explorer/src/workspaces/GraphWorkspace/scene.ts index 6a099c48..010e24a7 100644 --- a/semantica-explorer/src/workspaces/GraphWorkspace/scene.ts +++ b/semantica-explorer/src/workspaces/GraphWorkspace/scene.ts @@ -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[]; diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/types.ts b/semantica-explorer/src/workspaces/GraphWorkspace/types.ts index 69b73c12..b679581e 100644 --- a/semantica-explorer/src/workspaces/GraphWorkspace/types.ts +++ b/semantica-explorer/src/workspaces/GraphWorkspace/types.ts @@ -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; 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 { From 16d628997a619f953df117f6c7cb7777eb8a1e11 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Mon, 20 Apr 2026 17:14:40 +0530 Subject: [PATCH 2/3] test(explorer): cover graph display declutter flows Co-authored-by: KaifAhmad1 Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> --- semantica-explorer/package-lock.json | 528 ++++++++++++++++++ semantica-explorer/package.json | 4 +- .../workspaces/GraphWorkspace/graphTheme.ts | 3 +- .../tests/graphSceneState.display.test.ts | 137 +++++ 4 files changed, 670 insertions(+), 2 deletions(-) create mode 100644 semantica-explorer/tests/graphSceneState.display.test.ts diff --git a/semantica-explorer/package-lock.json b/semantica-explorer/package-lock.json index 48e61227..64e09c6f 100644 --- a/semantica-explorer/package-lock.json +++ b/semantica-explorer/package-lock.json @@ -41,6 +41,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": "^8.0.1" @@ -345,6 +346,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1992,6 +2435,48 @@ "dev": true, "license": "ISC" }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2334,6 +2819,19 @@ "node": ">=6.9.0" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3406,6 +3904,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", @@ -3574,6 +4082,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/semantica-explorer/package.json b/semantica-explorer/package.json index 61e69892..fb698fd5 100644 --- a/semantica-explorer/package.json +++ b/semantica-explorer/package.json @@ -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", @@ -44,6 +45,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": "^8.0.1" diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/graphTheme.ts b/semantica-explorer/src/workspaces/GraphWorkspace/graphTheme.ts index 815722f4..6afed942 100644 --- a/semantica-explorer/src/workspaces/GraphWorkspace/graphTheme.ts +++ b/semantica-explorer/src/workspaces/GraphWorkspace/graphTheme.ts @@ -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: { @@ -530,7 +531,7 @@ export const GRAPH_THEME: GraphTheme = { maxGroups: 8, }, diagnostics: { - enabledInDev: import.meta.env.DEV, + enabledInDev: IS_DEV, }, }, }; diff --git a/semantica-explorer/tests/graphSceneState.display.test.ts b/semantica-explorer/tests/graphSceneState.display.test.ts new file mode 100644 index 00000000..b4865026 --- /dev/null +++ b/semantica-explorer/tests/graphSceneState.display.test.ts @@ -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); +}); From 8d2dfaa53c6141d22840bd410e60f00df925fd10 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Mon, 20 Apr 2026 17:33:12 +0530 Subject: [PATCH 3/3] docs(changelog): add PR #483 explorer declutter release notes Co-authored-by: KaifAhmad1 Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1bc150c..4e74952f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`.