From 564f7e87c0f50fd1efca12e1d4d99de7a62d88d3 Mon Sep 17 00:00:00 2001 From: Sreeram Date: Tue, 24 Mar 2026 21:19:08 +0530 Subject: [PATCH] feat: lens-based graph navigation with flow view and sidebar history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace flat all-nodes-at-once graph with a two-level drill-down: - Level 1: layer cluster nodes (free-form graph of architectural layers) - Level 2: file nodes within a layer + portal nodes to adjacent layers - Breadcrumb navigation with Escape key to go back Add swim-lane "Flow" view showing all files in columns ordered by request lifecycle (API → Middleware → Service → Data → etc.) using dagre for within-lane vertical positioning. Sidebar improvements: - Navigation history trail (← Back + clickable breadcrumbs) - Directional connection labels (imports/imported by, contains/contained in) - Separate "Defined in this file" section for child classes/functions - Fix search dropdown overlapping breadcrumbs (z-index) - Remove redundant CodeViewer auto-open on node click Layer detector: add External Services and Background Tasks patterns. --- .../core/src/analyzer/layer-detector.ts | 10 + .../dashboard/src/components/Breadcrumb.tsx | 100 +++ .../dashboard/src/components/GraphView.tsx | 606 +++++++++++++----- .../src/components/LayerClusterNode.tsx | 101 +++ .../dashboard/src/components/LayerLegend.tsx | 84 ++- .../dashboard/src/components/NodeInfo.tsx | 174 ++++- .../dashboard/src/components/PortalNode.tsx | 61 ++ .../dashboard/src/components/SearchBar.tsx | 8 +- .../packages/dashboard/src/store.ts | 169 ++++- .../dashboard/src/utils/edgeAggregation.ts | 133 ++++ .../packages/dashboard/src/utils/layout.ts | 196 +++++- 11 files changed, 1438 insertions(+), 204 deletions(-) create mode 100644 understand-anything-plugin/packages/dashboard/src/components/Breadcrumb.tsx create mode 100644 understand-anything-plugin/packages/dashboard/src/components/LayerClusterNode.tsx create mode 100644 understand-anything-plugin/packages/dashboard/src/components/PortalNode.tsx create mode 100644 understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts diff --git a/understand-anything-plugin/packages/core/src/analyzer/layer-detector.ts b/understand-anything-plugin/packages/core/src/analyzer/layer-detector.ts index 07eb89f..e50e94f 100644 --- a/understand-anything-plugin/packages/core/src/analyzer/layer-detector.ts +++ b/understand-anything-plugin/packages/core/src/analyzer/layer-detector.ts @@ -39,6 +39,16 @@ const LAYER_PATTERNS: Array<{ patterns: string[]; layerName: string; description layerName: "Middleware Layer", description: "Request/response middleware and interceptors", }, + { + patterns: ["client", "integration", "external", "sdk", "vendor", "adapter"], + layerName: "External Services", + description: "External service integrations, SDKs, and third-party adapters", + }, + { + patterns: ["worker", "job", "queue", "cron", "consumer", "processor", "scheduler", "background"], + layerName: "Background Tasks", + description: "Background workers, job processors, and scheduled tasks", + }, { patterns: ["util", "helper", "lib", "common", "shared"], layerName: "Utility Layer", diff --git a/understand-anything-plugin/packages/dashboard/src/components/Breadcrumb.tsx b/understand-anything-plugin/packages/dashboard/src/components/Breadcrumb.tsx new file mode 100644 index 0000000..f35de41 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/Breadcrumb.tsx @@ -0,0 +1,100 @@ +import { useCallback, useEffect } from "react"; +import { useDashboardStore } from "../store"; +import type { ViewMode } from "../store"; + +export default function Breadcrumb() { + const navigationLevel = useDashboardStore((s) => s.navigationLevel); + const activeLayerId = useDashboardStore((s) => s.activeLayerId); + const viewMode = useDashboardStore((s) => s.viewMode); + const graph = useDashboardStore((s) => s.graph); + const navigateToOverview = useDashboardStore((s) => s.navigateToOverview); + const setViewMode = useDashboardStore((s) => s.setViewMode); + + const activeLayer = graph?.layers.find((l) => l.id === activeLayerId); + + // Escape key to go back to overview (only in graph mode) + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if ( + e.key === "Escape" && + viewMode === "graph" && + navigationLevel === "layer-detail" + ) { + navigateToOverview(); + } + }, + [viewMode, navigationLevel, navigateToOverview], + ); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + const handleViewToggle = useCallback( + (mode: ViewMode) => { + setViewMode(mode); + }, + [setViewMode], + ); + + return ( +
+ {/* View mode toggle */} +
+ +
+ +
+ + {/* Navigation breadcrumb (only in graph mode) */} + {viewMode === "graph" && navigationLevel === "overview" && ( +
+ Project Overview +
+ )} + + {viewMode === "graph" && navigationLevel === "layer-detail" && ( +
+ + + + {activeLayer?.name ?? "Layer"} + + + (Esc to go back) + +
+ )} + + {viewMode === "flow" && ( +
+ Request Flow +
+ )} +
+ ); +} diff --git a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx index c51fa65..55cd25b 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx @@ -14,76 +14,199 @@ import "@xyflow/react/dist/style.css"; import CustomNode from "./CustomNode"; import type { CustomFlowNode } from "./CustomNode"; +import LayerClusterNode from "./LayerClusterNode"; +import type { LayerClusterFlowNode } from "./LayerClusterNode"; +import PortalNode from "./PortalNode"; +import type { PortalFlowNode } from "./PortalNode"; +import Breadcrumb from "./Breadcrumb"; import { useDashboardStore } from "../store"; -import { applyDagreLayout, NODE_WIDTH, NODE_HEIGHT } from "../utils/layout"; -import { getLayerColor } from "./LayerLegend"; +import { + applyDagreLayout, + applySwimLaneLayout, + NODE_WIDTH, + NODE_HEIGHT, + LAYER_CLUSTER_WIDTH, + LAYER_CLUSTER_HEIGHT, + PORTAL_NODE_WIDTH, + PORTAL_NODE_HEIGHT, +} from "../utils/layout"; +import { + aggregateLayerEdges, + computePortals, + findCrossLayerFileNodes, +} from "../utils/edgeAggregation"; -const LAYER_PADDING = 40; +const nodeTypes = { + custom: CustomNode, + "layer-cluster": LayerClusterNode, + portal: PortalNode, +}; -const nodeTypes = { custom: CustomNode }; +// ── Overview level: layers as cluster nodes ──────────────────────────── -export default function GraphView() { +function useOverviewGraph() { const graph = useDashboardStore((s) => s.graph); + const searchResults = useDashboardStore((s) => s.searchResults); + const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer); + const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds); + + return useMemo(() => { + if (!graph) return { nodes: [] as Node[], edges: [] as Edge[] }; + + const layers = graph.layers ?? []; + if (layers.length === 0) return { nodes: [] as Node[], edges: [] as Edge[] }; + + // Build search match counts per layer + const searchMatchByLayer = new Map(); + if (searchResults.length > 0) { + const nodeToLayer = new Map(); + for (const layer of layers) { + for (const nid of layer.nodeIds) { + nodeToLayer.set(nid, layer.id); + } + } + for (const result of searchResults) { + const lid = nodeToLayer.get(result.nodeId); + if (lid) { + searchMatchByLayer.set(lid, (searchMatchByLayer.get(lid) ?? 0) + 1); + } + } + } + + // Build tour highlight set for layers + const tourLayerIds = new Set(); + if (tourHighlightedNodeIds.length > 0) { + for (const layer of layers) { + if (layer.nodeIds.some((nid) => tourHighlightedNodeIds.includes(nid))) { + tourLayerIds.add(layer.id); + } + } + } + + // Create cluster nodes + const clusterNodes: LayerClusterFlowNode[] = layers.map((layer, i) => { + // Compute aggregate complexity from member nodes + const memberNodes = graph.nodes.filter((n) => layer.nodeIds.includes(n.id)); + const complexCounts = { simple: 0, moderate: 0, complex: 0 }; + for (const n of memberNodes) { + complexCounts[n.complexity]++; + } + const aggregateComplexity = + complexCounts.complex > memberNodes.length * 0.3 + ? "complex" + : complexCounts.moderate > memberNodes.length * 0.3 + ? "moderate" + : "simple"; + + return { + id: layer.id, + type: "layer-cluster" as const, + position: { x: 0, y: 0 }, + data: { + layerId: layer.id, + layerName: layer.name, + layerDescription: layer.description, + fileCount: layer.nodeIds.length, + aggregateComplexity, + layerColorIndex: i, + searchMatchCount: searchMatchByLayer.get(layer.id), + onDrillIn: drillIntoLayer, + }, + }; + }); + + // Aggregate edges between layers + const aggregated = aggregateLayerEdges(graph); + const flowEdges: Edge[] = aggregated.map((agg, i) => ({ + id: `le-${i}`, + source: agg.sourceLayerId, + target: agg.targetLayerId, + label: `${agg.count}`, + style: { + stroke: "rgba(212,165,116,0.4)", + strokeWidth: Math.min(1 + Math.log2(agg.count + 1), 5), + }, + labelStyle: { fill: "#a39787", fontSize: 11, fontWeight: 600 }, + })); + + // Layout with cluster dimensions + const dims = new Map(); + for (const n of clusterNodes) { + dims.set(n.id, { width: LAYER_CLUSTER_WIDTH, height: LAYER_CLUSTER_HEIGHT }); + } + const laid = applyDagreLayout(clusterNodes as unknown as Node[], flowEdges, "TB", dims); + return { nodes: laid.nodes, edges: laid.edges }; + }, [graph, searchResults, drillIntoLayer, tourHighlightedNodeIds]); +} + +// ── Layer detail level: files + portal nodes ─────────────────────────── + +function useLayerDetailGraph() { + const graph = useDashboardStore((s) => s.graph); + const activeLayerId = useDashboardStore((s) => s.activeLayerId); const selectedNodeId = useDashboardStore((s) => s.selectedNodeId); const searchResults = useDashboardStore((s) => s.searchResults); const selectNode = useDashboardStore((s) => s.selectNode); - const openCodeViewer = useDashboardStore((s) => s.openCodeViewer); - const showLayers = useDashboardStore((s) => s.showLayers); const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds); const persona = useDashboardStore((s) => s.persona); const diffMode = useDashboardStore((s) => s.diffMode); const changedNodeIds = useDashboardStore((s) => s.changedNodeIds); const affectedNodeIds = useDashboardStore((s) => s.affectedNodeIds); const focusNodeId = useDashboardStore((s) => s.focusNodeId); - const setFocusNode = useDashboardStore((s) => s.setFocusNode); + const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer); const handleNodeSelect = useCallback( (nodeId: string) => { selectNode(nodeId); - openCodeViewer(nodeId); }, - [selectNode, openCodeViewer], + [selectNode], ); - const { initialNodes, initialEdges } = useMemo(() => { - if (!graph) - return { - initialNodes: [] as (CustomFlowNode | Node)[], - initialEdges: [] as Edge[], - }; + return useMemo(() => { + if (!graph || !activeLayerId) + return { nodes: [] as Node[], edges: [] as Edge[] }; - // Filter nodes and edges based on persona - let filteredGraphNodes = - persona === "non-technical" - ? graph.nodes.filter( - (n) => - n.type === "concept" || n.type === "module" || n.type === "file", - ) - : graph.nodes; + const activeLayer = graph.layers.find((l) => l.id === activeLayerId); + if (!activeLayer) return { nodes: [] as Node[], edges: [] as Edge[] }; + + const layerNodeIds = new Set(activeLayer.nodeIds); + + // Filter to file nodes in this layer + let filteredGraphNodes = graph.nodes.filter( + (n) => layerNodeIds.has(n.id) && n.type === "file", + ); + + // Persona filtering + if (persona === "non-technical") { + filteredGraphNodes = filteredGraphNodes.filter( + (n) => n.type === "concept" || n.type === "module" || n.type === "file", + ); + } let filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id)); - let filteredGraphEdges = - persona === "non-technical" - ? graph.edges.filter( - (e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target), - ) - : graph.edges; - // Focus mode: filter to 1-hop neighborhood of the focused node + // Intra-layer edges only + let filteredGraphEdges = graph.edges.filter( + (e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target), + ); + + // Focus mode: 1-hop neighborhood within the layer if (focusNodeId && filteredNodeIds.has(focusNodeId)) { const focusNeighborIds = new Set([focusNodeId]); for (const edge of filteredGraphEdges) { if (edge.source === focusNodeId) focusNeighborIds.add(edge.target); if (edge.target === focusNodeId) focusNeighborIds.add(edge.source); } - filteredGraphNodes = filteredGraphNodes.filter((n) => focusNeighborIds.has(n.id)); + filteredGraphNodes = filteredGraphNodes.filter((n) => + focusNeighborIds.has(n.id), + ); filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id)); filteredGraphEdges = filteredGraphEdges.filter( (e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target), ); } - // Compute neighbor set for selection-based highlighting + // Neighbor set for selection highlighting const neighborNodeIds = new Set(); if (selectedNodeId) { for (const edge of filteredGraphEdges) { @@ -93,6 +216,7 @@ export default function GraphView() { neighborNodeIds.add(selectedNodeId); } + // Build file flow nodes const flowNodes: CustomFlowNode[] = filteredGraphNodes.map((node) => { const matchResult = searchResults.find((r) => r.nodeId === node.id); const hasSelection = !!selectedNodeId; @@ -111,22 +235,32 @@ export default function GraphView() { isTourHighlighted: tourHighlightedNodeIds.includes(node.id), isDiffChanged: diffMode && changedNodeIds.has(node.id), isDiffAffected: diffMode && affectedNodeIds.has(node.id), - isDiffFaded: diffMode && !changedNodeIds.has(node.id) && !affectedNodeIds.has(node.id), - isNeighbor: hasSelection && neighborNodeIds.has(node.id) && selectedNodeId !== node.id, + isDiffFaded: + diffMode && + !changedNodeIds.has(node.id) && + !affectedNodeIds.has(node.id), + isNeighbor: + hasSelection && + neighborNodeIds.has(node.id) && + selectedNodeId !== node.id, isSelectionFaded: hasSelection && !neighborNodeIds.has(node.id), onNodeClick: handleNodeSelect, }, }; }); - const diffNodeIds = diffMode ? new Set([...changedNodeIds, ...affectedNodeIds]) : new Set(); + // Build diff-aware edges + const diffNodeIds = diffMode + ? new Set([...changedNodeIds, ...affectedNodeIds]) + : new Set(); const flowEdges: Edge[] = filteredGraphEdges.map((edge, i) => { const sourceInDiff = diffMode && diffNodeIds.has(edge.source); const targetInDiff = diffMode && diffNodeIds.has(edge.target); const isImpacted = diffMode && (sourceInDiff || targetInDiff); - // Selection-based edge highlighting - const isSelectedEdge = !!selectedNodeId && (edge.source === selectedNodeId || edge.target === selectedNodeId); + const isSelectedEdge = + !!selectedNodeId && + (edge.source === selectedNodeId || edge.target === selectedNodeId); const hasSelection = !!selectedNodeId; let edgeStyle: React.CSSProperties; @@ -135,9 +269,10 @@ export default function GraphView() { if (isImpacted) { edgeStyle = { - stroke: sourceInDiff && targetInDiff - ? "rgba(224, 82, 82, 0.7)" - : "rgba(212, 160, 48, 0.5)", + stroke: + sourceInDiff && targetInDiff + ? "rgba(224, 82, 82, 0.7)" + : "rgba(212, 160, 48, 0.5)", strokeWidth: 2.5, }; edgeLabelStyle = { fill: "#a39787", fontSize: 10 }; @@ -171,105 +306,259 @@ export default function GraphView() { }; }); - // Run dagre layout on all nodes (without groups) - const laid = applyDagreLayout(flowNodes, flowEdges); - const laidNodes = laid.nodes as CustomFlowNode[]; + // Portal nodes for connected external layers + const portals = computePortals(graph, activeLayerId); + const layerIndexMap = new Map(graph.layers.map((l, i) => [l.id, i])); - const layers = graph.layers ?? []; - if (!showLayers || layers.length === 0) { - return { initialNodes: laidNodes, initialEdges: laid.edges }; - } + const portalNodes: PortalFlowNode[] = portals.map((portal) => ({ + id: `portal:${portal.layerId}`, + type: "portal" as const, + position: { x: 0, y: 0 }, + data: { + targetLayerId: portal.layerId, + targetLayerName: portal.layerName, + connectionCount: portal.connectionCount, + layerColorIndex: layerIndexMap.get(portal.layerId) ?? 0, + onNavigate: drillIntoLayer, + }, + })); - // Build a map of nodeId -> layer for quick lookup - const nodeToLayer = new Map(); - for (const layer of layers) { - for (const nodeId of layer.nodeIds) { - nodeToLayer.set(nodeId, layer.id); - } - } - - // Create group nodes and adjust member positions - const groupNodes: Node[] = []; - const adjustedNodes: (CustomFlowNode | Node)[] = []; - - for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) { - const layer = layers[layerIdx]; - const memberNodes = laidNodes.filter((n) => - layer.nodeIds.includes(n.id), + // Connect portal nodes to the file nodes that have cross-layer edges + const portalEdges: Edge[] = []; + let portalEdgeIdx = flowEdges.length; + for (const portal of portals) { + const crossFiles = findCrossLayerFileNodes( + graph, + activeLayerId, + portal.layerId, ); - - if (memberNodes.length === 0) continue; - - // Compute bounding box around member nodes - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - for (const node of memberNodes) { - const x = node.position.x; - const y = node.position.y; - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x + NODE_WIDTH); - maxY = Math.max(maxY, y + NODE_HEIGHT); - } - - // Group node position = top-left with padding - const groupX = minX - LAYER_PADDING; - const groupY = minY - LAYER_PADDING - 24; // extra space for label - const groupWidth = maxX - minX + LAYER_PADDING * 2; - const groupHeight = maxY - minY + LAYER_PADDING * 2 + 24; - - // Create the group node with distinct color per layer - const layerColor = getLayerColor(layerIdx); - groupNodes.push({ - id: layer.id, - type: "group", - position: { x: groupX, y: groupY }, - data: { label: layer.name }, - style: { - width: groupWidth, - height: groupHeight, - backgroundColor: layerColor.bg, - borderRadius: 12, - border: `2px solid ${layerColor.border}`, - padding: 8, - fontSize: 13, - fontWeight: 600, - color: layerColor.label, - }, - }); - - // Adjust member node positions to be relative to the group - for (const node of memberNodes) { - adjustedNodes.push({ - ...node, - parentId: layer.id, - extent: "parent" as const, - position: { - x: node.position.x - groupX, - y: node.position.y - groupY, - }, - }); + for (const fileId of crossFiles) { + if (filteredNodeIds.has(fileId)) { + portalEdges.push({ + id: `e-${portalEdgeIdx++}`, + source: fileId, + target: `portal:${portal.layerId}`, + style: { stroke: "rgba(212,165,116,0.2)", strokeWidth: 1, strokeDasharray: "4 4" }, + animated: false, + }); + } } } - // Add nodes that are not in any layer (keep original positions) - for (const node of laidNodes) { - if (!nodeToLayer.has(node.id)) { - adjustedNodes.push(node); - } - } - - // Group nodes must come before their children in the array - const allNodes: (CustomFlowNode | Node)[] = [ - ...groupNodes, - ...adjustedNodes, + // Layout with mixed dimensions + const allFlowNodes: Node[] = [ + ...(flowNodes as unknown as Node[]), + ...(portalNodes as unknown as Node[]), ]; + const allFlowEdges = [...flowEdges, ...portalEdges]; - return { initialNodes: allNodes, initialEdges: laid.edges }; - }, [graph, searchResults, selectedNodeId, showLayers, tourHighlightedNodeIds, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds, focusNodeId]); + const dims = new Map(); + for (const n of flowNodes) { + dims.set(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + } + for (const n of portalNodes) { + dims.set(n.id, { width: PORTAL_NODE_WIDTH, height: PORTAL_NODE_HEIGHT }); + } + + const laid = applyDagreLayout(allFlowNodes, allFlowEdges, "TB", dims); + return { nodes: laid.nodes, edges: laid.edges }; + }, [ + graph, + activeLayerId, + selectedNodeId, + searchResults, + tourHighlightedNodeIds, + persona, + handleNodeSelect, + diffMode, + changedNodeIds, + affectedNodeIds, + focusNodeId, + drillIntoLayer, + ]); +} + +// ── Flow (swim-lane) view: all layers as columns ────────────────────── + +function useFlowViewGraph() { + const graph = useDashboardStore((s) => s.graph); + const selectedNodeId = useDashboardStore((s) => s.selectedNodeId); + const searchResults = useDashboardStore((s) => s.searchResults); + const selectNode = useDashboardStore((s) => s.selectNode); + const tourHighlightedNodeIds = useDashboardStore( + (s) => s.tourHighlightedNodeIds, + ); + const diffMode = useDashboardStore((s) => s.diffMode); + const changedNodeIds = useDashboardStore((s) => s.changedNodeIds); + const affectedNodeIds = useDashboardStore((s) => s.affectedNodeIds); + + const handleNodeSelect = useCallback( + (nodeId: string) => { + selectNode(nodeId); + }, + [selectNode], + ); + + return useMemo(() => { + if (!graph || graph.layers.length === 0) + return { nodes: [] as Node[], edges: [] as Edge[] }; + + // Build all file nodes across all layers + const allLayerNodeIds = new Set( + graph.layers.flatMap((l) => l.nodeIds), + ); + + const fileGraphNodes = graph.nodes.filter( + (n) => n.type === "file" && allLayerNodeIds.has(n.id), + ); + + // Neighbor set for selection highlighting + const fileNodeIds = new Set(fileGraphNodes.map((n) => n.id)); + const neighborNodeIds = new Set(); + if (selectedNodeId) { + for (const edge of graph.edges) { + if (edge.source === selectedNodeId && fileNodeIds.has(edge.target)) + neighborNodeIds.add(edge.target); + if (edge.target === selectedNodeId && fileNodeIds.has(edge.source)) + neighborNodeIds.add(edge.source); + } + neighborNodeIds.add(selectedNodeId); + } + + const diffNodeIds = diffMode + ? new Set([...changedNodeIds, ...affectedNodeIds]) + : new Set(); + + const flowNodes: CustomFlowNode[] = fileGraphNodes.map((node) => { + const matchResult = searchResults.find((r) => r.nodeId === node.id); + const hasSelection = !!selectedNodeId; + return { + id: node.id, + type: "custom" as const, + position: { x: 0, y: 0 }, + data: { + label: node.name ?? node.filePath?.split("/").pop() ?? node.id, + nodeType: node.type, + summary: node.summary, + complexity: node.complexity, + isHighlighted: !!matchResult, + searchScore: matchResult?.score, + isSelected: selectedNodeId === node.id, + isTourHighlighted: tourHighlightedNodeIds.includes(node.id), + isDiffChanged: diffMode && changedNodeIds.has(node.id), + isDiffAffected: diffMode && affectedNodeIds.has(node.id), + isDiffFaded: + diffMode && + !changedNodeIds.has(node.id) && + !affectedNodeIds.has(node.id), + isNeighbor: + hasSelection && + neighborNodeIds.has(node.id) && + selectedNodeId !== node.id, + isSelectionFaded: hasSelection && !neighborNodeIds.has(node.id), + onNodeClick: handleNodeSelect, + }, + }; + }); + + // Build cross-lane edges (only between file nodes that are in layers) + const flowEdges: Edge[] = []; + let edgeIdx = 0; + for (const edge of graph.edges) { + if (!fileNodeIds.has(edge.source) || !fileNodeIds.has(edge.target)) + continue; + + const isSelectedEdge = + !!selectedNodeId && + (edge.source === selectedNodeId || edge.target === selectedNodeId); + const hasSelection = !!selectedNodeId; + const sourceInDiff = diffMode && diffNodeIds.has(edge.source); + const targetInDiff = diffMode && diffNodeIds.has(edge.target); + const isImpacted = diffMode && (sourceInDiff || targetInDiff); + + let edgeStyle: React.CSSProperties; + let edgeLabelStyle: React.CSSProperties; + let edgeAnimated: boolean; + + if (isImpacted) { + edgeStyle = { + stroke: + sourceInDiff && targetInDiff + ? "rgba(224, 82, 82, 0.7)" + : "rgba(212, 160, 48, 0.5)", + strokeWidth: 2.5, + }; + edgeLabelStyle = { fill: "#a39787", fontSize: 10 }; + edgeAnimated = true; + } else if (diffMode) { + edgeStyle = { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }; + edgeLabelStyle = { fill: "rgba(163,151,135,0.3)", fontSize: 10 }; + edgeAnimated = false; + } else if (isSelectedEdge) { + edgeStyle = { stroke: "rgba(212,165,116,0.8)", strokeWidth: 2.5 }; + edgeLabelStyle = { fill: "#d4a574", fontSize: 11, fontWeight: 600 }; + edgeAnimated = true; + } else if (hasSelection) { + edgeStyle = { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }; + edgeLabelStyle = { fill: "rgba(163,151,135,0.2)", fontSize: 10 }; + edgeAnimated = false; + } else { + edgeStyle = { stroke: "rgba(212,165,116,0.25)", strokeWidth: 1 }; + edgeLabelStyle = { fill: "#a39787", fontSize: 9 }; + edgeAnimated = false; + } + + flowEdges.push({ + id: `fe-${edgeIdx++}`, + source: edge.source, + target: edge.target, + label: edge.type, + animated: edgeAnimated, + style: edgeStyle, + labelStyle: edgeLabelStyle, + }); + } + + const result = applySwimLaneLayout( + graph, + flowNodes as unknown as Node[], + flowEdges, + ); + return { nodes: result.nodes, edges: result.edges }; + }, [ + graph, + selectedNodeId, + searchResults, + tourHighlightedNodeIds, + handleNodeSelect, + diffMode, + changedNodeIds, + affectedNodeIds, + ]); +} + +// ── Main GraphView component ─────────────────────────────────────────── + +export default function GraphView() { + const graph = useDashboardStore((s) => s.graph); + const navigationLevel = useDashboardStore((s) => s.navigationLevel); + const activeLayerId = useDashboardStore((s) => s.activeLayerId); + const viewMode = useDashboardStore((s) => s.viewMode); + const selectNode = useDashboardStore((s) => s.selectNode); + const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer); + const focusNodeId = useDashboardStore((s) => s.focusNodeId); + const setFocusNode = useDashboardStore((s) => s.setFocusNode); + + const overviewGraph = useOverviewGraph(); + const detailGraph = useLayerDetailGraph(); + const flowGraph = useFlowViewGraph(); + + const { nodes: initialNodes, edges: initialEdges } = + viewMode === "flow" + ? flowGraph + : navigationLevel === "overview" + ? overviewGraph + : detailGraph; const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -284,28 +573,51 @@ export default function GraphView() { setEdges(initialEdges); }, [initialEdges, setEdges]); + // Fit view on level/layer/view-mode transitions + useEffect(() => { + const timer = setTimeout(() => { + fitView({ duration: 400, padding: 0.2 }); + }, 50); + return () => clearTimeout(timer); + }, [navigationLevel, activeLayerId, viewMode, fitView]); + // Zoom-to-node when navigateToNode is called const zoomToNodeId = useDashboardStore((s) => s.zoomToNodeId); useEffect(() => { if (zoomToNodeId) { - // Small delay to let React Flow update node positions first const timer = setTimeout(() => { - fitView({ nodes: [{ id: zoomToNodeId }], duration: 400, padding: 0.5 }); + fitView({ + nodes: [{ id: zoomToNodeId }], + duration: 400, + padding: 0.5, + }); useDashboardStore.setState({ zoomToNodeId: null }); - }, 50); + }, 100); return () => clearTimeout(timer); } }, [zoomToNodeId, fitView]); const onNodeClick = useCallback( (_: React.MouseEvent, node: { id: string }) => { - // Ignore clicks on group nodes - const isGroupNode = graph?.layers?.some((l) => l.id === node.id); - if (isGroupNode) return; - selectNode(node.id); - openCodeViewer(node.id); + // In flow view, all clicks are selections (no drill-in) + // Ignore clicks on lane group nodes + if (viewMode === "flow") { + if (node.id.startsWith("lane:")) return; + selectNode(node.id); + return; + } + if (navigationLevel === "overview") { + // At overview, clicking a layer cluster drills in + drillIntoLayer(node.id); + } else if (node.id.startsWith("portal:")) { + // Portal nodes navigate to that layer + const targetLayerId = node.id.replace("portal:", ""); + drillIntoLayer(targetLayerId); + } else { + selectNode(node.id); + } }, - [selectNode, openCodeViewer, graph], + [viewMode, navigationLevel, drillIntoLayer, selectNode], ); const onPaneClick = useCallback(() => { @@ -322,8 +634,9 @@ export default function GraphView() { return (
- {focusNodeId && ( -
+ + {focusNodeId && navigationLevel === "layer-detail" && ( +
+ + {navigationLevel === "overview" + ? `${layers.length} layers` + : activeLayer?.name ?? "Layer"} + - {showLayers && hasLayers && ( -
- {layers.map((layer, i) => { - const color = getLayerColor(i); - return ( -
- - - {layer.name} - - ({layer.nodeIds.length}) - +
+ {layers.map((layer, i) => { + const color = getLayerColor(i); + const isActive = navigationLevel === "layer-detail" && layer.id === activeLayerId; + return ( +
+ + + {layer.name} + + ({layer.nodeIds.length}) -
- ); - })} -
- )} +
+
+ ); + })} +
); } diff --git a/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx b/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx index 9500cfc..2fbd521 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx @@ -15,17 +15,72 @@ const complexityBadgeColors: Record = { complex: "text-[#c97070] border border-[#c97070]/30 bg-[#c97070]/10", }; +/** + * Human-readable directional labels for edge types. + * Returns different text depending on whether the selected node is + * the source or target of the edge. + */ +function getDirectionalLabel(edgeType: string, isSource: boolean): string { + switch (edgeType) { + case "imports": + return isSource ? "imports" : "imported by"; + case "exports": + return isSource ? "exports to" : "exported by"; + case "contains": + return isSource ? "contains" : "contained in"; + case "inherits": + return isSource ? "inherits from" : "inherited by"; + case "implements": + return isSource ? "implements" : "implemented by"; + case "calls": + return isSource ? "calls" : "called by"; + case "subscribes": + return isSource ? "subscribes to" : "subscribed by"; + case "publishes": + return isSource ? "publishes to" : "consumed by"; + case "middleware": + return isSource ? "middleware for" : "uses middleware"; + case "reads_from": + return isSource ? "reads from" : "read by"; + case "writes_to": + return isSource ? "writes to" : "written by"; + case "transforms": + return isSource ? "transforms" : "transformed by"; + case "validates": + return isSource ? "validates" : "validated by"; + case "depends_on": + return isSource ? "depends on" : "depended on by"; + case "tested_by": + return isSource ? "tested by" : "tests"; + case "configures": + return isSource ? "configures" : "configured by"; + case "related": + return "related to"; + case "similar_to": + return "similar to"; + default: + return isSource ? edgeType : `${edgeType} (reverse)`; + } +} + export default function NodeInfo() { const graph = useDashboardStore((s) => s.graph); const selectedNodeId = useDashboardStore((s) => s.selectedNodeId); + const nodeHistory = useDashboardStore((s) => s.nodeHistory); + const goBackNode = useDashboardStore((s) => s.goBackNode); const [languageExpanded, setLanguageExpanded] = useState(true); const navigateToNode = useDashboardStore((s) => s.navigateToNode); - const openCodeViewer = useDashboardStore((s) => s.openCodeViewer); const setFocusNode = useDashboardStore((s) => s.setFocusNode); const focusNodeId = useDashboardStore((s) => s.focusNodeId); const node = graph?.nodes.find((n) => n.id === selectedNodeId) ?? null; + // Resolve history node names for the breadcrumb trail + const historyNodes = nodeHistory.map((id) => { + const n = graph?.nodes.find((gn) => gn.id === id); + return { id, name: n?.name ?? id }; + }); + if (!node) { return (
@@ -34,16 +89,82 @@ export default function NodeInfo() { ); } - const connections = (graph?.edges ?? []).filter( + const allEdges = graph?.edges ?? []; + const connections = allEdges.filter( (e) => e.source === node.id || e.target === node.id, ); + // Separate child nodes (contained IN this file) from other connections + const childEdges = connections.filter( + (e) => e.type === "contains" && e.source === node.id, + ); + const otherConnections = connections.filter( + (e) => !(e.type === "contains" && e.source === node.id), + ); + + // Resolve child nodes + const childNodes = childEdges + .map((e) => graph?.nodes.find((n) => n.id === e.target)) + .filter(Boolean); + const typeBadge = typeBadgeColors[node.type] ?? typeBadgeColors.file; const complexityBadge = complexityBadgeColors[node.complexity] ?? complexityBadgeColors.simple; return (
+ {/* Navigation history trail */} + {historyNodes.length > 0 && ( +
+ + + {historyNodes.slice(-3).map((h, i, arr) => ( + + + {i < arr.length - 1 && ( + + )} + + ))} + + + {node.name} + +
+ )} +
)} - {connections.length > 0 && ( + {/* Child classes/functions within this file */} + {childNodes.length > 0 && ( +
+

+ Defined in this file ({childNodes.length}) +

+
+ {childNodes.map((child) => { + if (!child) return null; + const childTypeBadge = typeBadgeColors[child.type] ?? typeBadgeColors.file; + const childComplexity = complexityBadgeColors[child.complexity] ?? complexityBadgeColors.simple; + return ( +
navigateToNode(child.id)} + > +
+ + {child.type} + + {child.name} + + {child.complexity} + +
+ {child.summary && ( +

+ {child.summary} +

+ )} +
+ ); + })} +
+
+ )} + + {/* Other connections (excluding "contains" children) */} + {otherConnections.length > 0 && (

- Connections ({connections.length}) + Connections ({otherConnections.length})

- {connections.map((edge, i) => { + {otherConnections.map((edge, i) => { const isSource = edge.source === node.id; const otherId = isSource ? edge.target : edge.source; const otherNode = graph?.nodes.find((n) => n.id === otherId); + const dirLabel = getDirectionalLabel(edge.type, isSource); const arrow = isSource ? "\u2192" : "\u2190"; return ( @@ -149,11 +310,10 @@ export default function NodeInfo() { className="text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle flex items-center gap-2 cursor-pointer hover:border-gold/40 hover:bg-gold/5 transition-colors" onClick={() => { navigateToNode(otherId); - openCodeViewer(otherId); }} > {arrow} - {edge.type} + {dirLabel} {otherNode?.name ?? otherId} diff --git a/understand-anything-plugin/packages/dashboard/src/components/PortalNode.tsx b/understand-anything-plugin/packages/dashboard/src/components/PortalNode.tsx new file mode 100644 index 0000000..3a9a388 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/PortalNode.tsx @@ -0,0 +1,61 @@ +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps, Node } from "@xyflow/react"; +import { getLayerColor } from "./LayerLegend"; + +export interface PortalNodeData extends Record { + targetLayerId: string; + targetLayerName: string; + connectionCount: number; + layerColorIndex: number; + onNavigate: (layerId: string) => void; +} + +export type PortalFlowNode = Node; + +export default function PortalNode({ + data, +}: NodeProps) { + const color = getLayerColor(data.layerColorIndex); + + return ( +
data.onNavigate(data.targetLayerId)} + > + + +
+
+
+ + + {data.targetLayerName} + +
+ +
+
+ {data.connectionCount} connection{data.connectionCount !== 1 ? "s" : ""} +
+
+ + +
+ ); +} diff --git a/understand-anything-plugin/packages/dashboard/src/components/SearchBar.tsx b/understand-anything-plugin/packages/dashboard/src/components/SearchBar.tsx index 8a8f245..b932464 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/SearchBar.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/SearchBar.tsx @@ -14,7 +14,7 @@ export default function SearchBar() { const searchResults = useDashboardStore((s) => s.searchResults); const graph = useDashboardStore((s) => s.graph); const setSearchQuery = useDashboardStore((s) => s.setSearchQuery); - const selectNode = useDashboardStore((s) => s.selectNode); + const navigateToNodeInLayer = useDashboardStore((s) => s.navigateToNodeInLayer); const searchMode = useDashboardStore((s) => s.searchMode); const setSearchMode = useDashboardStore((s) => s.setSearchMode); @@ -40,10 +40,10 @@ export default function SearchBar() { const handleResultClick = useCallback( (nodeId: string) => { - selectNode(nodeId); + navigateToNodeInLayer(nodeId); setDropdownOpen(false); }, - [selectNode], + [navigateToNodeInLayer], ); // Close dropdown on Escape @@ -72,7 +72,7 @@ export default function SearchBar() { const showDropdown = dropdownOpen && searchQuery.trim() && topResults.length > 0; return ( -
+
void; selectNode: (nodeId: string | null) => void; navigateToNode: (nodeId: string) => void; + navigateToNodeInLayer: (nodeId: string) => void; + goBackNode: () => void; + drillIntoLayer: (layerId: string) => void; + navigateToOverview: () => void; + setViewMode: (mode: ViewMode) => void; setFocusNode: (nodeId: string | null) => void; setSearchQuery: (query: string) => void; toggleLayers: () => void; @@ -64,6 +89,22 @@ function getSortedTour(graph: KnowledgeGraph): TourStep[] { return [...tour].sort((a, b) => a.order - b.order); } +/** Navigate tour step to the correct layer for the first highlighted node. */ +function navigateTourToLayer( + graph: KnowledgeGraph, + nodeIds: string[], +): Partial { + if (nodeIds.length === 0) return {}; + const layerId = findNodeLayer(graph, nodeIds[0]); + if (layerId) { + return { + navigationLevel: "layer-detail" as const, + activeLayerId: layerId, + }; + } + return {}; +} + export const useDashboardStore = create()((set, get) => ({ graph: null, selectedNodeId: null, @@ -74,6 +115,10 @@ export const useDashboardStore = create()((set, get) => ({ showLayers: false, + navigationLevel: "overview", + activeLayerId: null, + viewMode: "graph", + codeViewerOpen: false, codeViewerNodeId: null, @@ -89,15 +134,125 @@ export const useDashboardStore = create()((set, get) => ({ zoomToNodeId: null, focusNodeId: null, + nodeHistory: [], setGraph: (graph) => { const searchEngine = new SearchEngine(graph.nodes); const query = get().searchQuery; const searchResults = query.trim() ? searchEngine.search(query) : []; - set({ graph, searchEngine, searchResults }); + set({ + graph, + searchEngine, + searchResults, + navigationLevel: "overview", + activeLayerId: null, + selectedNodeId: null, + focusNodeId: null, + nodeHistory: [], + }); }, - selectNode: (nodeId) => set({ selectedNodeId: nodeId }), - navigateToNode: (nodeId) => set({ selectedNodeId: nodeId, zoomToNodeId: nodeId }), + + selectNode: (nodeId) => { + const { selectedNodeId, nodeHistory } = get(); + if (nodeId && selectedNodeId && nodeId !== selectedNodeId) { + // Push current node to history before navigating away + set({ + selectedNodeId: nodeId, + nodeHistory: [...nodeHistory, selectedNodeId], + }); + } else { + set({ selectedNodeId: nodeId }); + } + }, + + navigateToNode: (nodeId) => { + get().navigateToNodeInLayer(nodeId); + }, + + navigateToNodeInLayer: (nodeId) => { + const { graph, selectedNodeId, nodeHistory } = get(); + if (!graph) return; + const layerId = findNodeLayer(graph, nodeId); + const newHistory = + selectedNodeId && nodeId !== selectedNodeId + ? [...nodeHistory, selectedNodeId] + : nodeHistory; + if (layerId) { + set({ + navigationLevel: "layer-detail", + activeLayerId: layerId, + selectedNodeId: nodeId, + zoomToNodeId: nodeId, + focusNodeId: null, + codeViewerOpen: false, + codeViewerNodeId: null, + nodeHistory: newHistory, + }); + } else { + set({ + selectedNodeId: nodeId, + zoomToNodeId: nodeId, + nodeHistory: newHistory, + }); + } + }, + + goBackNode: () => { + const { nodeHistory } = get(); + if (nodeHistory.length === 0) return; + const prevNodeId = nodeHistory[nodeHistory.length - 1]; + const newHistory = nodeHistory.slice(0, -1); + // Navigate to previous node WITHOUT pushing to history + const { graph } = get(); + if (!graph) return; + const layerId = findNodeLayer(graph, prevNodeId); + if (layerId) { + set({ + navigationLevel: "layer-detail", + activeLayerId: layerId, + selectedNodeId: prevNodeId, + zoomToNodeId: prevNodeId, + nodeHistory: newHistory, + }); + } else { + set({ + selectedNodeId: prevNodeId, + zoomToNodeId: prevNodeId, + nodeHistory: newHistory, + }); + } + }, + + drillIntoLayer: (layerId) => + set({ + navigationLevel: "layer-detail", + activeLayerId: layerId, + selectedNodeId: null, + focusNodeId: null, + codeViewerOpen: false, + codeViewerNodeId: null, + }), + + navigateToOverview: () => + set({ + navigationLevel: "overview", + activeLayerId: null, + selectedNodeId: null, + focusNodeId: null, + codeViewerOpen: false, + codeViewerNodeId: null, + }), + + setViewMode: (mode) => + set({ + viewMode: mode, + // When switching to flow view, go to overview level (flow shows all layers) + navigationLevel: mode === "flow" ? "overview" : get().navigationLevel, + activeLayerId: mode === "flow" ? null : get().activeLayerId, + selectedNodeId: null, + focusNodeId: null, + }), + setFocusNode: (nodeId) => set({ focusNodeId: nodeId, selectedNodeId: nodeId }), setSearchMode: (mode) => set({ searchMode: mode }), setSearchQuery: (query) => { @@ -141,11 +296,13 @@ export const useDashboardStore = create()((set, get) => ({ const { graph } = get(); if (!graph || !graph.tour || graph.tour.length === 0) return; const sorted = getSortedTour(graph); + const layerNav = navigateTourToLayer(graph, sorted[0].nodeIds); set({ tourActive: true, currentTourStep: 0, tourHighlightedNodeIds: sorted[0].nodeIds, selectedNodeId: null, + ...layerNav, }); }, @@ -161,9 +318,11 @@ export const useDashboardStore = create()((set, get) => ({ if (!graph || !graph.tour || graph.tour.length === 0) return; const sorted = getSortedTour(graph); if (step < 0 || step >= sorted.length) return; + const layerNav = navigateTourToLayer(graph, sorted[step].nodeIds); set({ currentTourStep: step, tourHighlightedNodeIds: sorted[step].nodeIds, + ...layerNav, }); }, @@ -173,9 +332,11 @@ export const useDashboardStore = create()((set, get) => ({ const sorted = getSortedTour(graph); if (currentTourStep < sorted.length - 1) { const next = currentTourStep + 1; + const layerNav = navigateTourToLayer(graph, sorted[next].nodeIds); set({ currentTourStep: next, tourHighlightedNodeIds: sorted[next].nodeIds, + ...layerNav, }); } }, @@ -186,9 +347,11 @@ export const useDashboardStore = create()((set, get) => ({ if (currentTourStep > 0) { const sorted = getSortedTour(graph); const prev = currentTourStep - 1; + const layerNav = navigateTourToLayer(graph, sorted[prev].nodeIds); set({ currentTourStep: prev, tourHighlightedNodeIds: sorted[prev].nodeIds, + ...layerNav, }); } }, diff --git a/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts b/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts new file mode 100644 index 0000000..40312ff --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts @@ -0,0 +1,133 @@ +import type { KnowledgeGraph } from "@understand-anything/core/types"; + +export interface LayerEdgeAggregation { + sourceLayerId: string; + targetLayerId: string; + count: number; + edgeTypes: string[]; +} + +export interface PortalInfo { + layerId: string; + layerName: string; + connectionCount: number; +} + +/** + * Aggregate edges between layers. Counts how many graph edges cross + * from one layer to another. Only considers edges where both endpoints + * are assigned to a layer. + */ +export function aggregateLayerEdges( + graph: KnowledgeGraph, +): LayerEdgeAggregation[] { + const nodeToLayer = new Map(); + for (const layer of graph.layers) { + for (const nodeId of layer.nodeIds) { + nodeToLayer.set(nodeId, layer.id); + } + } + + // Key: "layerA|layerB" (sorted) → aggregation + const pairMap = new Map< + string, + { sourceLayerId: string; targetLayerId: string; count: number; edgeTypes: Set } + >(); + + for (const edge of graph.edges) { + const sourceLayer = nodeToLayer.get(edge.source); + const targetLayer = nodeToLayer.get(edge.target); + if (!sourceLayer || !targetLayer) continue; + if (sourceLayer === targetLayer) continue; + + // Canonical key so A→B and B→A merge + const [a, b] = + sourceLayer < targetLayer + ? [sourceLayer, targetLayer] + : [targetLayer, sourceLayer]; + const key = `${a}|${b}`; + + const existing = pairMap.get(key); + if (existing) { + existing.count++; + existing.edgeTypes.add(edge.type); + } else { + pairMap.set(key, { + sourceLayerId: a, + targetLayerId: b, + count: 1, + edgeTypes: new Set([edge.type]), + }); + } + } + + return Array.from(pairMap.values()).map((p) => ({ + sourceLayerId: p.sourceLayerId, + targetLayerId: p.targetLayerId, + count: p.count, + edgeTypes: Array.from(p.edgeTypes), + })); +} + +/** + * Compute portal info for a given layer: which other layers are connected + * and how many edges cross the boundary. + */ +export function computePortals( + graph: KnowledgeGraph, + activeLayerId: string, +): PortalInfo[] { + const aggregated = aggregateLayerEdges(graph); + const layerNameMap = new Map(graph.layers.map((l) => [l.id, l.name])); + + const portalMap = new Map(); + + for (const agg of aggregated) { + if (agg.sourceLayerId === activeLayerId) { + portalMap.set( + agg.targetLayerId, + (portalMap.get(agg.targetLayerId) ?? 0) + agg.count, + ); + } else if (agg.targetLayerId === activeLayerId) { + portalMap.set( + agg.sourceLayerId, + (portalMap.get(agg.sourceLayerId) ?? 0) + agg.count, + ); + } + } + + return Array.from(portalMap.entries()).map(([layerId, count]) => ({ + layerId, + layerName: layerNameMap.get(layerId) ?? layerId, + connectionCount: count, + })); +} + +/** + * For a given layer, find which file nodes in that layer connect to a + * specific external layer. Returns the set of node IDs in activeLayer + * that have edges crossing to targetLayerId. + */ +export function findCrossLayerFileNodes( + graph: KnowledgeGraph, + activeLayerId: string, + targetLayerId: string, +): Set { + const activeNodeIds = new Set( + graph.layers.find((l) => l.id === activeLayerId)?.nodeIds ?? [], + ); + const targetNodeIds = new Set( + graph.layers.find((l) => l.id === targetLayerId)?.nodeIds ?? [], + ); + + const result = new Set(); + for (const edge of graph.edges) { + if (activeNodeIds.has(edge.source) && targetNodeIds.has(edge.target)) { + result.add(edge.source); + } + if (activeNodeIds.has(edge.target) && targetNodeIds.has(edge.source)) { + result.add(edge.target); + } + } + return result; +} diff --git a/understand-anything-plugin/packages/dashboard/src/utils/layout.ts b/understand-anything-plugin/packages/dashboard/src/utils/layout.ts index 5910f40..e7ff519 100644 --- a/understand-anything-plugin/packages/dashboard/src/utils/layout.ts +++ b/understand-anything-plugin/packages/dashboard/src/utils/layout.ts @@ -1,13 +1,25 @@ import dagre from "@dagrejs/dagre"; import type { Node, Edge } from "@xyflow/react"; +import type { KnowledgeGraph } from "@understand-anything/core/types"; export const NODE_WIDTH = 280; export const NODE_HEIGHT = 120; +export const LAYER_CLUSTER_WIDTH = 320; +export const LAYER_CLUSTER_HEIGHT = 180; +export const PORTAL_NODE_WIDTH = 240; +export const PORTAL_NODE_HEIGHT = 80; + +// Swim-lane constants +export const LANE_WIDTH = 320; +export const LANE_GAP = 40; +export const LANE_PADDING = 30; +export const LANE_HEADER_HEIGHT = 40; export function applyDagreLayout( nodes: Node[], edges: Edge[], direction: "TB" | "LR" = "TB", + nodeDimensions?: Map, ): { nodes: Node[]; edges: Edge[] } { const g = new dagre.graphlib.Graph(); g.setDefaultEdgeLabel(() => ({})); @@ -23,7 +35,10 @@ export function applyDagreLayout( }); nodes.forEach((node) => { - g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + const dims = nodeDimensions?.get(node.id); + const w = dims?.width ?? NODE_WIDTH; + const h = dims?.height ?? NODE_HEIGHT; + g.setNode(node.id, { width: w, height: h }); }); edges.forEach((edge) => { @@ -35,14 +50,189 @@ export function applyDagreLayout( const layoutedNodes = nodes.map((node) => { const pos = g.node(node.id); if (!pos) return { ...node, position: { x: 0, y: 0 } }; + const dims = nodeDimensions?.get(node.id); + const w = dims?.width ?? NODE_WIDTH; + const h = dims?.height ?? NODE_HEIGHT; return { ...node, position: { - x: pos.x - NODE_WIDTH / 2, - y: pos.y - NODE_HEIGHT / 2, + x: pos.x - w / 2, + y: pos.y - h / 2, }, }; }); return { nodes: layoutedNodes, edges }; } + +/** + * Preferred order of layers for the swim-lane flow view. + * Reflects a typical request lifecycle: entry → middleware → logic → data → external. + * Layers not in this list are appended at the end. + */ +const LAYER_FLOW_ORDER = [ + "API Layer", + "UI Layer", + "Middleware Layer", + "Service Layer", + "External Services", + "Data Layer", + "Background Tasks", + "Utility Layer", + "Configuration Layer", + "Test Layer", +]; + +function getLayerSortIndex(layerName: string): number { + const idx = LAYER_FLOW_ORDER.indexOf(layerName); + return idx >= 0 ? idx : LAYER_FLOW_ORDER.length; +} + +export interface SwimLaneResult { + /** All nodes: lane background groups + file nodes positioned inside them */ + nodes: Node[]; + /** Cross-lane and intra-lane edges */ + edges: Edge[]; + /** Ordered layer info for reference */ + lanes: Array<{ layerId: string; layerName: string; columnIndex: number }>; +} + +/** + * Swim-lane layout: each layer becomes a vertical column (lane), ordered + * by the request lifecycle. Within each lane, dagre handles vertical + * positioning. File nodes are children of their lane group node. + */ +export function applySwimLaneLayout( + graph: KnowledgeGraph, + fileNodes: Node[], + allEdges: Edge[], +): SwimLaneResult { + // Sort layers by flow order + const sortedLayers = [...graph.layers].sort( + (a, b) => getLayerSortIndex(a.name) - getLayerSortIndex(b.name), + ); + + // Build nodeId → layer mapping + const nodeToLayerId = new Map(); + for (const layer of sortedLayers) { + for (const nid of layer.nodeIds) { + nodeToLayerId.set(nid, layer.id); + } + } + + // Group file nodes by layer + const nodesByLayer = new Map(); + for (const layer of sortedLayers) { + nodesByLayer.set(layer.id, []); + } + for (const node of fileNodes) { + const lid = nodeToLayerId.get(node.id); + if (lid && nodesByLayer.has(lid)) { + nodesByLayer.get(lid)!.push(node); + } + } + + // For each lane, use dagre to compute y-positions of its nodes + // (treating the lane as a vertical sub-graph) + const laneHeights = new Map(); + + for (const [layerId, nodes] of nodesByLayer) { + if (nodes.length === 0) { + laneHeights.set(layerId, LANE_HEADER_HEIGHT + LANE_PADDING * 2); + continue; + } + + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + g.setGraph({ + rankdir: "TB", + nodesep: 20, + ranksep: 40, + marginx: 0, + marginy: 0, + }); + + const laneNodeIds = new Set(nodes.map((n) => n.id)); + for (const node of nodes) { + g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + } + + // Only include edges within this lane for vertical ordering + for (const edge of allEdges) { + if (laneNodeIds.has(edge.source) && laneNodeIds.has(edge.target)) { + g.setEdge(edge.source, edge.target); + } + } + + dagre.layout(g); + + // Read dagre's y-positions and assign relative positions within the lane + let maxY = 0; + for (const node of nodes) { + const pos = g.node(node.id); + if (pos) { + // Store the y position in the node's data for later use + (node as Node & { _laneY: number })._laneY = pos.y - NODE_HEIGHT / 2; + maxY = Math.max(maxY, pos.y + NODE_HEIGHT / 2); + } + } + + laneHeights.set(layerId, LANE_HEADER_HEIGHT + maxY + LANE_PADDING * 2); + } + + // Find the tallest lane so all lanes are the same height + const maxLaneHeight = Math.max( + 200, + ...Array.from(laneHeights.values()), + ); + + // Build lane group nodes and position file nodes as children + const resultNodes: Node[] = []; + const lanes: SwimLaneResult["lanes"] = []; + + sortedLayers.forEach((layer, colIdx) => { + const laneX = colIdx * (LANE_WIDTH + LANE_GAP); + const laneId = `lane:${layer.id}`; + + lanes.push({ + layerId: layer.id, + layerName: layer.name, + columnIndex: colIdx, + }); + + // Lane background group node + resultNodes.push({ + id: laneId, + type: "group", + position: { x: laneX, y: 0 }, + data: { label: layer.name }, + style: { + width: LANE_WIDTH, + height: maxLaneHeight, + backgroundColor: "rgba(212,165,116,0.03)", + borderRadius: 12, + border: "1px solid rgba(212,165,116,0.1)", + padding: 0, + }, + }); + + // Position file nodes within this lane + const nodes = nodesByLayer.get(layer.id) ?? []; + for (const node of nodes) { + const laneY = (node as Node & { _laneY?: number })._laneY ?? 0; + resultNodes.push({ + ...node, + parentId: laneId, + extent: "parent" as const, + position: { + x: (LANE_WIDTH - NODE_WIDTH) / 2, + y: LANE_HEADER_HEIGHT + LANE_PADDING + laneY, + }, + }); + // Clean up temp property + delete (node as Node & { _laneY?: number })._laneY; + } + }); + + return { nodes: resultNodes, edges: allEdges, lanes }; +}