Merge pull request #44 from fishinakleinbottle/feat/improve-dashboard-ux

Dashboard Improvement: lens-based hierarchical graph navigation
This commit is contained in:
Yuxiang Lin
2026-03-28 16:45:24 +08:00
committed by GitHub
Unverified
17 changed files with 1173 additions and 491 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
{
"name": "understand-anything",
"description": "Multi-agent codebase analysis with interactive dashboard, guided tours, and skill commands",
"version": "1.2.2",
"version": "1.3.0",
"source": "./understand-anything-plugin"
}
]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "understand-anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "1.2.2",
"version": "1.3.0",
"author": {
"name": "Lum1104"
},
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "understand-anything",
"displayName": "Understand Anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "1.2.2",
"version": "1.3.0",
"author": {
"name": "Lum1104"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@understand-anything/skill",
"version": "1.2.2",
"version": "1.3.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.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",
@@ -63,7 +63,7 @@ function App() {
// Navigation
{
key: "Escape",
description: "Close panels and modals",
description: "Close panels / go back to overview",
action: () => {
// Read from store at invocation time to avoid stale closures
const state = useDashboardStore.getState();
@@ -71,6 +71,8 @@ function App() {
state.closeCodeViewer();
} else if (state.selectedNodeId) {
state.selectNode(null);
} else if (state.navigationLevel === "layer-detail") {
state.navigateToOverview();
} else if (state.tourActive) {
state.stopTour();
} else {
@@ -114,15 +116,6 @@ function App() {
category: "Tour",
},
// View toggles
{
key: "l",
description: "Toggle layer visualization",
action: () => {
const state = useDashboardStore.getState();
state.toggleLayers();
},
category: "View",
},
{
key: "d",
description: "Toggle diff mode",
@@ -195,13 +188,15 @@ function App() {
}, [setDiffOverlay]);
// Determine sidebar content
// Learn persona always shows LearnPanel; tour active overrides everything
const sidebarContent = tourActive || persona === "junior" ? (
<LearnPanel />
) : selectedNodeId ? (
<NodeInfo />
) : (
<ProjectOverview />
// NodeInfo always takes priority when a node is selected.
// Learn mode adds LearnPanel below it; otherwise ProjectOverview shows when idle.
const isLearnMode = tourActive || persona === "junior";
const sidebarContent = (
<>
{selectedNodeId && <NodeInfo />}
{isLearnMode && <LearnPanel />}
{!selectedNodeId && !isLearnMode && <ProjectOverview />}
</>
);
return (
@@ -268,7 +263,7 @@ function App() {
</div>
{/* Right sidebar */}
<aside className="w-[360px] shrink-0 bg-surface border-l border-border-subtle overflow-hidden">
<aside className="w-[360px] shrink-0 bg-surface border-l border-border-subtle overflow-auto">
{sidebarContent}
</aside>
@@ -0,0 +1,38 @@
import { useDashboardStore } from "../store";
export default function Breadcrumb() {
const navigationLevel = useDashboardStore((s) => s.navigationLevel);
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
const graph = useDashboardStore((s) => s.graph);
const navigateToOverview = useDashboardStore((s) => s.navigateToOverview);
const activeLayer = graph?.layers.find((l) => l.id === activeLayerId);
return (
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
{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>
)}
{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>
)}
</div>
);
}
@@ -36,6 +36,8 @@ export interface CustomNodeData extends Record<string, unknown> {
isDiffChanged: boolean;
isDiffAffected: boolean;
isDiffFaded: boolean;
isNeighbor: boolean;
isSelectionFaded: boolean;
onNodeClick?: (nodeId: string) => void;
}
@@ -74,6 +76,13 @@ function CustomNodeComponent({
extraClass += " diff-faded";
}
// Selection-based dimming (when another node is selected, fade unrelated nodes)
if (data.isSelectionFaded) {
extraClass += " opacity-20 pointer-events-auto";
} else if (data.isNeighbor) {
extraClass += " ring-1 ring-gold-dim/50";
}
const name = data.label ?? "unnamed";
const truncatedName =
name.length > 24 ? name.slice(0, 22) + "..." : name;
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
ReactFlow,
ReactFlowProvider,
@@ -15,24 +15,38 @@ 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 type { KnowledgeGraph } from "@understand-anything/core/types";
import { useTheme } from "../themes/index.ts";
import { applyDagreLayout, applyDagreLayoutAsync, NODE_WIDTH, NODE_HEIGHT } from "../utils/layout";
import {
applyDagreLayout,
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,
};
/**
* Node count above which layout runs in a Web Worker
* to avoid blocking the main thread.
*/
const ASYNC_LAYOUT_THRESHOLD = 200;
// ── Helper components that must live inside <ReactFlow> ────────────────
const nodeTypes = { custom: CustomNode };
/**
* Inner component that pans/zooms to tour-highlighted nodes.
* Must be rendered inside <ReactFlow> so useReactFlow() works.
*/
/** Pans/zooms to tour-highlighted nodes. */
function TourFitView() {
const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds);
const { fitView } = useReactFlow();
@@ -47,7 +61,6 @@ function TourFitView() {
prevRef.current = tourHighlightedNodeIds;
if (changed) {
// Small delay to ensure nodes are rendered before fitting
requestAnimationFrame(() => {
fitView({
nodes: tourHighlightedNodeIds.map((id) => ({ id })),
@@ -63,10 +76,7 @@ function TourFitView() {
return null;
}
/**
* Centers the graph on the selected node (e.g. from search).
* Must be rendered inside <ReactFlow> so useReactFlow() works.
*/
/** Centers the graph on the selected node (e.g. from search). */
function SelectedNodeFitView() {
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const { fitView } = useReactFlow();
@@ -90,328 +100,397 @@ function SelectedNodeFitView() {
return null;
}
/**
* Build topology-only flow data: nodes and edges without visual-only state
* (selection, tour highlights, search results). This output drives dagre
* layout and should only recompute when the graph structure changes.
*/
function buildTopologyData(
graph: NonNullable<ReturnType<typeof useDashboardStore.getState>["graph"]>,
persona: string,
diffMode: boolean,
changedNodeIds: Set<string>,
affectedNodeIds: Set<string>,
handleNodeSelect: (nodeId: string) => void,
) {
const filteredGraphNodes =
persona === "non-technical"
? graph.nodes.filter(
(n) =>
n.type === "concept" || n.type === "module" || n.type === "file",
)
: graph.nodes;
// ── Overview level: layers as cluster nodes ────────────────────────────
const filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
const filteredGraphEdges =
persona === "non-technical"
? graph.edges.filter(
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
)
: graph.edges;
function useOverviewGraph() {
const graph = useDashboardStore((s) => s.graph);
const searchResults = useDashboardStore((s) => s.searchResults);
const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
const flowNodes: CustomFlowNode[] = filteredGraphNodes.map((node) => ({
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: false,
searchScore: undefined,
isSelected: false,
isTourHighlighted: false,
isDiffChanged: diffMode && changedNodeIds.has(node.id),
isDiffAffected: diffMode && affectedNodeIds.has(node.id),
isDiffFaded: diffMode && !changedNodeIds.has(node.id) && !affectedNodeIds.has(node.id),
onNodeClick: handleNodeSelect,
},
}));
return useMemo(() => {
if (!graph) return { nodes: [] as Node[], edges: [] as Edge[] };
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);
const layers = graph.layers ?? [];
if (layers.length === 0) return { nodes: [] as Node[], edges: [] as Edge[] };
return {
id: `e-${i}`,
source: edge.source,
target: edge.target,
label: edge.type,
animated: edge.type === "calls" || isImpacted,
style: isImpacted
? {
stroke: sourceInDiff && targetInDiff
? "var(--color-diff-changed)"
: "var(--color-diff-affected)",
strokeWidth: 2.5,
}
: diffMode
? { stroke: "var(--color-edge-dim)", strokeWidth: 1 }
: { stroke: "var(--color-edge)", strokeWidth: 1.5 },
labelStyle: diffMode && !isImpacted
? { fill: "var(--color-text-muted)", fontSize: 10 }
: { fill: "var(--color-text-secondary)", fontSize: 10 },
};
});
return { flowNodes, flowEdges };
}
/**
* Lightweight overlay of visual-only state onto already-positioned nodes.
* This is O(n) object spreads — cheap even for thousands of nodes — and
* avoids triggering a dagre relayout when selection/highlight/search changes.
*/
function applyVisualState(
nodes: (CustomFlowNode | Node)[],
selectedNodeId: string | null,
tourHighlightedNodeIds: string[],
searchResults: Array<{ nodeId: string; score: number }>,
): (CustomFlowNode | Node)[] {
const searchMap = new Map(searchResults.map((r) => [r.nodeId, r.score]));
const tourSet = new Set(tourHighlightedNodeIds);
return nodes.map((node) => {
// Skip group nodes (layer containers) — they have no CustomNodeData
if (node.type === "group") return node;
const searchScore = searchMap.get(node.id);
const isHighlighted = searchScore !== undefined;
const isSelected = selectedNodeId === node.id;
const isTourHighlighted = tourSet.has(node.id);
const data = node.data as CustomFlowNode["data"];
// Skip creating a new object if nothing visual changed
if (
data.isHighlighted === isHighlighted &&
data.searchScore === searchScore &&
data.isSelected === isSelected &&
data.isTourHighlighted === isTourHighlighted
) {
return node;
// 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);
}
}
}
return {
...node,
data: {
...data,
isHighlighted,
searchScore,
isSelected,
isTourHighlighted,
},
};
});
}
// Create cluster nodes
const clusterNodes: LayerClusterFlowNode[] = layers.map((layer, i) => {
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";
function applyLayerGroups(
laidNodes: CustomFlowNode[],
edges: Edge[],
layers: Array<{ id: string; name: string; nodeIds: string[] }>,
showLayers: boolean,
): { initialNodes: (CustomFlowNode | Node)[]; initialEdges: Edge[] } {
if (!showLayers || layers.length === 0) {
return { initialNodes: laidNodes, initialEdges: edges };
}
const nodeToLayer = new Map<string, string>();
for (const layer of layers) {
for (const nodeId of layer.nodeIds) {
nodeToLayer.set(nodeId, layer.id);
}
}
const groupNodes: Node[] = [];
const adjustedNodes: (CustomFlowNode | Node)[] = [];
for (const layer of layers) {
const memberNodes = laidNodes.filter((n) => layer.nodeIds.includes(n.id));
if (memberNodes.length === 0) continue;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const node of memberNodes) {
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + NODE_WIDTH);
maxY = Math.max(maxY, node.position.y + NODE_HEIGHT);
}
const groupX = minX - LAYER_PADDING;
const groupY = minY - LAYER_PADDING - 24;
const groupWidth = maxX - minX + LAYER_PADDING * 2;
const groupHeight = maxY - minY + LAYER_PADDING * 2 + 24;
groupNodes.push({
id: layer.id,
type: "group",
position: { x: groupX, y: groupY },
data: { label: layer.name },
style: {
width: groupWidth,
height: groupHeight,
backgroundColor: "var(--color-accent-overlay-bg)",
borderRadius: 12,
border: "2px dashed var(--color-accent-overlay-border)",
padding: 8,
fontSize: 13,
fontWeight: 600,
color: "var(--color-accent)",
},
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,
},
};
});
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,
},
});
}
}
// 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 },
}));
for (const node of laidNodes) {
if (!nodeToLayer.has(node.id)) {
adjustedNodes.push(node);
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 });
}
}
return {
initialNodes: [...groupNodes, ...adjustedNodes],
initialEdges: edges,
};
const laid = applyDagreLayout(clusterNodes as unknown as Node[], flowEdges, "TB", dims);
return { nodes: laid.nodes, edges: laid.edges };
}, [graph, searchResults, drillIntoLayer]);
}
function GraphViewInner() {
// ── Layer detail level: topology (dagre) + visual overlay ───────────────
/**
* Topology memo: computes node positions via dagre. Only recomputes when
* the graph structure, active layer, persona, diff, or focus changes.
* Does NOT depend on selectedNodeId, searchResults, or tourHighlightedNodeIds.
*/
function useLayerDetailTopology() {
const graph = useDashboardStore((s) => s.graph);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const searchResults = useDashboardStore((s) => s.searchResults);
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
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 { preset } = useTheme();
const [layouting, setLayouting] = useState(false);
const focusNodeId = useDashboardStore((s) => s.focusNodeId);
const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
const handleNodeSelect = useCallback(
(nodeId: string) => {
selectNode(nodeId);
openCodeViewer(nodeId);
},
[selectNode, openCodeViewer],
[selectNode],
);
// ── Topology memo: only recomputes when graph structure changes ──
// Does NOT depend on selectedNodeId, tourHighlightedNodeIds, or searchResults.
const { topoNodes, topoEdges, needsAsyncLayout } = useMemo(() => {
if (!graph) {
return { topoNodes: [] as CustomFlowNode[], topoEdges: [] as Edge[], needsAsyncLayout: false };
}
const { flowNodes, flowEdges } = buildTopologyData(
graph, persona, diffMode, changedNodeIds, affectedNodeIds,
handleNodeSelect,
return useMemo(() => {
if (!graph || !activeLayerId)
return { nodes: [] as CustomFlowNode[], edges: [] as Edge[], portalNodes: [] as PortalFlowNode[], portalEdges: [] as Edge[], filteredEdges: [] as KnowledgeGraph["edges"] };
const activeLayer = graph.layers.find((l) => l.id === activeLayerId);
if (!activeLayer) return { nodes: [] as CustomFlowNode[], edges: [] as Edge[], portalNodes: [] as PortalFlowNode[], portalEdges: [] as Edge[], filteredEdges: [] as KnowledgeGraph["edges"] };
const layerNodeIds = new Set(activeLayer.nodeIds);
// Non-technical persona only sees concept/module/file nodes
let filteredGraphNodes = persona === "non-technical"
? graph.nodes.filter(
(n) => layerNodeIds.has(n.id) && (n.type === "concept" || n.type === "module" || n.type === "file"),
)
: graph.nodes.filter((n) => layerNodeIds.has(n.id) && n.type === "file");
let filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
let filteredGraphEdges = graph.edges.filter(
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
);
return { topoNodes: flowNodes, topoEdges: flowEdges, needsAsyncLayout: flowNodes.length > ASYNC_LAYOUT_THRESHOLD };
}, [graph, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds]);
// ── Laid-out nodes from the last completed layout pass ──
// Stored in a ref so layout results persist across visual-state changes.
const laidOutRef = useRef<{ initialNodes: (CustomFlowNode | Node)[]; initialEdges: Edge[] } | null>(null);
// ── Sync layout: for small graphs, run dagre on the main thread ──
const syncResult = useMemo(() => {
if (!graph || needsAsyncLayout || topoNodes.length === 0) return null;
const laid = applyDagreLayout(topoNodes, topoEdges);
const layers = graph.layers ?? [];
return applyLayerGroups(laid.nodes as CustomFlowNode[], laid.edges, layers, showLayers);
}, [graph, topoNodes, topoEdges, needsAsyncLayout, showLayers]);
// Keep laidOutRef in sync with sync layout results
if (syncResult) {
laidOutRef.current = syncResult;
}
// ── Visual memo: cheap overlay of selection/highlight/search state ──
const visualNodes = useMemo(() => {
const base = laidOutRef.current;
if (!base) return [] as (CustomFlowNode | Node)[];
return applyVisualState(base.initialNodes, selectedNodeId, tourHighlightedNodeIds, searchResults);
}, [laidOutRef.current, selectedNodeId, tourHighlightedNodeIds, searchResults]);
const [nodes, setNodes, onNodesChange] = useNodesState(visualNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(laidOutRef.current?.initialEdges ?? []);
// ── Push sync layout + visual state to ReactFlow ──
useEffect(() => {
if (syncResult) {
const withVisual = applyVisualState(syncResult.initialNodes, selectedNodeId, tourHighlightedNodeIds, searchResults);
setNodes(withVisual);
setEdges(syncResult.initialEdges);
// 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),
);
filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
filteredGraphEdges = filteredGraphEdges.filter(
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
);
}
}, [syncResult, selectedNodeId, tourHighlightedNodeIds, searchResults, setNodes, setEdges]);
// ── Push visual-only changes (no relayout) ──
useEffect(() => {
if (laidOutRef.current && !layouting) {
const withVisual = applyVisualState(laidOutRef.current.initialNodes, selectedNodeId, tourHighlightedNodeIds, searchResults);
setNodes(withVisual);
}
}, [selectedNodeId, tourHighlightedNodeIds, searchResults, setNodes, layouting]);
const diffNodeIds = diffMode
? new Set([...changedNodeIds, ...affectedNodeIds])
: new Set<string>();
// ── Async layout: for large graphs, run dagre in a Web Worker ──
useEffect(() => {
if (!graph || !needsAsyncLayout || topoNodes.length === 0) return;
const flowNodes: CustomFlowNode[] = filteredGraphNodes.map((node) => ({
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: false,
searchScore: undefined,
isSelected: false,
isTourHighlighted: false,
isDiffChanged: diffMode && changedNodeIds.has(node.id),
isDiffAffected: diffMode && affectedNodeIds.has(node.id),
isDiffFaded: diffMode && !changedNodeIds.has(node.id) && !affectedNodeIds.has(node.id),
isNeighbor: false,
isSelectionFaded: false,
onNodeClick: handleNodeSelect,
},
}));
let cancelled = false;
setLayouting(true);
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);
applyDagreLayoutAsync(topoNodes, topoEdges).then((laid) => {
if (cancelled) return;
const layers = graph.layers ?? [];
const result = applyLayerGroups(laid.nodes as CustomFlowNode[], laid.edges, layers, showLayers);
laidOutRef.current = result;
const withVisual = applyVisualState(result.initialNodes, selectedNodeId, tourHighlightedNodeIds, searchResults);
setNodes(withVisual);
setEdges(result.initialEdges);
setLayouting(false);
}).catch(() => {
if (cancelled) return;
setLayouting(false);
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 {
edgeStyle = { stroke: "rgba(212,165,116,0.3)", strokeWidth: 1.5 };
edgeLabelStyle = { fill: "#a39787", fontSize: 10 };
edgeAnimated = edge.type === "calls";
}
return {
id: `e-${i}`,
source: edge.source,
target: edge.target,
label: edge.type,
animated: edgeAnimated,
style: edgeStyle,
labelStyle: edgeLabelStyle,
};
});
return () => { cancelled = true; setLayouting(false); };
}, [graph, topoNodes, topoEdges, needsAsyncLayout, showLayers, setNodes, setEdges]);
// Portal nodes for connected external layers
const portals = computePortals(graph, activeLayerId);
const layerIndexMap = new Map(graph.layers.map((l, i) => [l.id, i]));
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,
},
}));
const portalEdges: Edge[] = [];
let portalEdgeIdx = flowEdges.length;
for (const portal of portals) {
const crossFiles = findCrossLayerFileNodes(graph, activeLayerId, portal.layerId);
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,
});
}
}
}
const allFlowNodes: Node[] = [
...(flowNodes as unknown as Node[]),
...(portalNodes as unknown as Node[]),
];
const allFlowEdges = [...flowEdges, ...portalEdges];
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, portalNodes, portalEdges, filteredEdges: filteredGraphEdges };
}, [graph, activeLayerId, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds, focusNodeId, drillIntoLayer]);
}
/**
* Visual overlay: cheap O(n) pass that applies selection, search, and tour
* state onto already-positioned nodes. Avoids triggering dagre relayout.
*/
function useLayerDetailGraph() {
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const searchResults = useDashboardStore((s) => s.searchResults);
const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds);
const topo = useLayerDetailTopology();
const nodes = useMemo(() => {
const searchMap = new Map(searchResults.map((r) => [r.nodeId, r.score]));
const tourSet = new Set(tourHighlightedNodeIds);
// Build neighbor set for selection highlighting
const neighborNodeIds = new Set<string>();
if (selectedNodeId) {
for (const edge of topo.filteredEdges) {
if (edge.source === selectedNodeId) neighborNodeIds.add(edge.target);
if (edge.target === selectedNodeId) neighborNodeIds.add(edge.source);
}
neighborNodeIds.add(selectedNodeId);
}
return topo.nodes.map((node) => {
// Skip portal nodes — they have no CustomNodeData
if (node.type === "portal") return node;
const searchScore = searchMap.get(node.id);
const isHighlighted = searchScore !== undefined;
const isSelected = selectedNodeId === node.id;
const isTourHighlighted = tourSet.has(node.id);
const hasSelection = !!selectedNodeId;
const isNeighbor = hasSelection && neighborNodeIds.has(node.id) && !isSelected;
const isSelectionFaded = hasSelection && !neighborNodeIds.has(node.id);
const data = node.data as CustomFlowNode["data"];
// Skip creating a new object if nothing visual changed
if (
data.isHighlighted === isHighlighted &&
data.searchScore === searchScore &&
data.isSelected === isSelected &&
data.isTourHighlighted === isTourHighlighted &&
data.isNeighbor === isNeighbor &&
data.isSelectionFaded === isSelectionFaded
) {
return node;
}
return { ...node, data: { ...data, isHighlighted, searchScore, isSelected, isTourHighlighted, isNeighbor, isSelectionFaded } };
});
}, [topo.nodes, topo.filteredEdges, selectedNodeId, searchResults, tourHighlightedNodeIds]);
const edges = useMemo(() => {
if (!selectedNodeId) return topo.edges;
// Apply selection-based edge styling on top of topology edges
return topo.edges.map((edge) => {
const isSelectedEdge = edge.source === selectedNodeId || edge.target === selectedNodeId;
// Don't restyle diff-impacted or portal edges
if ((edge.style as Record<string, unknown>)?.strokeDasharray) return edge;
if (isSelectedEdge) {
return { ...edge, animated: true, style: { stroke: "rgba(212,165,116,0.8)", strokeWidth: 2.5 }, labelStyle: { fill: "#d4a574", fontSize: 11, fontWeight: 600 } };
}
// Fade unrelated edges
return { ...edge, animated: false, style: { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }, labelStyle: { fill: "rgba(163,151,135,0.2)", fontSize: 10 } };
});
}, [topo.edges, selectedNodeId]);
return { nodes, edges };
}
// ── Main inner component (must be inside ReactFlowProvider) ────────────
function GraphViewInner() {
const graph = useDashboardStore((s) => s.graph);
const navigationLevel = useDashboardStore((s) => s.navigationLevel);
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
const selectNode = useDashboardStore((s) => s.selectNode);
const openCodeViewer = useDashboardStore((s) => s.openCodeViewer);
const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
const focusNodeId = useDashboardStore((s) => s.focusNodeId);
const setFocusNode = useDashboardStore((s) => s.setFocusNode);
const { preset } = useTheme();
const overviewGraph = useOverviewGraph();
const detailGraph = useLayerDetailGraph();
const { nodes: initialNodes, edges: initialEdges } =
navigationLevel === "overview" ? overviewGraph : detailGraph;
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const { fitView } = useReactFlow();
useEffect(() => {
setNodes(initialNodes);
}, [initialNodes, setNodes]);
useEffect(() => {
setEdges(initialEdges);
}, [initialEdges, setEdges]);
// Fit view on level/layer transitions
useEffect(() => {
const timer = setTimeout(() => {
fitView({ duration: 400, padding: 0.2 });
}, 50);
return () => clearTimeout(timer);
}, [navigationLevel, activeLayerId, 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);
if (navigationLevel === "overview") {
drillIntoLayer(node.id);
} else if (node.id.startsWith("portal:")) {
const targetLayerId = node.id.replace("portal:", "");
drillIntoLayer(targetLayerId);
} else {
selectNode(node.id);
openCodeViewer(node.id);
}
},
[selectNode, openCodeViewer, graph],
[navigationLevel, drillIntoLayer, selectNode, openCodeViewer],
);
const onPaneClick = useCallback(() => {
@@ -428,14 +507,16 @@ function GraphViewInner() {
return (
<div className="h-full w-full relative">
{layouting && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-root/80 rounded-lg">
<div className="text-center">
<div className="inline-block w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin mb-3" />
<p className="text-text-secondary text-sm">
Laying out {topoNodes.length.toLocaleString()} nodes...
</p>
</div>
<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"
>
<span>Showing neighborhood</span>
<span className="text-text-muted">&times;</span>
</button>
</div>
)}
<ReactFlow
@@ -0,0 +1,104 @@
import { memo } from "react";
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">;
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>
);
}
export default memo(LayerClusterNode);
@@ -1,86 +1,70 @@
import { useDashboardStore } from "../store";
const LAYER_COLORS = [
"rgba(59, 130, 246, 0.08)", // blue
"rgba(16, 185, 129, 0.08)", // green
"rgba(245, 158, 11, 0.08)", // amber
"rgba(139, 92, 246, 0.08)", // violet
"rgba(236, 72, 153, 0.08)", // pink
"rgba(6, 182, 212, 0.08)", // cyan
"rgba(249, 115, 22, 0.08)", // orange
"rgba(168, 162, 158, 0.08)", // stone
// 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)
{ bg: "rgba(139, 111, 176, 0.12)", border: "rgba(139, 111, 176, 0.4)", label: "#8b6fb0" }, // purple (Service)
{ bg: "rgba(201, 160, 108, 0.12)", border: "rgba(201, 160, 108, 0.4)", label: "#c9a06c" }, // gold (Config)
{ bg: "rgba(176, 122, 138, 0.12)", border: "rgba(176, 122, 138, 0.4)", label: "#b07a8a" }, // pink (UI)
{ bg: "rgba(74, 155, 140, 0.12)", border: "rgba(74, 155, 140, 0.4)", label: "#4a9b8c" }, // teal (Middleware)
{ bg: "rgba(120, 130, 145, 0.12)", border: "rgba(120, 130, 145, 0.4)", label: "#788291" }, // slate (Test)
];
export const LAYER_BORDER_COLORS = [
"rgba(59, 130, 246, 0.5)", // blue
"rgba(16, 185, 129, 0.5)", // green
"rgba(245, 158, 11, 0.5)", // amber
"rgba(139, 92, 246, 0.5)", // violet
"rgba(236, 72, 153, 0.5)", // pink
"rgba(6, 182, 212, 0.5)", // cyan
"rgba(249, 115, 22, 0.5)", // orange
"rgba(168, 162, 158, 0.5)", // stone
];
export { LAYER_COLORS };
export function getLayerColor(index: number): string {
return LAYER_COLORS[index % LAYER_COLORS.length];
}
export function getLayerBorderColor(index: number): string {
return LAYER_BORDER_COLORS[index % LAYER_BORDER_COLORS.length];
export function getLayerColor(index: number) {
return LAYER_PALETTE[index % LAYER_PALETTE.length];
}
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-accent/20 text-accent"
: 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) => (
<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: getLayerBorderColor(i) }}
style={{
backgroundColor: color.label,
opacity: navigationLevel === "layer-detail" && !isActive ? 0.3 : 1,
}}
/>
<span className="text-text-secondary text-[11px]">
<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>
</span>
</div>
))}
</div>
)}
);
})}
</div>
</div>
);
}
@@ -15,13 +15,73 @@ 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 navigateToHistoryIndex = useDashboardStore((s) => s.navigateToHistoryIndex);
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">
@@ -30,16 +90,65 @@ 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={() => {
const fullIdx = historyNodes.length - arr.length + i;
navigateToHistoryIndex(fullIdx);
}}
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}`}
@@ -53,7 +162,19 @@ export default function NodeInfo() {
</span>
</div>
<h2 className="text-lg font-serif text-text-primary mb-2">{node.name}</h2>
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-serif text-text-primary">{node.name}</h2>
<button
onClick={() => setFocusNode(focusNodeId === node.id ? null : node.id)}
className={`text-[10px] font-semibold uppercase tracking-wider px-2.5 py-1 rounded transition-colors ${
focusNodeId === node.id
? "bg-gold/20 text-gold border border-gold/40"
: "text-text-muted border border-border-subtle hover:text-gold hover:border-gold/30"
}`}
>
{focusNodeId === node.id ? "Unfocus" : "Focus"}
</button>
</div>
<p className="text-sm text-text-secondary mb-4 leading-relaxed">
{node.summary}
@@ -115,25 +236,68 @@ 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-accent uppercase tracking-wider mb-2">
Connections ({connections.length})
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
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 (
<div
key={i}
className="text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle flex items-center gap-2"
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);
}}
>
<span className="text-accent font-mono">{arrow}</span>
<span className="text-text-muted">{edge.type}</span>
<span className="text-gold font-mono">{arrow}</span>
<span className="text-text-muted">{dirLabel}</span>
<span className="text-text-primary truncate">
{otherNode?.name ?? otherId}
</span>
@@ -0,0 +1,64 @@
import { memo } from "react";
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">;
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>
);
}
export default memo(PortalNode);
@@ -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,19 @@ import type {
} from "@understand-anything/core/types";
export type Persona = "non-technical" | "junior" | "experienced";
export type NavigationLevel = "overview" | "layer-detail";
/** 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;
}
/** Maximum number of entries in the sidebar navigation history. */
const MAX_HISTORY = 50;
interface DashboardStore {
graph: KnowledgeGraph | null;
@@ -17,7 +30,9 @@ interface DashboardStore {
searchMode: "fuzzy" | "semantic";
setSearchMode: (mode: "fuzzy" | "semantic") => void;
showLayers: boolean;
// Lens navigation
navigationLevel: NavigationLevel;
activeLayerId: string | null;
codeViewerOpen: boolean;
codeViewerNodeId: string | null;
@@ -32,10 +47,22 @@ interface DashboardStore {
changedNodeIds: Set<string>;
affectedNodeIds: Set<string>;
// 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;
navigateToHistoryIndex: (index: number) => void;
goBackNode: () => void;
drillIntoLayer: (layerId: string) => void;
navigateToOverview: () => void;
setFocusNode: (nodeId: string | null) => void;
setSearchQuery: (query: string) => void;
toggleLayers: () => void;
setPersona: (persona: Persona) => void;
openCodeViewer: (nodeId: string) => void;
closeCodeViewer: () => void;
@@ -56,6 +83,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,
@@ -64,8 +107,8 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
searchEngine: null,
searchMode: "fuzzy",
showLayers: false,
navigationLevel: "overview",
activeLayerId: null,
codeViewerOpen: false,
codeViewerNodeId: null,
@@ -79,13 +122,123 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
changedNodeIds: new Set<string>(),
affectedNodeIds: new Set<string>(),
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 }),
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].slice(-MAX_HISTORY),
});
} 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].slice(-MAX_HISTORY)
: nodeHistory;
if (layerId) {
set({
navigationLevel: "layer-detail",
activeLayerId: layerId,
selectedNodeId: nodeId,
focusNodeId: null,
codeViewerOpen: false,
codeViewerNodeId: null,
nodeHistory: newHistory,
});
} else {
set({
selectedNodeId: nodeId,
nodeHistory: newHistory,
});
}
},
navigateToHistoryIndex: (index) => {
const { nodeHistory, graph } = get();
if (!graph || index < 0 || index >= nodeHistory.length) return;
const targetId = nodeHistory[index];
const newHistory = nodeHistory.slice(0, index);
const layerId = findNodeLayer(graph, targetId);
set({
selectedNodeId: targetId,
nodeHistory: newHistory,
...(layerId ? { navigationLevel: "layer-detail" as const, activeLayerId: layerId } : {}),
});
},
goBackNode: () => {
const { nodeHistory, graph } = get();
if (nodeHistory.length === 0 || !graph) return;
const prevNodeId = nodeHistory[nodeHistory.length - 1];
const newHistory = nodeHistory.slice(0, -1);
const layerId = findNodeLayer(graph, prevNodeId);
if (layerId) {
set({
navigationLevel: "layer-detail",
activeLayerId: layerId,
selectedNodeId: prevNodeId,
nodeHistory: newHistory,
});
} else {
set({
selectedNodeId: 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,
}),
setFocusNode: (nodeId) => set({ focusNodeId: nodeId, selectedNodeId: nodeId }),
setSearchMode: (mode) => set({ searchMode: mode }),
setSearchQuery: (query) => {
const engine = get().searchEngine;
@@ -101,8 +254,6 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
set({ searchQuery: query, searchResults });
},
toggleLayers: () => set((state) => ({ showLayers: !state.showLayers })),
setPersona: (persona) => set({ persona }),
openCodeViewer: (nodeId) => set({ codeViewerOpen: true, codeViewerNodeId: nodeId }),
@@ -128,11 +279,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,
});
},
@@ -148,9 +301,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,
});
},
@@ -160,9 +315,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,
});
}
},
@@ -173,9 +330,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,135 @@
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.
* Accepts optional pre-computed aggregation to avoid redundant work.
*/
export function computePortals(
graph: KnowledgeGraph,
activeLayerId: string,
precomputed?: LayerEdgeAggregation[],
): PortalInfo[] {
const aggregated = precomputed ?? 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,9 +1,12 @@
import dagre from "@dagrejs/dagre";
import type { Node, Edge } from "@xyflow/react";
import type { LayoutMessage, LayoutResult } from "./layout.worker";
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;
/**
* Synchronous dagre layout — used for small graphs.
@@ -12,19 +15,26 @@ 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(() => ({}));
// Scale spacing for larger graphs to reduce overlap
const isLarge = nodes.length > 50;
g.setGraph({
rankdir: direction,
nodesep: 60,
ranksep: 80,
nodesep: isLarge ? 80 : 60,
ranksep: isLarge ? 120 : 80,
marginx: 20,
marginy: 20,
});
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) => {
@@ -36,11 +46,14 @@ 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,
},
};
});
@@ -48,78 +61,4 @@ export function applyDagreLayout(
return { nodes: layoutedNodes, edges };
}
let _worker: Worker | null = null;
let _nextRequestId = 0;
let _latestRequestId = -1;
const _pending = new Map<
number,
{
nodes: Node[];
edges: Edge[];
resolve: (v: { nodes: Node[]; edges: Edge[] }) => void;
reject: (reason?: unknown) => void;
}
>();
function getWorker(): Worker {
if (!_worker) {
_worker = new Worker(
new URL("./layout.worker.ts", import.meta.url),
{ type: "module" },
);
_worker.onmessage = (e: MessageEvent<LayoutResult>) => {
const { requestId, positions } = e.data;
const entry = _pending.get(requestId);
_pending.delete(requestId);
// S1: Discard stale results — only honour the latest request.
if (!entry || requestId !== _latestRequestId) return;
const layoutedNodes = entry.nodes.map((node) => ({
...node,
position: positions[node.id] ?? { x: 0, y: 0 },
}));
entry.resolve({ nodes: layoutedNodes, edges: entry.edges });
};
_worker.onerror = (err: ErrorEvent) => {
for (const [, entry] of _pending) {
entry.reject(err);
}
_pending.clear();
};
}
return _worker;
}
/**
* Async dagre layout via Web Worker — used for large graphs.
* Keeps the main thread responsive while dagre computes positions.
*
* Uses request-ID correlation so concurrent calls never cross-wire,
* and only the latest request's result is honoured (stale ones are discarded).
*/
export function applyDagreLayoutAsync(
nodes: Node[],
edges: Edge[],
direction: "TB" | "LR" = "TB",
): Promise<{ nodes: Node[]; edges: Edge[] }> {
return new Promise((resolve, reject) => {
const worker = getWorker();
const requestId = _nextRequestId++;
_latestRequestId = requestId;
_pending.set(requestId, { nodes, edges, resolve, reject });
const msg: LayoutMessage = {
requestId,
nodes: nodes.map((n) => ({ id: n.id, width: NODE_WIDTH, height: NODE_HEIGHT })),
edges: edges.map((e) => ({ source: e.source, target: e.target })),
direction,
};
worker.postMessage(msg);
});
}