mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
Merge pull request #44 from fishinakleinbottle/feat/improve-dashboard-ux
Dashboard Improvement: lens-based hierarchical graph navigation
This commit is contained in:
@@ -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,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"
|
||||
},
|
||||
|
||||
@@ -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,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">×</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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user