refactor(dashboard): remove swim-lane flow view

The swim-lane layout needs more design work before shipping. Remove the
flow view toggle, useFlowViewGraph hook, applySwimLaneLayout, and
related store/breadcrumb code. Hierarchical lens navigation is retained.
This commit is contained in:
Sreeram
2026-03-27 03:07:30 +05:30
parent 275b9020e3
commit ef1f220782
4 changed files with 10 additions and 391 deletions
@@ -1,29 +1,22 @@
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)
// Escape key to go back to overview
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (
e.key === "Escape" &&
viewMode === "graph" &&
navigationLevel === "layer-detail"
) {
if (e.key === "Escape" && navigationLevel === "layer-detail") {
navigateToOverview();
}
},
[viewMode, navigationLevel, navigateToOverview],
[navigationLevel, navigateToOverview],
);
useEffect(() => {
@@ -31,48 +24,15 @@ export default function Breadcrumb() {
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" && (
{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" && (
{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}
@@ -89,12 +49,6 @@ export default function Breadcrumb() {
</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>
);
}
@@ -23,7 +23,6 @@ import Breadcrumb from "./Breadcrumb";
import { useDashboardStore } from "../store";
import {
applyDagreLayout,
applySwimLaneLayout,
NODE_WIDTH,
NODE_HEIGHT,
LAYER_CLUSTER_WIDTH,
@@ -416,169 +415,12 @@ function useLayerDetailGraph() {
]);
}
// ── 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[] };
const allLayerNodeIds = new Set(
graph.layers.flatMap((l) => l.nodeIds),
);
const fileGraphNodes = graph.nodes.filter(
(n) => n.type === "file" && allLayerNodeIds.has(n.id),
);
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,
},
};
});
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 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 viewMode = useDashboardStore((s) => s.viewMode);
const selectNode = useDashboardStore((s) => s.selectNode);
const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
const focusNodeId = useDashboardStore((s) => s.focusNodeId);
@@ -586,14 +428,9 @@ function GraphViewInner() {
const overviewGraph = useOverviewGraph();
const detailGraph = useLayerDetailGraph();
const flowGraph = useFlowViewGraph();
const { nodes: initialNodes, edges: initialEdges } =
viewMode === "flow"
? flowGraph
: navigationLevel === "overview"
? overviewGraph
: detailGraph;
navigationLevel === "overview" ? overviewGraph : detailGraph;
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
@@ -608,22 +445,16 @@ function GraphViewInner() {
setEdges(initialEdges);
}, [initialEdges, setEdges]);
// Fit view on level/layer/view-mode transitions
// Fit view on level/layer transitions
useEffect(() => {
const timer = setTimeout(() => {
fitView({ duration: 400, padding: 0.2 });
}, 50);
return () => clearTimeout(timer);
}, [navigationLevel, activeLayerId, viewMode, fitView]);
}, [navigationLevel, activeLayerId, fitView]);
const onNodeClick = useCallback(
(_: React.MouseEvent, node: { id: string }) => {
// In flow view, all clicks are selections (no drill-in)
if (viewMode === "flow") {
if (node.id.startsWith("lane:")) return;
selectNode(node.id);
return;
}
if (navigationLevel === "overview") {
drillIntoLayer(node.id);
} else if (node.id.startsWith("portal:")) {
@@ -633,7 +464,7 @@ function GraphViewInner() {
selectNode(node.id);
}
},
[viewMode, navigationLevel, drillIntoLayer, selectNode],
[navigationLevel, drillIntoLayer, selectNode],
);
const onPaneClick = useCallback(() => {
@@ -8,7 +8,7 @@ import type {
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 {
@@ -33,9 +33,6 @@ interface DashboardStore {
navigationLevel: NavigationLevel;
activeLayerId: string | null;
// View mode: graph (default free-form) or flow (swim-lane)
viewMode: ViewMode;
codeViewerOpen: boolean;
codeViewerNodeId: string | null;
@@ -65,7 +62,6 @@ interface DashboardStore {
goBackNode: () => void;
drillIntoLayer: (layerId: string) => void;
navigateToOverview: () => void;
setViewMode: (mode: ViewMode) => void;
setFocusNode: (nodeId: string | null) => void;
setSearchQuery: (query: string) => void;
toggleLayers: () => void;
@@ -117,8 +113,6 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
navigationLevel: "overview",
activeLayerId: null,
viewMode: "graph",
codeViewerOpen: false,
codeViewerNodeId: null,
@@ -243,16 +237,6 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
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) => {
@@ -1,6 +1,5 @@
import dagre from "@dagrejs/dagre";
import type { Node, Edge } from "@xyflow/react";
import type { KnowledgeGraph } from "@understand-anything/core/types";
import type { LayoutMessage, LayoutResult } from "./layout.worker";
export const NODE_WIDTH = 280;
@@ -10,12 +9,6 @@ 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;
/**
* Synchronous dagre layout — used for small graphs.
*/
@@ -144,146 +137,3 @@ export function applyDagreLayoutAsync(
});
}
// ── Swim-lane layout ───────────────────────────────────────────────────
/**
* Preferred order of layers for the swim-lane flow view.
* Reflects a typical request lifecycle: entry → middleware → logic → data → external.
*/
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 {
nodes: Node[];
edges: Edge[];
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 {
const sortedLayers = [...graph.layers].sort(
(a, b) => getLayerSortIndex(a.name) - getLayerSortIndex(b.name),
);
const nodeToLayerId = new Map<string, string>();
for (const layer of sortedLayers) {
for (const nid of layer.nodeIds) {
nodeToLayerId.set(nid, layer.id);
}
}
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);
}
}
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 });
}
for (const edge of allEdges) {
if (laneNodeIds.has(edge.source) && laneNodeIds.has(edge.target)) {
g.setEdge(edge.source, edge.target);
}
}
dagre.layout(g);
let maxY = 0;
for (const node of nodes) {
const pos = g.node(node.id);
if (pos) {
(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);
}
const maxLaneHeight = Math.max(200, ...Array.from(laneHeights.values()));
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 });
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,
},
});
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,
},
});
delete (node as Node & { _laneY?: number })._laneY;
}
});
return { nodes: resultNodes, edges: allEdges, lanes };
}