mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
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:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user