feat: lens-based graph navigation with flow view and sidebar history

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.
This commit is contained in:
Sreeram
2026-03-24 21:19:08 +05:30
Unverified
parent 8b7bda30fa
commit 564f7e87c0
11 changed files with 1438 additions and 204 deletions
@@ -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",
@@ -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 (
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
{/* View mode toggle */}
<div className="flex items-center rounded-full bg-elevated border border-border-subtle shadow-lg overflow-hidden">
<button
onClick={() => handleViewToggle("graph")}
className={`px-3 py-2 text-[10px] font-semibold uppercase tracking-wider transition-colors ${
viewMode === "graph"
? "bg-gold/20 text-gold"
: "text-text-muted hover:text-text-secondary"
}`}
>
Graph
</button>
<div className="w-px h-4 bg-border-subtle" />
<button
onClick={() => handleViewToggle("flow")}
className={`px-3 py-2 text-[10px] font-semibold uppercase tracking-wider transition-colors ${
viewMode === "flow"
? "bg-gold/20 text-gold"
: "text-text-muted hover:text-text-secondary"
}`}
>
Flow
</button>
</div>
{/* Navigation breadcrumb (only in graph mode) */}
{viewMode === "graph" && navigationLevel === "overview" && (
<div className="px-4 py-2 rounded-full bg-elevated border border-border-subtle text-xs font-semibold tracking-wider uppercase text-text-secondary shadow-lg">
Project Overview
</div>
)}
{viewMode === "graph" && navigationLevel === "layer-detail" && (
<div className="flex items-center gap-1.5 px-4 py-2 rounded-full bg-elevated border border-gold/30 text-xs font-semibold tracking-wider uppercase shadow-lg">
<button
onClick={navigateToOverview}
className="text-gold hover:text-gold-bright transition-colors"
>
Project
</button>
<span className="text-text-muted"></span>
<span className="text-text-primary">
{activeLayer?.name ?? "Layer"}
</span>
<span className="text-text-muted ml-1 text-[10px] normal-case tracking-normal">
(Esc to go back)
</span>
</div>
)}
{viewMode === "flow" && (
<div className="px-4 py-2 rounded-full bg-elevated border border-border-subtle text-xs font-semibold tracking-wider uppercase text-text-secondary shadow-lg">
Request Flow
</div>
)}
</div>
);
}
@@ -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<string, number>();
if (searchResults.length > 0) {
const nodeToLayer = new Map<string, string>();
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<string>();
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<string, { width: number; height: number }>();
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<string>([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<string>();
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<string>();
// Build diff-aware edges
const diffNodeIds = diffMode
? new Set([...changedNodeIds, ...affectedNodeIds])
: new Set<string>();
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<string, string>();
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<string, { width: number; height: number }>();
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<string>();
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<string>();
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 (
<div className="h-full w-full relative">
{focusNodeId && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10">
<Breadcrumb />
{focusNodeId && navigationLevel === "layer-detail" && (
<div className="absolute top-14 left-1/2 -translate-x-1/2 z-10">
<button
onClick={() => setFocusNode(null)}
className="px-4 py-2 rounded-full bg-elevated border border-gold/30 text-gold text-xs font-semibold tracking-wider uppercase hover:bg-gold/10 transition-colors flex items-center gap-2 shadow-lg"
@@ -348,7 +661,12 @@ export default function GraphView() {
panOnScroll
colorMode="dark"
>
<Background variant={BackgroundVariant.Dots} color="rgba(212,165,116,0.15)" gap={20} size={1} />
<Background
variant={BackgroundVariant.Dots}
color="rgba(212,165,116,0.15)"
gap={20}
size={1}
/>
<Controls />
<MiniMap
nodeColor="#1a1a1a"
@@ -0,0 +1,101 @@
import { Handle, Position } from "@xyflow/react";
import type { NodeProps, Node } from "@xyflow/react";
import { getLayerColor } from "./LayerLegend";
const complexityColors: Record<string, string> = {
simple: "text-node-function",
moderate: "text-gold-dim",
complex: "text-[#c97070]",
};
export interface LayerClusterData extends Record<string, unknown> {
layerId: string;
layerName: string;
layerDescription: string;
fileCount: number;
aggregateComplexity: string;
layerColorIndex: number;
searchMatchCount?: number;
onDrillIn: (layerId: string) => void;
}
export type LayerClusterFlowNode = Node<LayerClusterData, "layer-cluster">;
export default function LayerClusterNode({
data,
}: NodeProps<LayerClusterFlowNode>) {
const color = getLayerColor(data.layerColorIndex);
const complexityColor =
complexityColors[data.aggregateComplexity] ?? complexityColors.simple;
return (
<div
className="relative rounded-xl bg-elevated border border-border-subtle overflow-hidden cursor-pointer transition-all duration-200 hover:border-gold/40 hover:shadow-lg group"
style={{
width: 300,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
}}
onClick={() => data.onDrillIn(data.layerId)}
>
{/* Left color bar */}
<div
className="absolute left-0 top-0 bottom-0 w-1.5 rounded-l-xl"
style={{ backgroundColor: color.label }}
/>
<Handle
type="target"
position={Position.Top}
className="!bg-text-muted !w-2 !h-2"
/>
<div className="pl-5 pr-4 py-4">
{/* Header row */}
<div className="flex items-center justify-between mb-2">
<span
className="text-[10px] font-semibold uppercase tracking-wider"
style={{ color: color.label }}
>
Layer
</span>
<div className="flex items-center gap-2">
{data.searchMatchCount != null && data.searchMatchCount > 0 && (
<span className="text-[10px] font-mono bg-gold/20 text-gold px-1.5 py-0.5 rounded">
{data.searchMatchCount} match{data.searchMatchCount !== 1 ? "es" : ""}
</span>
)}
<span className={`text-[10px] font-mono ${complexityColor}`}>
{data.aggregateComplexity}
</span>
</div>
</div>
{/* Layer name */}
<div className="text-lg font-serif text-text-primary mb-1">
{data.layerName}
</div>
{/* Description */}
<div className="text-[11px] text-text-secondary line-clamp-2 leading-tight mb-3">
{data.layerDescription}
</div>
{/* Footer */}
<div className="flex items-center justify-between">
<span className="text-[11px] text-text-muted">
{data.fileCount} file{data.fileCount !== 1 ? "s" : ""}
</span>
<span className="text-[10px] text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
Click to explore
</span>
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="!bg-text-muted !w-2 !h-2"
/>
</div>
);
}
@@ -1,6 +1,6 @@
import { useDashboardStore } from "../store";
// Shared layer color palette — used by both LayerLegend dots and GraphView group nodes
// Shared layer color palette — used by LayerLegend, LayerClusterNode, PortalNode, and GraphView
export const LAYER_PALETTE = [
{ bg: "rgba(74, 124, 155, 0.12)", border: "rgba(74, 124, 155, 0.4)", label: "#4a7c9b" }, // blue (API)
{ bg: "rgba(90, 158, 111, 0.12)", border: "rgba(90, 158, 111, 0.4)", label: "#5a9e6f" }, // green (Data)
@@ -17,56 +17,54 @@ export function getLayerColor(index: number) {
export default function LayerLegend() {
const graph = useDashboardStore((s) => s.graph);
const showLayers = useDashboardStore((s) => s.showLayers);
const toggleLayers = useDashboardStore((s) => s.toggleLayers);
const navigationLevel = useDashboardStore((s) => s.navigationLevel);
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
const layers = graph?.layers ?? [];
const hasLayers = layers.length > 0;
if (!hasLayers) return null;
const activeLayer = layers.find((l) => l.id === activeLayerId);
return (
<div className="flex items-center gap-2">
<button
onClick={toggleLayers}
disabled={!hasLayers}
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors ${
showLayers && hasLayers
? "bg-gold/20 text-gold"
: hasLayers
? "bg-elevated text-text-secondary hover:bg-surface"
: "bg-elevated text-text-muted cursor-not-allowed"
}`}
title={
hasLayers
? showLayers
? "Hide layer grouping"
: "Show layer grouping"
: "No layers in graph"
}
>
Layers {showLayers && hasLayers ? "ON" : "OFF"}
</button>
<span className="text-[11px] font-medium text-text-secondary">
{navigationLevel === "overview"
? `${layers.length} layers`
: activeLayer?.name ?? "Layer"}
</span>
{showLayers && hasLayers && (
<div className="flex items-center gap-3">
{layers.map((layer, i) => {
const color = getLayerColor(i);
return (
<div key={layer.id} className="flex items-center gap-1">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: color.label }}
/>
<span className="text-text-secondary text-[11px]">
{layer.name}
<span className="text-text-muted ml-0.5">
({layer.nodeIds.length})
</span>
<div className="flex items-center gap-3">
{layers.map((layer, i) => {
const color = getLayerColor(i);
const isActive = navigationLevel === "layer-detail" && layer.id === activeLayerId;
return (
<div key={layer.id} className="flex items-center gap-1">
<span
className="inline-block w-2 h-2 rounded-full"
style={{
backgroundColor: color.label,
opacity: navigationLevel === "layer-detail" && !isActive ? 0.3 : 1,
}}
/>
<span
className={`text-[11px] ${
isActive ? "text-text-primary font-medium" : "text-text-secondary"
}`}
style={{
opacity: navigationLevel === "layer-detail" && !isActive ? 0.4 : 1,
}}
>
{layer.name}
<span className="text-text-muted ml-0.5">
({layer.nodeIds.length})
</span>
</div>
);
})}
</div>
)}
</span>
</div>
);
})}
</div>
</div>
);
}
@@ -15,17 +15,72 @@ const complexityBadgeColors: Record<string, string> = {
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 (
<div className="h-full w-full flex items-center justify-center bg-surface">
@@ -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 (
<div className="h-full w-full overflow-auto p-5 animate-fade-slide-in">
{/* Navigation history trail */}
{historyNodes.length > 0 && (
<div className="mb-3 flex items-center gap-1 flex-wrap">
<button
onClick={goBackNode}
className="text-[10px] font-semibold text-gold hover:text-gold-bright transition-colors flex items-center gap-1"
>
<span></span>
<span>Back</span>
</button>
<span className="text-text-muted text-[10px]"></span>
{historyNodes.slice(-3).map((h, i, arr) => (
<span key={`${h.id}-${i}`} className="flex items-center gap-1">
<button
onClick={() => {
// Navigate back to this point in history
const fullIdx = historyNodes.length - arr.length + i;
// Pop history back to this point and navigate
const targetId = historyNodes[fullIdx].id;
// Use navigateToNode which will push current to history,
// but we want to rewind. Use goBackNode repeatedly would be clunky,
// so we directly set state.
const newHistory = nodeHistory.slice(0, fullIdx);
const layerId = graph
? graph.layers.find((l) => l.nodeIds.includes(targetId))?.id
: null;
useDashboardStore.setState({
selectedNodeId: targetId,
zoomToNodeId: targetId,
nodeHistory: newHistory,
...(layerId
? { navigationLevel: "layer-detail" as const, activeLayerId: layerId }
: {}),
});
}}
className="text-[10px] text-text-muted hover:text-gold transition-colors truncate max-w-[80px]"
title={h.name}
>
{h.name}
</button>
{i < arr.length - 1 && (
<span className="text-text-muted text-[10px]"></span>
)}
</span>
))}
<span className="text-text-muted text-[10px]"></span>
<span className="text-[10px] text-text-primary font-medium truncate max-w-[80px]">
{node.name}
</span>
</div>
)}
<div className="flex items-center gap-2 mb-3">
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded ${typeBadge}`}
@@ -131,16 +252,56 @@ export default function NodeInfo() {
</div>
)}
{connections.length > 0 && (
{/* Child classes/functions within this file */}
{childNodes.length > 0 && (
<div className="mb-4">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
Defined in this file ({childNodes.length})
</h3>
<div className="space-y-1">
{childNodes.map((child) => {
if (!child) return null;
const childTypeBadge = typeBadgeColors[child.type] ?? typeBadgeColors.file;
const childComplexity = complexityBadgeColors[child.complexity] ?? complexityBadgeColors.simple;
return (
<div
key={child.id}
className="text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle cursor-pointer hover:border-gold/40 hover:bg-gold/5 transition-colors"
onClick={() => navigateToNode(child.id)}
>
<div className="flex items-center gap-2">
<span className={`text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${childTypeBadge}`}>
{child.type}
</span>
<span className="text-text-primary truncate">{child.name}</span>
<span className={`text-[9px] ml-auto ${childComplexity} px-1 py-0.5 rounded`}>
{child.complexity}
</span>
</div>
{child.summary && (
<p className="text-[11px] text-text-muted mt-1 line-clamp-1 pl-1">
{child.summary}
</p>
)}
</div>
);
})}
</div>
</div>
)}
{/* Other connections (excluding "contains" children) */}
{otherConnections.length > 0 && (
<div>
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
Connections ({connections.length})
Connections ({otherConnections.length})
</h3>
<div className="space-y-1.5">
{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);
}}
>
<span className="text-gold font-mono">{arrow}</span>
<span className="text-text-muted">{edge.type}</span>
<span className="text-text-muted">{dirLabel}</span>
<span className="text-text-primary truncate">
{otherNode?.name ?? otherId}
</span>
@@ -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<string, unknown> {
targetLayerId: string;
targetLayerName: string;
connectionCount: number;
layerColorIndex: number;
onNavigate: (layerId: string) => void;
}
export type PortalFlowNode = Node<PortalNodeData, "portal">;
export default function PortalNode({
data,
}: NodeProps<PortalFlowNode>) {
const color = getLayerColor(data.layerColorIndex);
return (
<div
className="relative rounded-lg bg-elevated/60 overflow-hidden cursor-pointer transition-all duration-200 hover:bg-elevated/80"
style={{
width: 220,
border: `2px dashed ${color.border}`,
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
}}
onClick={() => data.onNavigate(data.targetLayerId)}
>
<Handle
type="target"
position={Position.Top}
className="!bg-text-muted !w-2 !h-2"
/>
<div className="px-3 py-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: color.label }}
/>
<span className="text-sm text-text-primary truncate">
{data.targetLayerName}
</span>
</div>
<span className="text-text-muted ml-2 shrink-0"></span>
</div>
<div className="text-[10px] text-text-muted mt-1 pl-4">
{data.connectionCount} connection{data.connectionCount !== 1 ? "s" : ""}
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="!bg-text-muted !w-2 !h-2"
/>
</div>
);
}
@@ -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 (
<div ref={containerRef} className="relative z-10">
<div ref={containerRef} className="relative z-30">
<div className="flex items-center gap-2 px-4 py-2 bg-surface border-b border-border-subtle">
<svg
className="w-4 h-4 text-text-muted shrink-0"
@@ -7,6 +7,16 @@ import type {
} from "@understand-anything/core/types";
export type Persona = "non-technical" | "junior" | "experienced";
export type NavigationLevel = "overview" | "layer-detail";
export type ViewMode = "graph" | "flow";
/** Find which layer a node belongs to. Returns layerId or null. */
function findNodeLayer(graph: KnowledgeGraph, nodeId: string): string | null {
for (const layer of graph.layers) {
if (layer.nodeIds.includes(nodeId)) return layer.id;
}
return null;
}
interface DashboardStore {
graph: KnowledgeGraph | null;
@@ -19,6 +29,13 @@ interface DashboardStore {
showLayers: boolean;
// Lens navigation
navigationLevel: NavigationLevel;
activeLayerId: string | null;
// View mode: graph (default free-form) or flow (swim-lane)
viewMode: ViewMode;
codeViewerOpen: boolean;
codeViewerNodeId: string | null;
@@ -38,9 +55,17 @@ interface DashboardStore {
// Focus mode: isolate a node's 1-hop neighborhood
focusNodeId: string | null;
// Sidebar navigation history (stack of visited node IDs)
nodeHistory: string[];
setGraph: (graph: KnowledgeGraph) => 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<DashboardStore> {
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<DashboardStore>()((set, get) => ({
graph: null,
selectedNodeId: null,
@@ -74,6 +115,10 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
showLayers: false,
navigationLevel: "overview",
activeLayerId: null,
viewMode: "graph",
codeViewerOpen: false,
codeViewerNodeId: null,
@@ -89,15 +134,125 @@ export const useDashboardStore = create<DashboardStore>()((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<DashboardStore>()((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<DashboardStore>()((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<DashboardStore>()((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<DashboardStore>()((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,
});
}
},
@@ -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<string, string>();
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<string> }
>();
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<string, number>();
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<string> {
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<string>();
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;
}
@@ -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<string, { width: number; height: number }>,
): { 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<string, string>();
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<string, Node[]>();
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<string, number>();
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 };
}