feat: improve dashboard navigation for large graphs

- Enable touchpad panning (panOnScroll) so two-finger scroll pans
  instead of zooming
- Color-code layer groups with distinct colors (blue, green, purple,
  gold, pink, teal, slate) instead of identical gold for all layers
- Show NodeInfo panel alongside LearnPanel when a node is clicked,
  so connections are always accessible regardless of persona mode
- Add clickable connections in NodeInfo sidebar that navigate and
  zoom to the target node
- Add edge highlighting on node selection: connected edges glow,
  unrelated nodes/edges dim to 20% opacity
- Add focus mode to isolate a node's 1-hop neighborhood
- Set minZoom=0.05, maxZoom=4, and scale dagre spacing for 50+ nodes
This commit is contained in:
Sreeram
2026-03-23 16:59:43 +05:30
Unverified
parent 42074bb210
commit 8b7bda30fa
7 changed files with 199 additions and 78 deletions
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { ReactFlowProvider } from "@xyflow/react";
import { validateGraph } from "@understand-anything/core/schema";
import { useDashboardStore } from "./store";
import GraphView from "./components/GraphView";
@@ -68,13 +69,15 @@ function App() {
}, [setDiffOverlay]);
// Determine sidebar content
// Learn persona always shows LearnPanel; tour active overrides everything
const sidebarContent = tourActive || persona === "junior" ? (
<LearnPanel />
) : selectedNodeId ? (
<NodeInfo />
) : (
<ProjectOverview />
// Learn mode shows LearnPanel + NodeInfo when a node is selected
// Other modes show NodeInfo when selected, ProjectOverview otherwise
const isLearnMode = tourActive || persona === "junior";
const sidebarContent = (
<>
{isLearnMode && <LearnPanel />}
{selectedNodeId && <NodeInfo />}
{!selectedNodeId && !isLearnMode && <ProjectOverview />}
</>
);
return (
@@ -108,11 +111,13 @@ function App() {
<div className="flex-1 flex min-h-0 relative">
{/* Graph area */}
<div className="flex-1 min-w-0 min-h-0">
<GraphView />
<ReactFlowProvider>
<GraphView />
</ReactFlowProvider>
</div>
{/* Right sidebar */}
<aside className="w-[360px] shrink-0 bg-surface border-l border-border-subtle overflow-hidden">
<aside className="w-[360px] shrink-0 bg-surface border-l border-border-subtle overflow-auto">
{sidebarContent}
</aside>
@@ -35,6 +35,8 @@ export interface CustomNodeData extends Record<string, unknown> {
isDiffChanged: boolean;
isDiffAffected: boolean;
isDiffFaded: boolean;
isNeighbor: boolean;
isSelectionFaded: boolean;
onNodeClick?: (nodeId: string) => void;
}
@@ -73,6 +75,13 @@ export default function CustomNode({
extraClass += " diff-faded";
}
// Selection-based dimming (when another node is selected, fade unrelated nodes)
if (data.isSelectionFaded) {
extraClass += " opacity-20 pointer-events-auto";
} else if (data.isNeighbor) {
extraClass += " ring-1 ring-gold-dim/50";
}
const name = data.label ?? "unnamed";
const truncatedName =
name.length > 24 ? name.slice(0, 22) + "..." : name;
@@ -3,6 +3,7 @@ import {
ReactFlow,
useNodesState,
useEdgesState,
useReactFlow,
Background,
BackgroundVariant,
Controls,
@@ -15,7 +16,7 @@ import CustomNode from "./CustomNode";
import type { CustomFlowNode } from "./CustomNode";
import { useDashboardStore } from "../store";
import { applyDagreLayout, NODE_WIDTH, NODE_HEIGHT } from "../utils/layout";
// Layer colors are hardcoded to gold-tinted values in the group node styles
import { getLayerColor } from "./LayerLegend";
const LAYER_PADDING = 40;
@@ -33,6 +34,8 @@ export default function GraphView() {
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 handleNodeSelect = useCallback(
(nodeId: string) => {
@@ -50,7 +53,7 @@ export default function GraphView() {
};
// Filter nodes and edges based on persona
const filteredGraphNodes =
let filteredGraphNodes =
persona === "non-technical"
? graph.nodes.filter(
(n) =>
@@ -58,16 +61,41 @@ export default function GraphView() {
)
: graph.nodes;
const filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
const filteredGraphEdges =
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
if (focusNodeId && filteredNodeIds.has(focusNodeId)) {
const focusNeighborIds = new Set<string>([focusNodeId]);
for (const edge of filteredGraphEdges) {
if (edge.source === focusNodeId) focusNeighborIds.add(edge.target);
if (edge.target === focusNodeId) focusNeighborIds.add(edge.source);
}
filteredGraphNodes = filteredGraphNodes.filter((n) => focusNeighborIds.has(n.id));
filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
filteredGraphEdges = filteredGraphEdges.filter(
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
);
}
// Compute neighbor set for selection-based 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,
@@ -84,6 +112,8 @@ export default function GraphView() {
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,
},
};
@@ -95,25 +125,49 @@ export default function GraphView() {
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 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)",
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.3)", strokeWidth: 1.5 };
edgeLabelStyle = { fill: "#a39787", fontSize: 10 };
edgeAnimated = edge.type === "calls";
}
return {
id: `e-${i}`,
source: edge.source,
target: edge.target,
label: edge.type,
animated: edge.type === "calls" || isImpacted,
style: isImpacted
? {
stroke: sourceInDiff && targetInDiff
? "rgba(224, 82, 82, 0.7)"
: "rgba(212, 160, 48, 0.5)",
strokeWidth: 2.5,
}
: diffMode
? { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }
: { stroke: "rgba(212,165,116,0.3)", strokeWidth: 1.5 },
labelStyle: diffMode && !isImpacted
? { fill: "rgba(163,151,135,0.3)", fontSize: 10 }
: { fill: "#a39787", fontSize: 10 },
animated: edgeAnimated,
style: edgeStyle,
labelStyle: edgeLabelStyle,
};
});
@@ -167,7 +221,8 @@ export default function GraphView() {
const groupWidth = maxX - minX + LAYER_PADDING * 2;
const groupHeight = maxY - minY + LAYER_PADDING * 2 + 24;
// Create the group node
// Create the group node with distinct color per layer
const layerColor = getLayerColor(layerIdx);
groupNodes.push({
id: layer.id,
type: "group",
@@ -176,13 +231,13 @@ export default function GraphView() {
style: {
width: groupWidth,
height: groupHeight,
backgroundColor: "rgba(212,165,116,0.05)",
backgroundColor: layerColor.bg,
borderRadius: 12,
border: `2px dashed rgba(212,165,116,0.25)`,
border: `2px solid ${layerColor.border}`,
padding: 8,
fontSize: 13,
fontWeight: 600,
color: "#d4a574",
color: layerColor.label,
},
});
@@ -214,11 +269,13 @@ export default function GraphView() {
];
return { initialNodes: allNodes, initialEdges: laid.edges };
}, [graph, searchResults, selectedNodeId, showLayers, tourHighlightedNodeIds, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds]);
}, [graph, searchResults, selectedNodeId, showLayers, tourHighlightedNodeIds, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds, focusNodeId]);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const { fitView } = useReactFlow();
useEffect(() => {
setNodes(initialNodes);
}, [initialNodes, setNodes]);
@@ -227,6 +284,19 @@ export default function GraphView() {
setEdges(initialEdges);
}, [initialEdges, setEdges]);
// 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 });
useDashboardStore.setState({ zoomToNodeId: null });
}, 50);
return () => clearTimeout(timer);
}
}, [zoomToNodeId, fitView]);
const onNodeClick = useCallback(
(_: React.MouseEvent, node: { id: string }) => {
// Ignore clicks on group nodes
@@ -251,7 +321,18 @@ export default function GraphView() {
}
return (
<div className="h-full w-full">
<div className="h-full w-full relative">
{focusNodeId && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10">
<button
onClick={() => setFocusNode(null)}
className="px-4 py-2 rounded-full bg-elevated border border-gold/30 text-gold text-xs font-semibold tracking-wider uppercase hover:bg-gold/10 transition-colors flex items-center gap-2 shadow-lg"
>
<span>Showing neighborhood</span>
<span className="text-text-muted">&times;</span>
</button>
</div>
)}
<ReactFlow
nodes={nodes}
edges={edges}
@@ -261,6 +342,10 @@ export default function GraphView() {
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.05}
maxZoom={4}
panOnScroll
colorMode="dark"
>
<Background variant={BackgroundVariant.Dots} color="rgba(212,165,116,0.15)" gap={20} size={1} />
@@ -1,35 +1,18 @@
import { useDashboardStore } from "../store";
const LAYER_COLORS = [
"rgba(59, 130, 246, 0.08)", // blue
"rgba(16, 185, 129, 0.08)", // green
"rgba(245, 158, 11, 0.08)", // amber
"rgba(139, 92, 246, 0.08)", // violet
"rgba(236, 72, 153, 0.08)", // pink
"rgba(6, 182, 212, 0.08)", // cyan
"rgba(249, 115, 22, 0.08)", // orange
"rgba(168, 162, 158, 0.08)", // stone
// Shared layer color palette — used by both LayerLegend dots and GraphView group nodes
export const LAYER_PALETTE = [
{ bg: "rgba(74, 124, 155, 0.12)", border: "rgba(74, 124, 155, 0.4)", label: "#4a7c9b" }, // blue (API)
{ bg: "rgba(90, 158, 111, 0.12)", border: "rgba(90, 158, 111, 0.4)", label: "#5a9e6f" }, // green (Data)
{ bg: "rgba(139, 111, 176, 0.12)", border: "rgba(139, 111, 176, 0.4)", label: "#8b6fb0" }, // purple (Service)
{ bg: "rgba(201, 160, 108, 0.12)", border: "rgba(201, 160, 108, 0.4)", label: "#c9a06c" }, // gold (Config)
{ bg: "rgba(176, 122, 138, 0.12)", border: "rgba(176, 122, 138, 0.4)", label: "#b07a8a" }, // pink (UI)
{ bg: "rgba(74, 155, 140, 0.12)", border: "rgba(74, 155, 140, 0.4)", label: "#4a9b8c" }, // teal (Middleware)
{ bg: "rgba(120, 130, 145, 0.12)", border: "rgba(120, 130, 145, 0.4)", label: "#788291" }, // slate (Test)
];
export const LAYER_BORDER_COLORS = [
"rgba(59, 130, 246, 0.5)", // blue
"rgba(16, 185, 129, 0.5)", // green
"rgba(245, 158, 11, 0.5)", // amber
"rgba(139, 92, 246, 0.5)", // violet
"rgba(236, 72, 153, 0.5)", // pink
"rgba(6, 182, 212, 0.5)", // cyan
"rgba(249, 115, 22, 0.5)", // orange
"rgba(168, 162, 158, 0.5)", // stone
];
export { LAYER_COLORS };
export function getLayerColor(index: number): string {
return LAYER_COLORS[index % LAYER_COLORS.length];
}
export function getLayerBorderColor(index: number): string {
return LAYER_BORDER_COLORS[index % LAYER_BORDER_COLORS.length];
export function getLayerColor(index: number) {
return LAYER_PALETTE[index % LAYER_PALETTE.length];
}
export default function LayerLegend() {
@@ -65,20 +48,23 @@ export default function LayerLegend() {
{showLayers && hasLayers && (
<div className="flex items-center gap-3">
{layers.map((layer, i) => (
<div key={layer.id} className="flex items-center gap-1">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: getLayerBorderColor(i) }}
/>
<span className="text-text-secondary text-[11px]">
{layer.name}
<span className="text-text-muted ml-0.5">
({layer.nodeIds.length})
{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>
</span>
</span>
</div>
))}
</div>
);
})}
</div>
)}
</div>
@@ -20,6 +20,10 @@ export default function NodeInfo() {
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
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;
if (!node) {
@@ -53,7 +57,19 @@ export default function NodeInfo() {
</span>
</div>
<h2 className="text-lg font-serif text-text-primary mb-2">{node.name}</h2>
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-serif text-text-primary">{node.name}</h2>
<button
onClick={() => setFocusNode(focusNodeId === node.id ? null : node.id)}
className={`text-[10px] font-semibold uppercase tracking-wider px-2.5 py-1 rounded transition-colors ${
focusNodeId === node.id
? "bg-gold/20 text-gold border border-gold/40"
: "text-text-muted border border-border-subtle hover:text-gold hover:border-gold/30"
}`}
>
{focusNodeId === node.id ? "Unfocus" : "Focus"}
</button>
</div>
<p className="text-sm text-text-secondary mb-4 leading-relaxed">
{node.summary}
@@ -130,7 +146,11 @@ export default function NodeInfo() {
return (
<div
key={i}
className="text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle flex items-center gap-2"
className="text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle flex items-center gap-2 cursor-pointer hover:border-gold/40 hover:bg-gold/5 transition-colors"
onClick={() => {
navigateToNode(otherId);
openCodeViewer(otherId);
}}
>
<span className="text-gold font-mono">{arrow}</span>
<span className="text-text-muted">{edge.type}</span>
@@ -32,8 +32,16 @@ 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;
setGraph: (graph: KnowledgeGraph) => void;
selectNode: (nodeId: string | null) => void;
navigateToNode: (nodeId: string) => void;
setFocusNode: (nodeId: string | null) => void;
setSearchQuery: (query: string) => void;
toggleLayers: () => void;
setPersona: (persona: Persona) => void;
@@ -79,6 +87,9 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
changedNodeIds: new Set<string>(),
affectedNodeIds: new Set<string>(),
zoomToNodeId: null,
focusNodeId: null,
setGraph: (graph) => {
const searchEngine = new SearchEngine(graph.nodes);
const query = get().searchQuery;
@@ -86,6 +97,8 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
set({ graph, searchEngine, searchResults });
},
selectNode: (nodeId) => set({ selectedNodeId: nodeId }),
navigateToNode: (nodeId) => set({ selectedNodeId: nodeId, zoomToNodeId: nodeId }),
setFocusNode: (nodeId) => set({ focusNodeId: nodeId, selectedNodeId: nodeId }),
setSearchMode: (mode) => set({ searchMode: mode }),
setSearchQuery: (query) => {
const engine = get().searchEngine;
@@ -11,10 +11,13 @@ export function applyDagreLayout(
): { nodes: Node[]; edges: Edge[] } {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
// Scale spacing for larger graphs to reduce overlap
const isLarge = nodes.length > 50;
g.setGraph({
rankdir: direction,
nodesep: 60,
ranksep: 80,
nodesep: isLarge ? 80 : 60,
ranksep: isLarge ? 120 : 80,
marginx: 20,
marginy: 20,
});