mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
feat: lens-based graph navigation with flow view and sidebar history
Replace flat all-nodes-at-once graph with a two-level drill-down: - Level 1: layer cluster nodes (free-form graph of architectural layers) - Level 2: file nodes within a layer + portal nodes to adjacent layers - Breadcrumb navigation with Escape key to go back Add swim-lane "Flow" view showing all files in columns ordered by request lifecycle (API → Middleware → Service → Data → etc.) using dagre for within-lane vertical positioning. Sidebar improvements: - Navigation history trail (← Back + clickable breadcrumbs) - Directional connection labels (imports/imported by, contains/contained in) - Separate "Defined in this file" section for child classes/functions - Fix search dropdown overlapping breadcrumbs (z-index) - Remove redundant CodeViewer auto-open on node click Layer detector: add External Services and Background Tasks patterns.
This commit is contained in:
@@ -39,6 +39,16 @@ const LAYER_PATTERNS: Array<{ patterns: string[]; layerName: string; description
|
||||
layerName: "Middleware Layer",
|
||||
description: "Request/response middleware and interceptors",
|
||||
},
|
||||
{
|
||||
patterns: ["client", "integration", "external", "sdk", "vendor", "adapter"],
|
||||
layerName: "External Services",
|
||||
description: "External service integrations, SDKs, and third-party adapters",
|
||||
},
|
||||
{
|
||||
patterns: ["worker", "job", "queue", "cron", "consumer", "processor", "scheduler", "background"],
|
||||
layerName: "Background Tasks",
|
||||
description: "Background workers, job processors, and scheduled tasks",
|
||||
},
|
||||
{
|
||||
patterns: ["util", "helper", "lib", "common", "shared"],
|
||||
layerName: "Utility Layer",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useDashboardStore } from "../store";
|
||||
import type { ViewMode } from "../store";
|
||||
|
||||
export default function Breadcrumb() {
|
||||
const navigationLevel = useDashboardStore((s) => s.navigationLevel);
|
||||
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
|
||||
const viewMode = useDashboardStore((s) => s.viewMode);
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const navigateToOverview = useDashboardStore((s) => s.navigateToOverview);
|
||||
const setViewMode = useDashboardStore((s) => s.setViewMode);
|
||||
|
||||
const activeLayer = graph?.layers.find((l) => l.id === activeLayerId);
|
||||
|
||||
// Escape key to go back to overview (only in graph mode)
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === "Escape" &&
|
||||
viewMode === "graph" &&
|
||||
navigationLevel === "layer-detail"
|
||||
) {
|
||||
navigateToOverview();
|
||||
}
|
||||
},
|
||||
[viewMode, navigationLevel, navigateToOverview],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const handleViewToggle = useCallback(
|
||||
(mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
},
|
||||
[setViewMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
|
||||
{/* View mode toggle */}
|
||||
<div className="flex items-center rounded-full bg-elevated border border-border-subtle shadow-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleViewToggle("graph")}
|
||||
className={`px-3 py-2 text-[10px] font-semibold uppercase tracking-wider transition-colors ${
|
||||
viewMode === "graph"
|
||||
? "bg-gold/20 text-gold"
|
||||
: "text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
Graph
|
||||
</button>
|
||||
<div className="w-px h-4 bg-border-subtle" />
|
||||
<button
|
||||
onClick={() => handleViewToggle("flow")}
|
||||
className={`px-3 py-2 text-[10px] font-semibold uppercase tracking-wider transition-colors ${
|
||||
viewMode === "flow"
|
||||
? "bg-gold/20 text-gold"
|
||||
: "text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
Flow
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation breadcrumb (only in graph mode) */}
|
||||
{viewMode === "graph" && navigationLevel === "overview" && (
|
||||
<div className="px-4 py-2 rounded-full bg-elevated border border-border-subtle text-xs font-semibold tracking-wider uppercase text-text-secondary shadow-lg">
|
||||
Project Overview
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "graph" && navigationLevel === "layer-detail" && (
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 rounded-full bg-elevated border border-gold/30 text-xs font-semibold tracking-wider uppercase shadow-lg">
|
||||
<button
|
||||
onClick={navigateToOverview}
|
||||
className="text-gold hover:text-gold-bright transition-colors"
|
||||
>
|
||||
Project
|
||||
</button>
|
||||
<span className="text-text-muted">›</span>
|
||||
<span className="text-text-primary">
|
||||
{activeLayer?.name ?? "Layer"}
|
||||
</span>
|
||||
<span className="text-text-muted ml-1 text-[10px] normal-case tracking-normal">
|
||||
(Esc to go back)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "flow" && (
|
||||
<div className="px-4 py-2 rounded-full bg-elevated border border-border-subtle text-xs font-semibold tracking-wider uppercase text-text-secondary shadow-lg">
|
||||
Request Flow
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,76 +14,199 @@ import "@xyflow/react/dist/style.css";
|
||||
|
||||
import CustomNode from "./CustomNode";
|
||||
import type { CustomFlowNode } from "./CustomNode";
|
||||
import LayerClusterNode from "./LayerClusterNode";
|
||||
import type { LayerClusterFlowNode } from "./LayerClusterNode";
|
||||
import PortalNode from "./PortalNode";
|
||||
import type { PortalFlowNode } from "./PortalNode";
|
||||
import Breadcrumb from "./Breadcrumb";
|
||||
import { useDashboardStore } from "../store";
|
||||
import { applyDagreLayout, NODE_WIDTH, NODE_HEIGHT } from "../utils/layout";
|
||||
import { getLayerColor } from "./LayerLegend";
|
||||
import {
|
||||
applyDagreLayout,
|
||||
applySwimLaneLayout,
|
||||
NODE_WIDTH,
|
||||
NODE_HEIGHT,
|
||||
LAYER_CLUSTER_WIDTH,
|
||||
LAYER_CLUSTER_HEIGHT,
|
||||
PORTAL_NODE_WIDTH,
|
||||
PORTAL_NODE_HEIGHT,
|
||||
} from "../utils/layout";
|
||||
import {
|
||||
aggregateLayerEdges,
|
||||
computePortals,
|
||||
findCrossLayerFileNodes,
|
||||
} from "../utils/edgeAggregation";
|
||||
|
||||
const LAYER_PADDING = 40;
|
||||
const nodeTypes = {
|
||||
custom: CustomNode,
|
||||
"layer-cluster": LayerClusterNode,
|
||||
portal: PortalNode,
|
||||
};
|
||||
|
||||
const nodeTypes = { custom: CustomNode };
|
||||
// ── Overview level: layers as cluster nodes ────────────────────────────
|
||||
|
||||
export default function GraphView() {
|
||||
function useOverviewGraph() {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const searchResults = useDashboardStore((s) => s.searchResults);
|
||||
const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
|
||||
const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!graph) return { nodes: [] as Node[], edges: [] as Edge[] };
|
||||
|
||||
const layers = graph.layers ?? [];
|
||||
if (layers.length === 0) return { nodes: [] as Node[], edges: [] as Edge[] };
|
||||
|
||||
// Build search match counts per layer
|
||||
const searchMatchByLayer = new Map<string, number>();
|
||||
if (searchResults.length > 0) {
|
||||
const nodeToLayer = new Map<string, string>();
|
||||
for (const layer of layers) {
|
||||
for (const nid of layer.nodeIds) {
|
||||
nodeToLayer.set(nid, layer.id);
|
||||
}
|
||||
}
|
||||
for (const result of searchResults) {
|
||||
const lid = nodeToLayer.get(result.nodeId);
|
||||
if (lid) {
|
||||
searchMatchByLayer.set(lid, (searchMatchByLayer.get(lid) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build tour highlight set for layers
|
||||
const tourLayerIds = new Set<string>();
|
||||
if (tourHighlightedNodeIds.length > 0) {
|
||||
for (const layer of layers) {
|
||||
if (layer.nodeIds.some((nid) => tourHighlightedNodeIds.includes(nid))) {
|
||||
tourLayerIds.add(layer.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create cluster nodes
|
||||
const clusterNodes: LayerClusterFlowNode[] = layers.map((layer, i) => {
|
||||
// Compute aggregate complexity from member nodes
|
||||
const memberNodes = graph.nodes.filter((n) => layer.nodeIds.includes(n.id));
|
||||
const complexCounts = { simple: 0, moderate: 0, complex: 0 };
|
||||
for (const n of memberNodes) {
|
||||
complexCounts[n.complexity]++;
|
||||
}
|
||||
const aggregateComplexity =
|
||||
complexCounts.complex > memberNodes.length * 0.3
|
||||
? "complex"
|
||||
: complexCounts.moderate > memberNodes.length * 0.3
|
||||
? "moderate"
|
||||
: "simple";
|
||||
|
||||
return {
|
||||
id: layer.id,
|
||||
type: "layer-cluster" as const,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
layerId: layer.id,
|
||||
layerName: layer.name,
|
||||
layerDescription: layer.description,
|
||||
fileCount: layer.nodeIds.length,
|
||||
aggregateComplexity,
|
||||
layerColorIndex: i,
|
||||
searchMatchCount: searchMatchByLayer.get(layer.id),
|
||||
onDrillIn: drillIntoLayer,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Aggregate edges between layers
|
||||
const aggregated = aggregateLayerEdges(graph);
|
||||
const flowEdges: Edge[] = aggregated.map((agg, i) => ({
|
||||
id: `le-${i}`,
|
||||
source: agg.sourceLayerId,
|
||||
target: agg.targetLayerId,
|
||||
label: `${agg.count}`,
|
||||
style: {
|
||||
stroke: "rgba(212,165,116,0.4)",
|
||||
strokeWidth: Math.min(1 + Math.log2(agg.count + 1), 5),
|
||||
},
|
||||
labelStyle: { fill: "#a39787", fontSize: 11, fontWeight: 600 },
|
||||
}));
|
||||
|
||||
// Layout with cluster dimensions
|
||||
const dims = new Map<string, { width: number; height: number }>();
|
||||
for (const n of clusterNodes) {
|
||||
dims.set(n.id, { width: LAYER_CLUSTER_WIDTH, height: LAYER_CLUSTER_HEIGHT });
|
||||
}
|
||||
const laid = applyDagreLayout(clusterNodes as unknown as Node[], flowEdges, "TB", dims);
|
||||
return { nodes: laid.nodes, edges: laid.edges };
|
||||
}, [graph, searchResults, drillIntoLayer, tourHighlightedNodeIds]);
|
||||
}
|
||||
|
||||
// ── Layer detail level: files + portal nodes ───────────────────────────
|
||||
|
||||
function useLayerDetailGraph() {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
|
||||
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
|
||||
const searchResults = useDashboardStore((s) => s.searchResults);
|
||||
const selectNode = useDashboardStore((s) => s.selectNode);
|
||||
const openCodeViewer = useDashboardStore((s) => s.openCodeViewer);
|
||||
const showLayers = useDashboardStore((s) => s.showLayers);
|
||||
const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds);
|
||||
const persona = useDashboardStore((s) => s.persona);
|
||||
const diffMode = useDashboardStore((s) => s.diffMode);
|
||||
const changedNodeIds = useDashboardStore((s) => s.changedNodeIds);
|
||||
const affectedNodeIds = useDashboardStore((s) => s.affectedNodeIds);
|
||||
const focusNodeId = useDashboardStore((s) => s.focusNodeId);
|
||||
const setFocusNode = useDashboardStore((s) => s.setFocusNode);
|
||||
const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
|
||||
|
||||
const handleNodeSelect = useCallback(
|
||||
(nodeId: string) => {
|
||||
selectNode(nodeId);
|
||||
openCodeViewer(nodeId);
|
||||
},
|
||||
[selectNode, openCodeViewer],
|
||||
[selectNode],
|
||||
);
|
||||
|
||||
const { initialNodes, initialEdges } = useMemo(() => {
|
||||
if (!graph)
|
||||
return {
|
||||
initialNodes: [] as (CustomFlowNode | Node)[],
|
||||
initialEdges: [] as Edge[],
|
||||
};
|
||||
return useMemo(() => {
|
||||
if (!graph || !activeLayerId)
|
||||
return { nodes: [] as Node[], edges: [] as Edge[] };
|
||||
|
||||
// Filter nodes and edges based on persona
|
||||
let filteredGraphNodes =
|
||||
persona === "non-technical"
|
||||
? graph.nodes.filter(
|
||||
(n) =>
|
||||
n.type === "concept" || n.type === "module" || n.type === "file",
|
||||
)
|
||||
: graph.nodes;
|
||||
const activeLayer = graph.layers.find((l) => l.id === activeLayerId);
|
||||
if (!activeLayer) return { nodes: [] as Node[], edges: [] as Edge[] };
|
||||
|
||||
const layerNodeIds = new Set(activeLayer.nodeIds);
|
||||
|
||||
// Filter to file nodes in this layer
|
||||
let filteredGraphNodes = graph.nodes.filter(
|
||||
(n) => layerNodeIds.has(n.id) && n.type === "file",
|
||||
);
|
||||
|
||||
// Persona filtering
|
||||
if (persona === "non-technical") {
|
||||
filteredGraphNodes = filteredGraphNodes.filter(
|
||||
(n) => n.type === "concept" || n.type === "module" || n.type === "file",
|
||||
);
|
||||
}
|
||||
|
||||
let filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
|
||||
let filteredGraphEdges =
|
||||
persona === "non-technical"
|
||||
? graph.edges.filter(
|
||||
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
|
||||
)
|
||||
: graph.edges;
|
||||
|
||||
// Focus mode: filter to 1-hop neighborhood of the focused node
|
||||
// Intra-layer edges only
|
||||
let filteredGraphEdges = graph.edges.filter(
|
||||
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
|
||||
);
|
||||
|
||||
// Focus mode: 1-hop neighborhood within the layer
|
||||
if (focusNodeId && filteredNodeIds.has(focusNodeId)) {
|
||||
const focusNeighborIds = new Set<string>([focusNodeId]);
|
||||
for (const edge of filteredGraphEdges) {
|
||||
if (edge.source === focusNodeId) focusNeighborIds.add(edge.target);
|
||||
if (edge.target === focusNodeId) focusNeighborIds.add(edge.source);
|
||||
}
|
||||
filteredGraphNodes = filteredGraphNodes.filter((n) => focusNeighborIds.has(n.id));
|
||||
filteredGraphNodes = filteredGraphNodes.filter((n) =>
|
||||
focusNeighborIds.has(n.id),
|
||||
);
|
||||
filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
|
||||
filteredGraphEdges = filteredGraphEdges.filter(
|
||||
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
|
||||
);
|
||||
}
|
||||
|
||||
// Compute neighbor set for selection-based highlighting
|
||||
// Neighbor set for selection highlighting
|
||||
const neighborNodeIds = new Set<string>();
|
||||
if (selectedNodeId) {
|
||||
for (const edge of filteredGraphEdges) {
|
||||
@@ -93,6 +216,7 @@ export default function GraphView() {
|
||||
neighborNodeIds.add(selectedNodeId);
|
||||
}
|
||||
|
||||
// Build file flow nodes
|
||||
const flowNodes: CustomFlowNode[] = filteredGraphNodes.map((node) => {
|
||||
const matchResult = searchResults.find((r) => r.nodeId === node.id);
|
||||
const hasSelection = !!selectedNodeId;
|
||||
@@ -111,22 +235,32 @@ export default function GraphView() {
|
||||
isTourHighlighted: tourHighlightedNodeIds.includes(node.id),
|
||||
isDiffChanged: diffMode && changedNodeIds.has(node.id),
|
||||
isDiffAffected: diffMode && affectedNodeIds.has(node.id),
|
||||
isDiffFaded: diffMode && !changedNodeIds.has(node.id) && !affectedNodeIds.has(node.id),
|
||||
isNeighbor: hasSelection && neighborNodeIds.has(node.id) && selectedNodeId !== node.id,
|
||||
isDiffFaded:
|
||||
diffMode &&
|
||||
!changedNodeIds.has(node.id) &&
|
||||
!affectedNodeIds.has(node.id),
|
||||
isNeighbor:
|
||||
hasSelection &&
|
||||
neighborNodeIds.has(node.id) &&
|
||||
selectedNodeId !== node.id,
|
||||
isSelectionFaded: hasSelection && !neighborNodeIds.has(node.id),
|
||||
onNodeClick: handleNodeSelect,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const diffNodeIds = diffMode ? new Set([...changedNodeIds, ...affectedNodeIds]) : new Set<string>();
|
||||
// Build diff-aware edges
|
||||
const diffNodeIds = diffMode
|
||||
? new Set([...changedNodeIds, ...affectedNodeIds])
|
||||
: new Set<string>();
|
||||
const flowEdges: Edge[] = filteredGraphEdges.map((edge, i) => {
|
||||
const sourceInDiff = diffMode && diffNodeIds.has(edge.source);
|
||||
const targetInDiff = diffMode && diffNodeIds.has(edge.target);
|
||||
const isImpacted = diffMode && (sourceInDiff || targetInDiff);
|
||||
|
||||
// Selection-based edge highlighting
|
||||
const isSelectedEdge = !!selectedNodeId && (edge.source === selectedNodeId || edge.target === selectedNodeId);
|
||||
const isSelectedEdge =
|
||||
!!selectedNodeId &&
|
||||
(edge.source === selectedNodeId || edge.target === selectedNodeId);
|
||||
const hasSelection = !!selectedNodeId;
|
||||
|
||||
let edgeStyle: React.CSSProperties;
|
||||
@@ -135,9 +269,10 @@ export default function GraphView() {
|
||||
|
||||
if (isImpacted) {
|
||||
edgeStyle = {
|
||||
stroke: sourceInDiff && targetInDiff
|
||||
? "rgba(224, 82, 82, 0.7)"
|
||||
: "rgba(212, 160, 48, 0.5)",
|
||||
stroke:
|
||||
sourceInDiff && targetInDiff
|
||||
? "rgba(224, 82, 82, 0.7)"
|
||||
: "rgba(212, 160, 48, 0.5)",
|
||||
strokeWidth: 2.5,
|
||||
};
|
||||
edgeLabelStyle = { fill: "#a39787", fontSize: 10 };
|
||||
@@ -171,105 +306,259 @@ export default function GraphView() {
|
||||
};
|
||||
});
|
||||
|
||||
// Run dagre layout on all nodes (without groups)
|
||||
const laid = applyDagreLayout(flowNodes, flowEdges);
|
||||
const laidNodes = laid.nodes as CustomFlowNode[];
|
||||
// Portal nodes for connected external layers
|
||||
const portals = computePortals(graph, activeLayerId);
|
||||
const layerIndexMap = new Map(graph.layers.map((l, i) => [l.id, i]));
|
||||
|
||||
const layers = graph.layers ?? [];
|
||||
if (!showLayers || layers.length === 0) {
|
||||
return { initialNodes: laidNodes, initialEdges: laid.edges };
|
||||
}
|
||||
const portalNodes: PortalFlowNode[] = portals.map((portal) => ({
|
||||
id: `portal:${portal.layerId}`,
|
||||
type: "portal" as const,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
targetLayerId: portal.layerId,
|
||||
targetLayerName: portal.layerName,
|
||||
connectionCount: portal.connectionCount,
|
||||
layerColorIndex: layerIndexMap.get(portal.layerId) ?? 0,
|
||||
onNavigate: drillIntoLayer,
|
||||
},
|
||||
}));
|
||||
|
||||
// Build a map of nodeId -> layer for quick lookup
|
||||
const nodeToLayer = new Map<string, string>();
|
||||
for (const layer of layers) {
|
||||
for (const nodeId of layer.nodeIds) {
|
||||
nodeToLayer.set(nodeId, layer.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Create group nodes and adjust member positions
|
||||
const groupNodes: Node[] = [];
|
||||
const adjustedNodes: (CustomFlowNode | Node)[] = [];
|
||||
|
||||
for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) {
|
||||
const layer = layers[layerIdx];
|
||||
const memberNodes = laidNodes.filter((n) =>
|
||||
layer.nodeIds.includes(n.id),
|
||||
// Connect portal nodes to the file nodes that have cross-layer edges
|
||||
const portalEdges: Edge[] = [];
|
||||
let portalEdgeIdx = flowEdges.length;
|
||||
for (const portal of portals) {
|
||||
const crossFiles = findCrossLayerFileNodes(
|
||||
graph,
|
||||
activeLayerId,
|
||||
portal.layerId,
|
||||
);
|
||||
|
||||
if (memberNodes.length === 0) continue;
|
||||
|
||||
// Compute bounding box around member nodes
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const node of memberNodes) {
|
||||
const x = node.position.x;
|
||||
const y = node.position.y;
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x + NODE_WIDTH);
|
||||
maxY = Math.max(maxY, y + NODE_HEIGHT);
|
||||
}
|
||||
|
||||
// Group node position = top-left with padding
|
||||
const groupX = minX - LAYER_PADDING;
|
||||
const groupY = minY - LAYER_PADDING - 24; // extra space for label
|
||||
const groupWidth = maxX - minX + LAYER_PADDING * 2;
|
||||
const groupHeight = maxY - minY + LAYER_PADDING * 2 + 24;
|
||||
|
||||
// Create the group node with distinct color per layer
|
||||
const layerColor = getLayerColor(layerIdx);
|
||||
groupNodes.push({
|
||||
id: layer.id,
|
||||
type: "group",
|
||||
position: { x: groupX, y: groupY },
|
||||
data: { label: layer.name },
|
||||
style: {
|
||||
width: groupWidth,
|
||||
height: groupHeight,
|
||||
backgroundColor: layerColor.bg,
|
||||
borderRadius: 12,
|
||||
border: `2px solid ${layerColor.border}`,
|
||||
padding: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: layerColor.label,
|
||||
},
|
||||
});
|
||||
|
||||
// Adjust member node positions to be relative to the group
|
||||
for (const node of memberNodes) {
|
||||
adjustedNodes.push({
|
||||
...node,
|
||||
parentId: layer.id,
|
||||
extent: "parent" as const,
|
||||
position: {
|
||||
x: node.position.x - groupX,
|
||||
y: node.position.y - groupY,
|
||||
},
|
||||
});
|
||||
for (const fileId of crossFiles) {
|
||||
if (filteredNodeIds.has(fileId)) {
|
||||
portalEdges.push({
|
||||
id: `e-${portalEdgeIdx++}`,
|
||||
source: fileId,
|
||||
target: `portal:${portal.layerId}`,
|
||||
style: { stroke: "rgba(212,165,116,0.2)", strokeWidth: 1, strokeDasharray: "4 4" },
|
||||
animated: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add nodes that are not in any layer (keep original positions)
|
||||
for (const node of laidNodes) {
|
||||
if (!nodeToLayer.has(node.id)) {
|
||||
adjustedNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Group nodes must come before their children in the array
|
||||
const allNodes: (CustomFlowNode | Node)[] = [
|
||||
...groupNodes,
|
||||
...adjustedNodes,
|
||||
// Layout with mixed dimensions
|
||||
const allFlowNodes: Node[] = [
|
||||
...(flowNodes as unknown as Node[]),
|
||||
...(portalNodes as unknown as Node[]),
|
||||
];
|
||||
const allFlowEdges = [...flowEdges, ...portalEdges];
|
||||
|
||||
return { initialNodes: allNodes, initialEdges: laid.edges };
|
||||
}, [graph, searchResults, selectedNodeId, showLayers, tourHighlightedNodeIds, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds, focusNodeId]);
|
||||
const dims = new Map<string, { width: number; height: number }>();
|
||||
for (const n of flowNodes) {
|
||||
dims.set(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
}
|
||||
for (const n of portalNodes) {
|
||||
dims.set(n.id, { width: PORTAL_NODE_WIDTH, height: PORTAL_NODE_HEIGHT });
|
||||
}
|
||||
|
||||
const laid = applyDagreLayout(allFlowNodes, allFlowEdges, "TB", dims);
|
||||
return { nodes: laid.nodes, edges: laid.edges };
|
||||
}, [
|
||||
graph,
|
||||
activeLayerId,
|
||||
selectedNodeId,
|
||||
searchResults,
|
||||
tourHighlightedNodeIds,
|
||||
persona,
|
||||
handleNodeSelect,
|
||||
diffMode,
|
||||
changedNodeIds,
|
||||
affectedNodeIds,
|
||||
focusNodeId,
|
||||
drillIntoLayer,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Flow (swim-lane) view: all layers as columns ──────────────────────
|
||||
|
||||
function useFlowViewGraph() {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
|
||||
const searchResults = useDashboardStore((s) => s.searchResults);
|
||||
const selectNode = useDashboardStore((s) => s.selectNode);
|
||||
const tourHighlightedNodeIds = useDashboardStore(
|
||||
(s) => s.tourHighlightedNodeIds,
|
||||
);
|
||||
const diffMode = useDashboardStore((s) => s.diffMode);
|
||||
const changedNodeIds = useDashboardStore((s) => s.changedNodeIds);
|
||||
const affectedNodeIds = useDashboardStore((s) => s.affectedNodeIds);
|
||||
|
||||
const handleNodeSelect = useCallback(
|
||||
(nodeId: string) => {
|
||||
selectNode(nodeId);
|
||||
},
|
||||
[selectNode],
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!graph || graph.layers.length === 0)
|
||||
return { nodes: [] as Node[], edges: [] as Edge[] };
|
||||
|
||||
// Build all file nodes across all layers
|
||||
const allLayerNodeIds = new Set(
|
||||
graph.layers.flatMap((l) => l.nodeIds),
|
||||
);
|
||||
|
||||
const fileGraphNodes = graph.nodes.filter(
|
||||
(n) => n.type === "file" && allLayerNodeIds.has(n.id),
|
||||
);
|
||||
|
||||
// Neighbor set for selection highlighting
|
||||
const fileNodeIds = new Set(fileGraphNodes.map((n) => n.id));
|
||||
const neighborNodeIds = new Set<string>();
|
||||
if (selectedNodeId) {
|
||||
for (const edge of graph.edges) {
|
||||
if (edge.source === selectedNodeId && fileNodeIds.has(edge.target))
|
||||
neighborNodeIds.add(edge.target);
|
||||
if (edge.target === selectedNodeId && fileNodeIds.has(edge.source))
|
||||
neighborNodeIds.add(edge.source);
|
||||
}
|
||||
neighborNodeIds.add(selectedNodeId);
|
||||
}
|
||||
|
||||
const diffNodeIds = diffMode
|
||||
? new Set([...changedNodeIds, ...affectedNodeIds])
|
||||
: new Set<string>();
|
||||
|
||||
const flowNodes: CustomFlowNode[] = fileGraphNodes.map((node) => {
|
||||
const matchResult = searchResults.find((r) => r.nodeId === node.id);
|
||||
const hasSelection = !!selectedNodeId;
|
||||
return {
|
||||
id: node.id,
|
||||
type: "custom" as const,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: node.name ?? node.filePath?.split("/").pop() ?? node.id,
|
||||
nodeType: node.type,
|
||||
summary: node.summary,
|
||||
complexity: node.complexity,
|
||||
isHighlighted: !!matchResult,
|
||||
searchScore: matchResult?.score,
|
||||
isSelected: selectedNodeId === node.id,
|
||||
isTourHighlighted: tourHighlightedNodeIds.includes(node.id),
|
||||
isDiffChanged: diffMode && changedNodeIds.has(node.id),
|
||||
isDiffAffected: diffMode && affectedNodeIds.has(node.id),
|
||||
isDiffFaded:
|
||||
diffMode &&
|
||||
!changedNodeIds.has(node.id) &&
|
||||
!affectedNodeIds.has(node.id),
|
||||
isNeighbor:
|
||||
hasSelection &&
|
||||
neighborNodeIds.has(node.id) &&
|
||||
selectedNodeId !== node.id,
|
||||
isSelectionFaded: hasSelection && !neighborNodeIds.has(node.id),
|
||||
onNodeClick: handleNodeSelect,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Build cross-lane edges (only between file nodes that are in layers)
|
||||
const flowEdges: Edge[] = [];
|
||||
let edgeIdx = 0;
|
||||
for (const edge of graph.edges) {
|
||||
if (!fileNodeIds.has(edge.source) || !fileNodeIds.has(edge.target))
|
||||
continue;
|
||||
|
||||
const isSelectedEdge =
|
||||
!!selectedNodeId &&
|
||||
(edge.source === selectedNodeId || edge.target === selectedNodeId);
|
||||
const hasSelection = !!selectedNodeId;
|
||||
const sourceInDiff = diffMode && diffNodeIds.has(edge.source);
|
||||
const targetInDiff = diffMode && diffNodeIds.has(edge.target);
|
||||
const isImpacted = diffMode && (sourceInDiff || targetInDiff);
|
||||
|
||||
let edgeStyle: React.CSSProperties;
|
||||
let edgeLabelStyle: React.CSSProperties;
|
||||
let edgeAnimated: boolean;
|
||||
|
||||
if (isImpacted) {
|
||||
edgeStyle = {
|
||||
stroke:
|
||||
sourceInDiff && targetInDiff
|
||||
? "rgba(224, 82, 82, 0.7)"
|
||||
: "rgba(212, 160, 48, 0.5)",
|
||||
strokeWidth: 2.5,
|
||||
};
|
||||
edgeLabelStyle = { fill: "#a39787", fontSize: 10 };
|
||||
edgeAnimated = true;
|
||||
} else if (diffMode) {
|
||||
edgeStyle = { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 };
|
||||
edgeLabelStyle = { fill: "rgba(163,151,135,0.3)", fontSize: 10 };
|
||||
edgeAnimated = false;
|
||||
} else if (isSelectedEdge) {
|
||||
edgeStyle = { stroke: "rgba(212,165,116,0.8)", strokeWidth: 2.5 };
|
||||
edgeLabelStyle = { fill: "#d4a574", fontSize: 11, fontWeight: 600 };
|
||||
edgeAnimated = true;
|
||||
} else if (hasSelection) {
|
||||
edgeStyle = { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 };
|
||||
edgeLabelStyle = { fill: "rgba(163,151,135,0.2)", fontSize: 10 };
|
||||
edgeAnimated = false;
|
||||
} else {
|
||||
edgeStyle = { stroke: "rgba(212,165,116,0.25)", strokeWidth: 1 };
|
||||
edgeLabelStyle = { fill: "#a39787", fontSize: 9 };
|
||||
edgeAnimated = false;
|
||||
}
|
||||
|
||||
flowEdges.push({
|
||||
id: `fe-${edgeIdx++}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
label: edge.type,
|
||||
animated: edgeAnimated,
|
||||
style: edgeStyle,
|
||||
labelStyle: edgeLabelStyle,
|
||||
});
|
||||
}
|
||||
|
||||
const result = applySwimLaneLayout(
|
||||
graph,
|
||||
flowNodes as unknown as Node[],
|
||||
flowEdges,
|
||||
);
|
||||
return { nodes: result.nodes, edges: result.edges };
|
||||
}, [
|
||||
graph,
|
||||
selectedNodeId,
|
||||
searchResults,
|
||||
tourHighlightedNodeIds,
|
||||
handleNodeSelect,
|
||||
diffMode,
|
||||
changedNodeIds,
|
||||
affectedNodeIds,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Main GraphView component ───────────────────────────────────────────
|
||||
|
||||
export default function GraphView() {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const navigationLevel = useDashboardStore((s) => s.navigationLevel);
|
||||
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
|
||||
const viewMode = useDashboardStore((s) => s.viewMode);
|
||||
const selectNode = useDashboardStore((s) => s.selectNode);
|
||||
const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
|
||||
const focusNodeId = useDashboardStore((s) => s.focusNodeId);
|
||||
const setFocusNode = useDashboardStore((s) => s.setFocusNode);
|
||||
|
||||
const overviewGraph = useOverviewGraph();
|
||||
const detailGraph = useLayerDetailGraph();
|
||||
const flowGraph = useFlowViewGraph();
|
||||
|
||||
const { nodes: initialNodes, edges: initialEdges } =
|
||||
viewMode === "flow"
|
||||
? flowGraph
|
||||
: navigationLevel === "overview"
|
||||
? overviewGraph
|
||||
: detailGraph;
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
@@ -284,28 +573,51 @@ export default function GraphView() {
|
||||
setEdges(initialEdges);
|
||||
}, [initialEdges, setEdges]);
|
||||
|
||||
// Fit view on level/layer/view-mode transitions
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fitView({ duration: 400, padding: 0.2 });
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [navigationLevel, activeLayerId, viewMode, fitView]);
|
||||
|
||||
// Zoom-to-node when navigateToNode is called
|
||||
const zoomToNodeId = useDashboardStore((s) => s.zoomToNodeId);
|
||||
useEffect(() => {
|
||||
if (zoomToNodeId) {
|
||||
// Small delay to let React Flow update node positions first
|
||||
const timer = setTimeout(() => {
|
||||
fitView({ nodes: [{ id: zoomToNodeId }], duration: 400, padding: 0.5 });
|
||||
fitView({
|
||||
nodes: [{ id: zoomToNodeId }],
|
||||
duration: 400,
|
||||
padding: 0.5,
|
||||
});
|
||||
useDashboardStore.setState({ zoomToNodeId: null });
|
||||
}, 50);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [zoomToNodeId, fitView]);
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(_: React.MouseEvent, node: { id: string }) => {
|
||||
// Ignore clicks on group nodes
|
||||
const isGroupNode = graph?.layers?.some((l) => l.id === node.id);
|
||||
if (isGroupNode) return;
|
||||
selectNode(node.id);
|
||||
openCodeViewer(node.id);
|
||||
// In flow view, all clicks are selections (no drill-in)
|
||||
// Ignore clicks on lane group nodes
|
||||
if (viewMode === "flow") {
|
||||
if (node.id.startsWith("lane:")) return;
|
||||
selectNode(node.id);
|
||||
return;
|
||||
}
|
||||
if (navigationLevel === "overview") {
|
||||
// At overview, clicking a layer cluster drills in
|
||||
drillIntoLayer(node.id);
|
||||
} else if (node.id.startsWith("portal:")) {
|
||||
// Portal nodes navigate to that layer
|
||||
const targetLayerId = node.id.replace("portal:", "");
|
||||
drillIntoLayer(targetLayerId);
|
||||
} else {
|
||||
selectNode(node.id);
|
||||
}
|
||||
},
|
||||
[selectNode, openCodeViewer, graph],
|
||||
[viewMode, navigationLevel, drillIntoLayer, selectNode],
|
||||
);
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
@@ -322,8 +634,9 @@ export default function GraphView() {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative">
|
||||
{focusNodeId && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10">
|
||||
<Breadcrumb />
|
||||
{focusNodeId && navigationLevel === "layer-detail" && (
|
||||
<div className="absolute top-14 left-1/2 -translate-x-1/2 z-10">
|
||||
<button
|
||||
onClick={() => setFocusNode(null)}
|
||||
className="px-4 py-2 rounded-full bg-elevated border border-gold/30 text-gold text-xs font-semibold tracking-wider uppercase hover:bg-gold/10 transition-colors flex items-center gap-2 shadow-lg"
|
||||
@@ -348,7 +661,12 @@ export default function GraphView() {
|
||||
panOnScroll
|
||||
colorMode="dark"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} color="rgba(212,165,116,0.15)" gap={20} size={1} />
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
color="rgba(212,165,116,0.15)"
|
||||
gap={20}
|
||||
size={1}
|
||||
/>
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor="#1a1a1a"
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { NodeProps, Node } from "@xyflow/react";
|
||||
import { getLayerColor } from "./LayerLegend";
|
||||
|
||||
const complexityColors: Record<string, string> = {
|
||||
simple: "text-node-function",
|
||||
moderate: "text-gold-dim",
|
||||
complex: "text-[#c97070]",
|
||||
};
|
||||
|
||||
export interface LayerClusterData extends Record<string, unknown> {
|
||||
layerId: string;
|
||||
layerName: string;
|
||||
layerDescription: string;
|
||||
fileCount: number;
|
||||
aggregateComplexity: string;
|
||||
layerColorIndex: number;
|
||||
searchMatchCount?: number;
|
||||
onDrillIn: (layerId: string) => void;
|
||||
}
|
||||
|
||||
export type LayerClusterFlowNode = Node<LayerClusterData, "layer-cluster">;
|
||||
|
||||
export default function LayerClusterNode({
|
||||
data,
|
||||
}: NodeProps<LayerClusterFlowNode>) {
|
||||
const color = getLayerColor(data.layerColorIndex);
|
||||
const complexityColor =
|
||||
complexityColors[data.aggregateComplexity] ?? complexityColors.simple;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-xl bg-elevated border border-border-subtle overflow-hidden cursor-pointer transition-all duration-200 hover:border-gold/40 hover:shadow-lg group"
|
||||
style={{
|
||||
width: 300,
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
onClick={() => data.onDrillIn(data.layerId)}
|
||||
>
|
||||
{/* Left color bar */}
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-1.5 rounded-l-xl"
|
||||
style={{ backgroundColor: color.label }}
|
||||
/>
|
||||
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!bg-text-muted !w-2 !h-2"
|
||||
/>
|
||||
|
||||
<div className="pl-5 pr-4 py-4">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span
|
||||
className="text-[10px] font-semibold uppercase tracking-wider"
|
||||
style={{ color: color.label }}
|
||||
>
|
||||
Layer
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{data.searchMatchCount != null && data.searchMatchCount > 0 && (
|
||||
<span className="text-[10px] font-mono bg-gold/20 text-gold px-1.5 py-0.5 rounded">
|
||||
{data.searchMatchCount} match{data.searchMatchCount !== 1 ? "es" : ""}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[10px] font-mono ${complexityColor}`}>
|
||||
{data.aggregateComplexity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layer name */}
|
||||
<div className="text-lg font-serif text-text-primary mb-1">
|
||||
{data.layerName}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="text-[11px] text-text-secondary line-clamp-2 leading-tight mb-3">
|
||||
{data.layerDescription}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{data.fileCount} file{data.fileCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Click to explore →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!bg-text-muted !w-2 !h-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useDashboardStore } from "../store";
|
||||
|
||||
// Shared layer color palette — used by both LayerLegend dots and GraphView group nodes
|
||||
// Shared layer color palette — used by LayerLegend, LayerClusterNode, PortalNode, and GraphView
|
||||
export const LAYER_PALETTE = [
|
||||
{ bg: "rgba(74, 124, 155, 0.12)", border: "rgba(74, 124, 155, 0.4)", label: "#4a7c9b" }, // blue (API)
|
||||
{ bg: "rgba(90, 158, 111, 0.12)", border: "rgba(90, 158, 111, 0.4)", label: "#5a9e6f" }, // green (Data)
|
||||
@@ -17,56 +17,54 @@ export function getLayerColor(index: number) {
|
||||
|
||||
export default function LayerLegend() {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const showLayers = useDashboardStore((s) => s.showLayers);
|
||||
const toggleLayers = useDashboardStore((s) => s.toggleLayers);
|
||||
const navigationLevel = useDashboardStore((s) => s.navigationLevel);
|
||||
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
|
||||
|
||||
const layers = graph?.layers ?? [];
|
||||
const hasLayers = layers.length > 0;
|
||||
|
||||
if (!hasLayers) return null;
|
||||
|
||||
const activeLayer = layers.find((l) => l.id === activeLayerId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleLayers}
|
||||
disabled={!hasLayers}
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors ${
|
||||
showLayers && hasLayers
|
||||
? "bg-gold/20 text-gold"
|
||||
: hasLayers
|
||||
? "bg-elevated text-text-secondary hover:bg-surface"
|
||||
: "bg-elevated text-text-muted cursor-not-allowed"
|
||||
}`}
|
||||
title={
|
||||
hasLayers
|
||||
? showLayers
|
||||
? "Hide layer grouping"
|
||||
: "Show layer grouping"
|
||||
: "No layers in graph"
|
||||
}
|
||||
>
|
||||
Layers {showLayers && hasLayers ? "ON" : "OFF"}
|
||||
</button>
|
||||
<span className="text-[11px] font-medium text-text-secondary">
|
||||
{navigationLevel === "overview"
|
||||
? `${layers.length} layers`
|
||||
: activeLayer?.name ?? "Layer"}
|
||||
</span>
|
||||
|
||||
{showLayers && hasLayers && (
|
||||
<div className="flex items-center gap-3">
|
||||
{layers.map((layer, i) => {
|
||||
const color = getLayerColor(i);
|
||||
return (
|
||||
<div key={layer.id} className="flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: color.label }}
|
||||
/>
|
||||
<span className="text-text-secondary text-[11px]">
|
||||
{layer.name}
|
||||
<span className="text-text-muted ml-0.5">
|
||||
({layer.nodeIds.length})
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{layers.map((layer, i) => {
|
||||
const color = getLayerColor(i);
|
||||
const isActive = navigationLevel === "layer-detail" && layer.id === activeLayerId;
|
||||
return (
|
||||
<div key={layer.id} className="flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color.label,
|
||||
opacity: navigationLevel === "layer-detail" && !isActive ? 0.3 : 1,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`text-[11px] ${
|
||||
isActive ? "text-text-primary font-medium" : "text-text-secondary"
|
||||
}`}
|
||||
style={{
|
||||
opacity: navigationLevel === "layer-detail" && !isActive ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{layer.name}
|
||||
<span className="text-text-muted ml-0.5">
|
||||
({layer.nodeIds.length})
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,17 +15,72 @@ const complexityBadgeColors: Record<string, string> = {
|
||||
complex: "text-[#c97070] border border-[#c97070]/30 bg-[#c97070]/10",
|
||||
};
|
||||
|
||||
/**
|
||||
* Human-readable directional labels for edge types.
|
||||
* Returns different text depending on whether the selected node is
|
||||
* the source or target of the edge.
|
||||
*/
|
||||
function getDirectionalLabel(edgeType: string, isSource: boolean): string {
|
||||
switch (edgeType) {
|
||||
case "imports":
|
||||
return isSource ? "imports" : "imported by";
|
||||
case "exports":
|
||||
return isSource ? "exports to" : "exported by";
|
||||
case "contains":
|
||||
return isSource ? "contains" : "contained in";
|
||||
case "inherits":
|
||||
return isSource ? "inherits from" : "inherited by";
|
||||
case "implements":
|
||||
return isSource ? "implements" : "implemented by";
|
||||
case "calls":
|
||||
return isSource ? "calls" : "called by";
|
||||
case "subscribes":
|
||||
return isSource ? "subscribes to" : "subscribed by";
|
||||
case "publishes":
|
||||
return isSource ? "publishes to" : "consumed by";
|
||||
case "middleware":
|
||||
return isSource ? "middleware for" : "uses middleware";
|
||||
case "reads_from":
|
||||
return isSource ? "reads from" : "read by";
|
||||
case "writes_to":
|
||||
return isSource ? "writes to" : "written by";
|
||||
case "transforms":
|
||||
return isSource ? "transforms" : "transformed by";
|
||||
case "validates":
|
||||
return isSource ? "validates" : "validated by";
|
||||
case "depends_on":
|
||||
return isSource ? "depends on" : "depended on by";
|
||||
case "tested_by":
|
||||
return isSource ? "tested by" : "tests";
|
||||
case "configures":
|
||||
return isSource ? "configures" : "configured by";
|
||||
case "related":
|
||||
return "related to";
|
||||
case "similar_to":
|
||||
return "similar to";
|
||||
default:
|
||||
return isSource ? edgeType : `${edgeType} (reverse)`;
|
||||
}
|
||||
}
|
||||
|
||||
export default function NodeInfo() {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
|
||||
const nodeHistory = useDashboardStore((s) => s.nodeHistory);
|
||||
const goBackNode = useDashboardStore((s) => s.goBackNode);
|
||||
const [languageExpanded, setLanguageExpanded] = useState(true);
|
||||
|
||||
const navigateToNode = useDashboardStore((s) => s.navigateToNode);
|
||||
const openCodeViewer = useDashboardStore((s) => s.openCodeViewer);
|
||||
const setFocusNode = useDashboardStore((s) => s.setFocusNode);
|
||||
const focusNodeId = useDashboardStore((s) => s.focusNodeId);
|
||||
const node = graph?.nodes.find((n) => n.id === selectedNodeId) ?? null;
|
||||
|
||||
// Resolve history node names for the breadcrumb trail
|
||||
const historyNodes = nodeHistory.map((id) => {
|
||||
const n = graph?.nodes.find((gn) => gn.id === id);
|
||||
return { id, name: n?.name ?? id };
|
||||
});
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center bg-surface">
|
||||
@@ -34,16 +89,82 @@ export default function NodeInfo() {
|
||||
);
|
||||
}
|
||||
|
||||
const connections = (graph?.edges ?? []).filter(
|
||||
const allEdges = graph?.edges ?? [];
|
||||
const connections = allEdges.filter(
|
||||
(e) => e.source === node.id || e.target === node.id,
|
||||
);
|
||||
|
||||
// Separate child nodes (contained IN this file) from other connections
|
||||
const childEdges = connections.filter(
|
||||
(e) => e.type === "contains" && e.source === node.id,
|
||||
);
|
||||
const otherConnections = connections.filter(
|
||||
(e) => !(e.type === "contains" && e.source === node.id),
|
||||
);
|
||||
|
||||
// Resolve child nodes
|
||||
const childNodes = childEdges
|
||||
.map((e) => graph?.nodes.find((n) => n.id === e.target))
|
||||
.filter(Boolean);
|
||||
|
||||
const typeBadge = typeBadgeColors[node.type] ?? typeBadgeColors.file;
|
||||
const complexityBadge =
|
||||
complexityBadgeColors[node.complexity] ?? complexityBadgeColors.simple;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto p-5 animate-fade-slide-in">
|
||||
{/* Navigation history trail */}
|
||||
{historyNodes.length > 0 && (
|
||||
<div className="mb-3 flex items-center gap-1 flex-wrap">
|
||||
<button
|
||||
onClick={goBackNode}
|
||||
className="text-[10px] font-semibold text-gold hover:text-gold-bright transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<span className="text-text-muted text-[10px]">│</span>
|
||||
{historyNodes.slice(-3).map((h, i, arr) => (
|
||||
<span key={`${h.id}-${i}`} className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Navigate back to this point in history
|
||||
const fullIdx = historyNodes.length - arr.length + i;
|
||||
// Pop history back to this point and navigate
|
||||
const targetId = historyNodes[fullIdx].id;
|
||||
// Use navigateToNode which will push current to history,
|
||||
// but we want to rewind. Use goBackNode repeatedly would be clunky,
|
||||
// so we directly set state.
|
||||
const newHistory = nodeHistory.slice(0, fullIdx);
|
||||
const layerId = graph
|
||||
? graph.layers.find((l) => l.nodeIds.includes(targetId))?.id
|
||||
: null;
|
||||
useDashboardStore.setState({
|
||||
selectedNodeId: targetId,
|
||||
zoomToNodeId: targetId,
|
||||
nodeHistory: newHistory,
|
||||
...(layerId
|
||||
? { navigationLevel: "layer-detail" as const, activeLayerId: layerId }
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
className="text-[10px] text-text-muted hover:text-gold transition-colors truncate max-w-[80px]"
|
||||
title={h.name}
|
||||
>
|
||||
{h.name}
|
||||
</button>
|
||||
{i < arr.length - 1 && (
|
||||
<span className="text-text-muted text-[10px]">›</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-text-muted text-[10px]">›</span>
|
||||
<span className="text-[10px] text-text-primary font-medium truncate max-w-[80px]">
|
||||
{node.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span
|
||||
className={`text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded ${typeBadge}`}
|
||||
@@ -131,16 +252,56 @@ export default function NodeInfo() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connections.length > 0 && (
|
||||
{/* Child classes/functions within this file */}
|
||||
{childNodes.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
|
||||
Defined in this file ({childNodes.length})
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{childNodes.map((child) => {
|
||||
if (!child) return null;
|
||||
const childTypeBadge = typeBadgeColors[child.type] ?? typeBadgeColors.file;
|
||||
const childComplexity = complexityBadgeColors[child.complexity] ?? complexityBadgeColors.simple;
|
||||
return (
|
||||
<div
|
||||
key={child.id}
|
||||
className="text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle cursor-pointer hover:border-gold/40 hover:bg-gold/5 transition-colors"
|
||||
onClick={() => navigateToNode(child.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${childTypeBadge}`}>
|
||||
{child.type}
|
||||
</span>
|
||||
<span className="text-text-primary truncate">{child.name}</span>
|
||||
<span className={`text-[9px] ml-auto ${childComplexity} px-1 py-0.5 rounded`}>
|
||||
{child.complexity}
|
||||
</span>
|
||||
</div>
|
||||
{child.summary && (
|
||||
<p className="text-[11px] text-text-muted mt-1 line-clamp-1 pl-1">
|
||||
{child.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other connections (excluding "contains" children) */}
|
||||
{otherConnections.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
|
||||
Connections ({connections.length})
|
||||
Connections ({otherConnections.length})
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{connections.map((edge, i) => {
|
||||
{otherConnections.map((edge, i) => {
|
||||
const isSource = edge.source === node.id;
|
||||
const otherId = isSource ? edge.target : edge.source;
|
||||
const otherNode = graph?.nodes.find((n) => n.id === otherId);
|
||||
const dirLabel = getDirectionalLabel(edge.type, isSource);
|
||||
const arrow = isSource ? "\u2192" : "\u2190";
|
||||
|
||||
return (
|
||||
@@ -149,11 +310,10 @@ export default function NodeInfo() {
|
||||
className="text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle flex items-center gap-2 cursor-pointer hover:border-gold/40 hover:bg-gold/5 transition-colors"
|
||||
onClick={() => {
|
||||
navigateToNode(otherId);
|
||||
openCodeViewer(otherId);
|
||||
}}
|
||||
>
|
||||
<span className="text-gold font-mono">{arrow}</span>
|
||||
<span className="text-text-muted">{edge.type}</span>
|
||||
<span className="text-text-muted">{dirLabel}</span>
|
||||
<span className="text-text-primary truncate">
|
||||
{otherNode?.name ?? otherId}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { NodeProps, Node } from "@xyflow/react";
|
||||
import { getLayerColor } from "./LayerLegend";
|
||||
|
||||
export interface PortalNodeData extends Record<string, unknown> {
|
||||
targetLayerId: string;
|
||||
targetLayerName: string;
|
||||
connectionCount: number;
|
||||
layerColorIndex: number;
|
||||
onNavigate: (layerId: string) => void;
|
||||
}
|
||||
|
||||
export type PortalFlowNode = Node<PortalNodeData, "portal">;
|
||||
|
||||
export default function PortalNode({
|
||||
data,
|
||||
}: NodeProps<PortalFlowNode>) {
|
||||
const color = getLayerColor(data.layerColorIndex);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-lg bg-elevated/60 overflow-hidden cursor-pointer transition-all duration-200 hover:bg-elevated/80"
|
||||
style={{
|
||||
width: 220,
|
||||
border: `2px dashed ${color.border}`,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
onClick={() => data.onNavigate(data.targetLayerId)}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!bg-text-muted !w-2 !h-2"
|
||||
/>
|
||||
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: color.label }}
|
||||
/>
|
||||
<span className="text-sm text-text-primary truncate">
|
||||
{data.targetLayerName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-text-muted ml-2 shrink-0">→</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-text-muted mt-1 pl-4">
|
||||
{data.connectionCount} connection{data.connectionCount !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!bg-text-muted !w-2 !h-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default function SearchBar() {
|
||||
const searchResults = useDashboardStore((s) => s.searchResults);
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const setSearchQuery = useDashboardStore((s) => s.setSearchQuery);
|
||||
const selectNode = useDashboardStore((s) => s.selectNode);
|
||||
const navigateToNodeInLayer = useDashboardStore((s) => s.navigateToNodeInLayer);
|
||||
const searchMode = useDashboardStore((s) => s.searchMode);
|
||||
const setSearchMode = useDashboardStore((s) => s.setSearchMode);
|
||||
|
||||
@@ -40,10 +40,10 @@ export default function SearchBar() {
|
||||
|
||||
const handleResultClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
selectNode(nodeId);
|
||||
navigateToNodeInLayer(nodeId);
|
||||
setDropdownOpen(false);
|
||||
},
|
||||
[selectNode],
|
||||
[navigateToNodeInLayer],
|
||||
);
|
||||
|
||||
// Close dropdown on Escape
|
||||
@@ -72,7 +72,7 @@ export default function SearchBar() {
|
||||
const showDropdown = dropdownOpen && searchQuery.trim() && topResults.length > 0;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative z-10">
|
||||
<div ref={containerRef} className="relative z-30">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-surface border-b border-border-subtle">
|
||||
<svg
|
||||
className="w-4 h-4 text-text-muted shrink-0"
|
||||
|
||||
@@ -7,6 +7,16 @@ import type {
|
||||
} from "@understand-anything/core/types";
|
||||
|
||||
export type Persona = "non-technical" | "junior" | "experienced";
|
||||
export type NavigationLevel = "overview" | "layer-detail";
|
||||
export type ViewMode = "graph" | "flow";
|
||||
|
||||
/** Find which layer a node belongs to. Returns layerId or null. */
|
||||
function findNodeLayer(graph: KnowledgeGraph, nodeId: string): string | null {
|
||||
for (const layer of graph.layers) {
|
||||
if (layer.nodeIds.includes(nodeId)) return layer.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface DashboardStore {
|
||||
graph: KnowledgeGraph | null;
|
||||
@@ -19,6 +29,13 @@ interface DashboardStore {
|
||||
|
||||
showLayers: boolean;
|
||||
|
||||
// Lens navigation
|
||||
navigationLevel: NavigationLevel;
|
||||
activeLayerId: string | null;
|
||||
|
||||
// View mode: graph (default free-form) or flow (swim-lane)
|
||||
viewMode: ViewMode;
|
||||
|
||||
codeViewerOpen: boolean;
|
||||
codeViewerNodeId: string | null;
|
||||
|
||||
@@ -38,9 +55,17 @@ interface DashboardStore {
|
||||
// Focus mode: isolate a node's 1-hop neighborhood
|
||||
focusNodeId: string | null;
|
||||
|
||||
// Sidebar navigation history (stack of visited node IDs)
|
||||
nodeHistory: string[];
|
||||
|
||||
setGraph: (graph: KnowledgeGraph) => void;
|
||||
selectNode: (nodeId: string | null) => void;
|
||||
navigateToNode: (nodeId: string) => void;
|
||||
navigateToNodeInLayer: (nodeId: string) => void;
|
||||
goBackNode: () => void;
|
||||
drillIntoLayer: (layerId: string) => void;
|
||||
navigateToOverview: () => void;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
setFocusNode: (nodeId: string | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
toggleLayers: () => void;
|
||||
@@ -64,6 +89,22 @@ function getSortedTour(graph: KnowledgeGraph): TourStep[] {
|
||||
return [...tour].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/** Navigate tour step to the correct layer for the first highlighted node. */
|
||||
function navigateTourToLayer(
|
||||
graph: KnowledgeGraph,
|
||||
nodeIds: string[],
|
||||
): Partial<DashboardStore> {
|
||||
if (nodeIds.length === 0) return {};
|
||||
const layerId = findNodeLayer(graph, nodeIds[0]);
|
||||
if (layerId) {
|
||||
return {
|
||||
navigationLevel: "layer-detail" as const,
|
||||
activeLayerId: layerId,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
graph: null,
|
||||
selectedNodeId: null,
|
||||
@@ -74,6 +115,10 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
|
||||
showLayers: false,
|
||||
|
||||
navigationLevel: "overview",
|
||||
activeLayerId: null,
|
||||
viewMode: "graph",
|
||||
|
||||
codeViewerOpen: false,
|
||||
codeViewerNodeId: null,
|
||||
|
||||
@@ -89,15 +134,125 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
|
||||
zoomToNodeId: null,
|
||||
focusNodeId: null,
|
||||
nodeHistory: [],
|
||||
|
||||
setGraph: (graph) => {
|
||||
const searchEngine = new SearchEngine(graph.nodes);
|
||||
const query = get().searchQuery;
|
||||
const searchResults = query.trim() ? searchEngine.search(query) : [];
|
||||
set({ graph, searchEngine, searchResults });
|
||||
set({
|
||||
graph,
|
||||
searchEngine,
|
||||
searchResults,
|
||||
navigationLevel: "overview",
|
||||
activeLayerId: null,
|
||||
selectedNodeId: null,
|
||||
focusNodeId: null,
|
||||
nodeHistory: [],
|
||||
});
|
||||
},
|
||||
selectNode: (nodeId) => set({ selectedNodeId: nodeId }),
|
||||
navigateToNode: (nodeId) => set({ selectedNodeId: nodeId, zoomToNodeId: nodeId }),
|
||||
|
||||
selectNode: (nodeId) => {
|
||||
const { selectedNodeId, nodeHistory } = get();
|
||||
if (nodeId && selectedNodeId && nodeId !== selectedNodeId) {
|
||||
// Push current node to history before navigating away
|
||||
set({
|
||||
selectedNodeId: nodeId,
|
||||
nodeHistory: [...nodeHistory, selectedNodeId],
|
||||
});
|
||||
} else {
|
||||
set({ selectedNodeId: nodeId });
|
||||
}
|
||||
},
|
||||
|
||||
navigateToNode: (nodeId) => {
|
||||
get().navigateToNodeInLayer(nodeId);
|
||||
},
|
||||
|
||||
navigateToNodeInLayer: (nodeId) => {
|
||||
const { graph, selectedNodeId, nodeHistory } = get();
|
||||
if (!graph) return;
|
||||
const layerId = findNodeLayer(graph, nodeId);
|
||||
const newHistory =
|
||||
selectedNodeId && nodeId !== selectedNodeId
|
||||
? [...nodeHistory, selectedNodeId]
|
||||
: nodeHistory;
|
||||
if (layerId) {
|
||||
set({
|
||||
navigationLevel: "layer-detail",
|
||||
activeLayerId: layerId,
|
||||
selectedNodeId: nodeId,
|
||||
zoomToNodeId: nodeId,
|
||||
focusNodeId: null,
|
||||
codeViewerOpen: false,
|
||||
codeViewerNodeId: null,
|
||||
nodeHistory: newHistory,
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
selectedNodeId: nodeId,
|
||||
zoomToNodeId: nodeId,
|
||||
nodeHistory: newHistory,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
goBackNode: () => {
|
||||
const { nodeHistory } = get();
|
||||
if (nodeHistory.length === 0) return;
|
||||
const prevNodeId = nodeHistory[nodeHistory.length - 1];
|
||||
const newHistory = nodeHistory.slice(0, -1);
|
||||
// Navigate to previous node WITHOUT pushing to history
|
||||
const { graph } = get();
|
||||
if (!graph) return;
|
||||
const layerId = findNodeLayer(graph, prevNodeId);
|
||||
if (layerId) {
|
||||
set({
|
||||
navigationLevel: "layer-detail",
|
||||
activeLayerId: layerId,
|
||||
selectedNodeId: prevNodeId,
|
||||
zoomToNodeId: prevNodeId,
|
||||
nodeHistory: newHistory,
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
selectedNodeId: prevNodeId,
|
||||
zoomToNodeId: prevNodeId,
|
||||
nodeHistory: newHistory,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
drillIntoLayer: (layerId) =>
|
||||
set({
|
||||
navigationLevel: "layer-detail",
|
||||
activeLayerId: layerId,
|
||||
selectedNodeId: null,
|
||||
focusNodeId: null,
|
||||
codeViewerOpen: false,
|
||||
codeViewerNodeId: null,
|
||||
}),
|
||||
|
||||
navigateToOverview: () =>
|
||||
set({
|
||||
navigationLevel: "overview",
|
||||
activeLayerId: null,
|
||||
selectedNodeId: null,
|
||||
focusNodeId: null,
|
||||
codeViewerOpen: false,
|
||||
codeViewerNodeId: null,
|
||||
}),
|
||||
|
||||
setViewMode: (mode) =>
|
||||
set({
|
||||
viewMode: mode,
|
||||
// When switching to flow view, go to overview level (flow shows all layers)
|
||||
navigationLevel: mode === "flow" ? "overview" : get().navigationLevel,
|
||||
activeLayerId: mode === "flow" ? null : get().activeLayerId,
|
||||
selectedNodeId: null,
|
||||
focusNodeId: null,
|
||||
}),
|
||||
|
||||
setFocusNode: (nodeId) => set({ focusNodeId: nodeId, selectedNodeId: nodeId }),
|
||||
setSearchMode: (mode) => set({ searchMode: mode }),
|
||||
setSearchQuery: (query) => {
|
||||
@@ -141,11 +296,13 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
const { graph } = get();
|
||||
if (!graph || !graph.tour || graph.tour.length === 0) return;
|
||||
const sorted = getSortedTour(graph);
|
||||
const layerNav = navigateTourToLayer(graph, sorted[0].nodeIds);
|
||||
set({
|
||||
tourActive: true,
|
||||
currentTourStep: 0,
|
||||
tourHighlightedNodeIds: sorted[0].nodeIds,
|
||||
selectedNodeId: null,
|
||||
...layerNav,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -161,9 +318,11 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
if (!graph || !graph.tour || graph.tour.length === 0) return;
|
||||
const sorted = getSortedTour(graph);
|
||||
if (step < 0 || step >= sorted.length) return;
|
||||
const layerNav = navigateTourToLayer(graph, sorted[step].nodeIds);
|
||||
set({
|
||||
currentTourStep: step,
|
||||
tourHighlightedNodeIds: sorted[step].nodeIds,
|
||||
...layerNav,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -173,9 +332,11 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
const sorted = getSortedTour(graph);
|
||||
if (currentTourStep < sorted.length - 1) {
|
||||
const next = currentTourStep + 1;
|
||||
const layerNav = navigateTourToLayer(graph, sorted[next].nodeIds);
|
||||
set({
|
||||
currentTourStep: next,
|
||||
tourHighlightedNodeIds: sorted[next].nodeIds,
|
||||
...layerNav,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -186,9 +347,11 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
if (currentTourStep > 0) {
|
||||
const sorted = getSortedTour(graph);
|
||||
const prev = currentTourStep - 1;
|
||||
const layerNav = navigateTourToLayer(graph, sorted[prev].nodeIds);
|
||||
set({
|
||||
currentTourStep: prev,
|
||||
tourHighlightedNodeIds: sorted[prev].nodeIds,
|
||||
...layerNav,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { KnowledgeGraph } from "@understand-anything/core/types";
|
||||
|
||||
export interface LayerEdgeAggregation {
|
||||
sourceLayerId: string;
|
||||
targetLayerId: string;
|
||||
count: number;
|
||||
edgeTypes: string[];
|
||||
}
|
||||
|
||||
export interface PortalInfo {
|
||||
layerId: string;
|
||||
layerName: string;
|
||||
connectionCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate edges between layers. Counts how many graph edges cross
|
||||
* from one layer to another. Only considers edges where both endpoints
|
||||
* are assigned to a layer.
|
||||
*/
|
||||
export function aggregateLayerEdges(
|
||||
graph: KnowledgeGraph,
|
||||
): LayerEdgeAggregation[] {
|
||||
const nodeToLayer = new Map<string, string>();
|
||||
for (const layer of graph.layers) {
|
||||
for (const nodeId of layer.nodeIds) {
|
||||
nodeToLayer.set(nodeId, layer.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Key: "layerA|layerB" (sorted) → aggregation
|
||||
const pairMap = new Map<
|
||||
string,
|
||||
{ sourceLayerId: string; targetLayerId: string; count: number; edgeTypes: Set<string> }
|
||||
>();
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
const sourceLayer = nodeToLayer.get(edge.source);
|
||||
const targetLayer = nodeToLayer.get(edge.target);
|
||||
if (!sourceLayer || !targetLayer) continue;
|
||||
if (sourceLayer === targetLayer) continue;
|
||||
|
||||
// Canonical key so A→B and B→A merge
|
||||
const [a, b] =
|
||||
sourceLayer < targetLayer
|
||||
? [sourceLayer, targetLayer]
|
||||
: [targetLayer, sourceLayer];
|
||||
const key = `${a}|${b}`;
|
||||
|
||||
const existing = pairMap.get(key);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
existing.edgeTypes.add(edge.type);
|
||||
} else {
|
||||
pairMap.set(key, {
|
||||
sourceLayerId: a,
|
||||
targetLayerId: b,
|
||||
count: 1,
|
||||
edgeTypes: new Set([edge.type]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(pairMap.values()).map((p) => ({
|
||||
sourceLayerId: p.sourceLayerId,
|
||||
targetLayerId: p.targetLayerId,
|
||||
count: p.count,
|
||||
edgeTypes: Array.from(p.edgeTypes),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute portal info for a given layer: which other layers are connected
|
||||
* and how many edges cross the boundary.
|
||||
*/
|
||||
export function computePortals(
|
||||
graph: KnowledgeGraph,
|
||||
activeLayerId: string,
|
||||
): PortalInfo[] {
|
||||
const aggregated = aggregateLayerEdges(graph);
|
||||
const layerNameMap = new Map(graph.layers.map((l) => [l.id, l.name]));
|
||||
|
||||
const portalMap = new Map<string, number>();
|
||||
|
||||
for (const agg of aggregated) {
|
||||
if (agg.sourceLayerId === activeLayerId) {
|
||||
portalMap.set(
|
||||
agg.targetLayerId,
|
||||
(portalMap.get(agg.targetLayerId) ?? 0) + agg.count,
|
||||
);
|
||||
} else if (agg.targetLayerId === activeLayerId) {
|
||||
portalMap.set(
|
||||
agg.sourceLayerId,
|
||||
(portalMap.get(agg.sourceLayerId) ?? 0) + agg.count,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(portalMap.entries()).map(([layerId, count]) => ({
|
||||
layerId,
|
||||
layerName: layerNameMap.get(layerId) ?? layerId,
|
||||
connectionCount: count,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given layer, find which file nodes in that layer connect to a
|
||||
* specific external layer. Returns the set of node IDs in activeLayer
|
||||
* that have edges crossing to targetLayerId.
|
||||
*/
|
||||
export function findCrossLayerFileNodes(
|
||||
graph: KnowledgeGraph,
|
||||
activeLayerId: string,
|
||||
targetLayerId: string,
|
||||
): Set<string> {
|
||||
const activeNodeIds = new Set(
|
||||
graph.layers.find((l) => l.id === activeLayerId)?.nodeIds ?? [],
|
||||
);
|
||||
const targetNodeIds = new Set(
|
||||
graph.layers.find((l) => l.id === targetLayerId)?.nodeIds ?? [],
|
||||
);
|
||||
|
||||
const result = new Set<string>();
|
||||
for (const edge of graph.edges) {
|
||||
if (activeNodeIds.has(edge.source) && targetNodeIds.has(edge.target)) {
|
||||
result.add(edge.source);
|
||||
}
|
||||
if (activeNodeIds.has(edge.target) && targetNodeIds.has(edge.source)) {
|
||||
result.add(edge.target);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import dagre from "@dagrejs/dagre";
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
import type { KnowledgeGraph } from "@understand-anything/core/types";
|
||||
|
||||
export const NODE_WIDTH = 280;
|
||||
export const NODE_HEIGHT = 120;
|
||||
export const LAYER_CLUSTER_WIDTH = 320;
|
||||
export const LAYER_CLUSTER_HEIGHT = 180;
|
||||
export const PORTAL_NODE_WIDTH = 240;
|
||||
export const PORTAL_NODE_HEIGHT = 80;
|
||||
|
||||
// Swim-lane constants
|
||||
export const LANE_WIDTH = 320;
|
||||
export const LANE_GAP = 40;
|
||||
export const LANE_PADDING = 30;
|
||||
export const LANE_HEADER_HEIGHT = 40;
|
||||
|
||||
export function applyDagreLayout(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
direction: "TB" | "LR" = "TB",
|
||||
nodeDimensions?: Map<string, { width: number; height: number }>,
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
@@ -23,7 +35,10 @@ export function applyDagreLayout(
|
||||
});
|
||||
|
||||
nodes.forEach((node) => {
|
||||
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
const dims = nodeDimensions?.get(node.id);
|
||||
const w = dims?.width ?? NODE_WIDTH;
|
||||
const h = dims?.height ?? NODE_HEIGHT;
|
||||
g.setNode(node.id, { width: w, height: h });
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
@@ -35,14 +50,189 @@ export function applyDagreLayout(
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
const pos = g.node(node.id);
|
||||
if (!pos) return { ...node, position: { x: 0, y: 0 } };
|
||||
const dims = nodeDimensions?.get(node.id);
|
||||
const w = dims?.width ?? NODE_WIDTH;
|
||||
const h = dims?.height ?? NODE_HEIGHT;
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: pos.x - NODE_WIDTH / 2,
|
||||
y: pos.y - NODE_HEIGHT / 2,
|
||||
x: pos.x - w / 2,
|
||||
y: pos.y - h / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes: layoutedNodes, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Preferred order of layers for the swim-lane flow view.
|
||||
* Reflects a typical request lifecycle: entry → middleware → logic → data → external.
|
||||
* Layers not in this list are appended at the end.
|
||||
*/
|
||||
const LAYER_FLOW_ORDER = [
|
||||
"API Layer",
|
||||
"UI Layer",
|
||||
"Middleware Layer",
|
||||
"Service Layer",
|
||||
"External Services",
|
||||
"Data Layer",
|
||||
"Background Tasks",
|
||||
"Utility Layer",
|
||||
"Configuration Layer",
|
||||
"Test Layer",
|
||||
];
|
||||
|
||||
function getLayerSortIndex(layerName: string): number {
|
||||
const idx = LAYER_FLOW_ORDER.indexOf(layerName);
|
||||
return idx >= 0 ? idx : LAYER_FLOW_ORDER.length;
|
||||
}
|
||||
|
||||
export interface SwimLaneResult {
|
||||
/** All nodes: lane background groups + file nodes positioned inside them */
|
||||
nodes: Node[];
|
||||
/** Cross-lane and intra-lane edges */
|
||||
edges: Edge[];
|
||||
/** Ordered layer info for reference */
|
||||
lanes: Array<{ layerId: string; layerName: string; columnIndex: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swim-lane layout: each layer becomes a vertical column (lane), ordered
|
||||
* by the request lifecycle. Within each lane, dagre handles vertical
|
||||
* positioning. File nodes are children of their lane group node.
|
||||
*/
|
||||
export function applySwimLaneLayout(
|
||||
graph: KnowledgeGraph,
|
||||
fileNodes: Node[],
|
||||
allEdges: Edge[],
|
||||
): SwimLaneResult {
|
||||
// Sort layers by flow order
|
||||
const sortedLayers = [...graph.layers].sort(
|
||||
(a, b) => getLayerSortIndex(a.name) - getLayerSortIndex(b.name),
|
||||
);
|
||||
|
||||
// Build nodeId → layer mapping
|
||||
const nodeToLayerId = new Map<string, string>();
|
||||
for (const layer of sortedLayers) {
|
||||
for (const nid of layer.nodeIds) {
|
||||
nodeToLayerId.set(nid, layer.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Group file nodes by layer
|
||||
const nodesByLayer = new Map<string, Node[]>();
|
||||
for (const layer of sortedLayers) {
|
||||
nodesByLayer.set(layer.id, []);
|
||||
}
|
||||
for (const node of fileNodes) {
|
||||
const lid = nodeToLayerId.get(node.id);
|
||||
if (lid && nodesByLayer.has(lid)) {
|
||||
nodesByLayer.get(lid)!.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// For each lane, use dagre to compute y-positions of its nodes
|
||||
// (treating the lane as a vertical sub-graph)
|
||||
const laneHeights = new Map<string, number>();
|
||||
|
||||
for (const [layerId, nodes] of nodesByLayer) {
|
||||
if (nodes.length === 0) {
|
||||
laneHeights.set(layerId, LANE_HEADER_HEIGHT + LANE_PADDING * 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({
|
||||
rankdir: "TB",
|
||||
nodesep: 20,
|
||||
ranksep: 40,
|
||||
marginx: 0,
|
||||
marginy: 0,
|
||||
});
|
||||
|
||||
const laneNodeIds = new Set(nodes.map((n) => n.id));
|
||||
for (const node of nodes) {
|
||||
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
}
|
||||
|
||||
// Only include edges within this lane for vertical ordering
|
||||
for (const edge of allEdges) {
|
||||
if (laneNodeIds.has(edge.source) && laneNodeIds.has(edge.target)) {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
}
|
||||
}
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
// Read dagre's y-positions and assign relative positions within the lane
|
||||
let maxY = 0;
|
||||
for (const node of nodes) {
|
||||
const pos = g.node(node.id);
|
||||
if (pos) {
|
||||
// Store the y position in the node's data for later use
|
||||
(node as Node & { _laneY: number })._laneY = pos.y - NODE_HEIGHT / 2;
|
||||
maxY = Math.max(maxY, pos.y + NODE_HEIGHT / 2);
|
||||
}
|
||||
}
|
||||
|
||||
laneHeights.set(layerId, LANE_HEADER_HEIGHT + maxY + LANE_PADDING * 2);
|
||||
}
|
||||
|
||||
// Find the tallest lane so all lanes are the same height
|
||||
const maxLaneHeight = Math.max(
|
||||
200,
|
||||
...Array.from(laneHeights.values()),
|
||||
);
|
||||
|
||||
// Build lane group nodes and position file nodes as children
|
||||
const resultNodes: Node[] = [];
|
||||
const lanes: SwimLaneResult["lanes"] = [];
|
||||
|
||||
sortedLayers.forEach((layer, colIdx) => {
|
||||
const laneX = colIdx * (LANE_WIDTH + LANE_GAP);
|
||||
const laneId = `lane:${layer.id}`;
|
||||
|
||||
lanes.push({
|
||||
layerId: layer.id,
|
||||
layerName: layer.name,
|
||||
columnIndex: colIdx,
|
||||
});
|
||||
|
||||
// Lane background group node
|
||||
resultNodes.push({
|
||||
id: laneId,
|
||||
type: "group",
|
||||
position: { x: laneX, y: 0 },
|
||||
data: { label: layer.name },
|
||||
style: {
|
||||
width: LANE_WIDTH,
|
||||
height: maxLaneHeight,
|
||||
backgroundColor: "rgba(212,165,116,0.03)",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(212,165,116,0.1)",
|
||||
padding: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Position file nodes within this lane
|
||||
const nodes = nodesByLayer.get(layer.id) ?? [];
|
||||
for (const node of nodes) {
|
||||
const laneY = (node as Node & { _laneY?: number })._laneY ?? 0;
|
||||
resultNodes.push({
|
||||
...node,
|
||||
parentId: laneId,
|
||||
extent: "parent" as const,
|
||||
position: {
|
||||
x: (LANE_WIDTH - NODE_WIDTH) / 2,
|
||||
y: LANE_HEADER_HEIGHT + LANE_PADDING + laneY,
|
||||
},
|
||||
});
|
||||
// Clean up temp property
|
||||
delete (node as Node & { _laneY?: number })._laneY;
|
||||
}
|
||||
});
|
||||
|
||||
return { nodes: resultNodes, edges: allEdges, lanes };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user