fix(dashboard): review fixes for hierarchical navigation PR

- Restore topology/visual separation in useLayerDetailGraph to prevent
  dagre relayout on every selection/search change (perf regression)
- Remove unused tourHighlightedNodeIds from useOverviewGraph deps
- Remove dead code: showLayers/toggleLayers, zoomToNodeId,
  applyDagreLayoutAsync (Web Worker path)
- Replace direct useDashboardStore.setState() in NodeInfo with proper
  navigateToHistoryIndex store action
- Fix non-technical persona filter (was no-op due to prior type=file constraint)
- Integrate Escape-to-overview into App keyboard shortcuts (remove
  duplicate listener from Breadcrumb)
- Cap nodeHistory at 50 entries to prevent unbounded growth
- Add memo() to LayerClusterNode and PortalNode for render performance
- Make computePortals accept pre-computed aggregation to avoid redundant work
- Restore code viewer popup on node click
- Show NodeInfo above LearnPanel when a node is selected

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-03-28 16:44:15 +08:00
Unverified
parent 3cdc2ac71b
commit 6fcee67214
9 changed files with 162 additions and 245 deletions
@@ -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,13 @@ function App() {
}, [setDiffOverlay]);
// Determine sidebar content
// Learn mode shows LearnPanel + NodeInfo when a node is selected
// Other modes show NodeInfo when selected, ProjectOverview otherwise
// 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 = (
<>
{isLearnMode && <LearnPanel />}
{selectedNodeId && <NodeInfo />}
{isLearnMode && <LearnPanel />}
{!selectedNodeId && !isLearnMode && <ProjectOverview />}
</>
);
@@ -1,4 +1,3 @@
import { useCallback, useEffect } from "react";
import { useDashboardStore } from "../store";
export default function Breadcrumb() {
@@ -9,21 +8,6 @@ export default function Breadcrumb() {
const activeLayer = graph?.layers.find((l) => l.id === activeLayerId);
// Escape key to go back to overview
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" && navigationLevel === "layer-detail") {
navigateToOverview();
}
},
[navigationLevel, navigateToOverview],
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
return (
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
{navigationLevel === "overview" && (
@@ -21,6 +21,7 @@ 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,
@@ -105,7 +106,6 @@ 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[] };
@@ -181,18 +181,20 @@ function useOverviewGraph() {
}
const laid = applyDagreLayout(clusterNodes as unknown as Node[], flowEdges, "TB", dims);
return { nodes: laid.nodes, edges: laid.edges };
}, [graph, searchResults, drillIntoLayer, tourHighlightedNodeIds]);
}, [graph, searchResults, drillIntoLayer]);
}
// ── Layer detail level: files + portal nodes ───────────────────────────
// ── Layer detail level: topology (dagre) + visual overlay ───────────────
function useLayerDetailGraph() {
/**
* 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 activeLayerId = useDashboardStore((s) => s.activeLayerId);
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 persona = useDashboardStore((s) => s.persona);
const diffMode = useDashboardStore((s) => s.diffMode);
const changedNodeIds = useDashboardStore((s) => s.changedNodeIds);
@@ -209,22 +211,19 @@ function useLayerDetailGraph() {
return useMemo(() => {
if (!graph || !activeLayerId)
return { nodes: [] as Node[], edges: [] as Edge[] };
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 Node[], edges: [] as Edge[] };
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);
let filteredGraphNodes = graph.nodes.filter(
(n) => layerNodeIds.has(n.id) && n.type === "file",
);
if (persona === "non-technical") {
filteredGraphNodes = filteredGraphNodes.filter(
(n) => n.type === "concept" || n.type === "module" || n.type === "file",
);
}
// 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));
@@ -248,71 +247,44 @@ function useLayerDetailGraph() {
);
}
// Neighbor set for selection highlighting
const neighborNodeIds = new Set<string>();
if (selectedNodeId) {
for (const edge of filteredGraphEdges) {
if (edge.source === selectedNodeId) neighborNodeIds.add(edge.target);
if (edge.target === selectedNodeId) neighborNodeIds.add(edge.source);
}
neighborNodeIds.add(selectedNodeId);
}
const flowNodes: CustomFlowNode[] = filteredGraphNodes.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 diffNodeIds = diffMode
? new Set([...changedNodeIds, ...affectedNodeIds])
: new Set<string>();
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,
},
}));
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 isSelectedEdge =
!!selectedNodeId &&
(edge.source === selectedNodeId || edge.target === selectedNodeId);
const hasSelection = !!selectedNodeId;
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)",
stroke: sourceInDiff && targetInDiff ? "rgba(224, 82, 82, 0.7)" : "rgba(212, 160, 48, 0.5)",
strokeWidth: 2.5,
};
edgeLabelStyle = { fill: "#a39787", fontSize: 10 };
@@ -321,14 +293,6 @@ function useLayerDetailGraph() {
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.3)", strokeWidth: 1.5 };
edgeLabelStyle = { fill: "#a39787", fontSize: 10 };
@@ -366,11 +330,7 @@ function useLayerDetailGraph() {
const portalEdges: Edge[] = [];
let portalEdgeIdx = flowEdges.length;
for (const portal of portals) {
const crossFiles = findCrossLayerFileNodes(
graph,
activeLayerId,
portal.layerId,
);
const crossFiles = findCrossLayerFileNodes(graph, activeLayerId, portal.layerId);
for (const fileId of crossFiles) {
if (filteredNodeIds.has(fileId)) {
portalEdges.push({
@@ -399,21 +359,83 @@ function useLayerDetailGraph() {
}
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,
]);
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) ────────────
@@ -423,6 +445,7 @@ function GraphViewInner() {
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);
@@ -464,9 +487,10 @@ function GraphViewInner() {
drillIntoLayer(targetLayerId);
} else {
selectNode(node.id);
openCodeViewer(node.id);
}
},
[navigationLevel, drillIntoLayer, selectNode],
[navigationLevel, drillIntoLayer, selectNode, openCodeViewer],
);
const onPaneClick = useCallback(() => {
@@ -1,3 +1,4 @@
import { memo } from "react";
import { Handle, Position } from "@xyflow/react";
import type { NodeProps, Node } from "@xyflow/react";
import { getLayerColor } from "./LayerLegend";
@@ -21,7 +22,7 @@ export interface LayerClusterData extends Record<string, unknown> {
export type LayerClusterFlowNode = Node<LayerClusterData, "layer-cluster">;
export default function LayerClusterNode({
function LayerClusterNode({
data,
}: NodeProps<LayerClusterFlowNode>) {
const color = getLayerColor(data.layerColorIndex);
@@ -99,3 +100,5 @@ export default function LayerClusterNode({
</div>
);
}
export default memo(LayerClusterNode);
@@ -71,6 +71,7 @@ export default function NodeInfo() {
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;
@@ -128,25 +129,8 @@ export default function NodeInfo() {
<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 }
: {}),
});
navigateToHistoryIndex(fullIdx);
}}
className="text-[10px] text-text-muted hover:text-gold transition-colors truncate max-w-[80px]"
title={h.name}
@@ -1,3 +1,4 @@
import { memo } from "react";
import { Handle, Position } from "@xyflow/react";
import type { NodeProps, Node } from "@xyflow/react";
import { getLayerColor } from "./LayerLegend";
@@ -12,7 +13,7 @@ export interface PortalNodeData extends Record<string, unknown> {
export type PortalFlowNode = Node<PortalNodeData, "portal">;
export default function PortalNode({
function PortalNode({
data,
}: NodeProps<PortalFlowNode>) {
const color = getLayerColor(data.layerColorIndex);
@@ -59,3 +60,5 @@ export default function PortalNode({
</div>
);
}
export default memo(PortalNode);
@@ -18,6 +18,9 @@ function findNodeLayer(graph: KnowledgeGraph, nodeId: string): string | null {
return null;
}
/** Maximum number of entries in the sidebar navigation history. */
const MAX_HISTORY = 50;
interface DashboardStore {
graph: KnowledgeGraph | null;
selectedNodeId: string | null;
@@ -27,8 +30,6 @@ interface DashboardStore {
searchMode: "fuzzy" | "semantic";
setSearchMode: (mode: "fuzzy" | "semantic") => void;
showLayers: boolean;
// Lens navigation
navigationLevel: NavigationLevel;
activeLayerId: string | null;
@@ -46,9 +47,6 @@ interface DashboardStore {
changedNodeIds: Set<string>;
affectedNodeIds: Set<string>;
// Zoom-to-node: set a nodeId to trigger GraphView to pan/zoom to it
zoomToNodeId: string | null;
// Focus mode: isolate a node's 1-hop neighborhood
focusNodeId: string | null;
@@ -59,12 +57,12 @@ interface DashboardStore {
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;
@@ -109,8 +107,6 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
searchEngine: null,
searchMode: "fuzzy",
showLayers: false,
navigationLevel: "overview",
activeLayerId: null,
codeViewerOpen: false,
@@ -126,7 +122,6 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
changedNodeIds: new Set<string>(),
affectedNodeIds: new Set<string>(),
zoomToNodeId: null,
focusNodeId: null,
nodeHistory: [],
@@ -152,7 +147,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
// Push current node to history before navigating away
set({
selectedNodeId: nodeId,
nodeHistory: [...nodeHistory, selectedNodeId],
nodeHistory: [...nodeHistory, selectedNodeId].slice(-MAX_HISTORY),
});
} else {
set({ selectedNodeId: nodeId });
@@ -169,14 +164,13 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
const layerId = findNodeLayer(graph, nodeId);
const newHistory =
selectedNodeId && nodeId !== selectedNodeId
? [...nodeHistory, selectedNodeId]
? [...nodeHistory, selectedNodeId].slice(-MAX_HISTORY)
: nodeHistory;
if (layerId) {
set({
navigationLevel: "layer-detail",
activeLayerId: layerId,
selectedNodeId: nodeId,
zoomToNodeId: nodeId,
focusNodeId: null,
codeViewerOpen: false,
codeViewerNodeId: null,
@@ -185,33 +179,40 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
} else {
set({
selectedNodeId: nodeId,
zoomToNodeId: 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 } = get();
if (nodeHistory.length === 0) return;
const { nodeHistory, graph } = get();
if (nodeHistory.length === 0 || !graph) 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,
});
}
@@ -253,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 }),
@@ -72,12 +72,14 @@ export function aggregateLayerEdges(
/**
* 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 = aggregateLayerEdges(graph);
const aggregated = precomputed ?? aggregateLayerEdges(graph);
const layerNameMap = new Map(graph.layers.map((l) => [l.id, l.name]));
const portalMap = new Map<string, number>();
@@ -1,6 +1,5 @@
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;
@@ -62,78 +61,4 @@ export function applyDagreLayout(
return { nodes: layoutedNodes, edges };
}
// ── Async layout via Web Worker ────────────────────────────────────────
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);
// 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.
*/
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);
});
}