feat(dashboard): add graph view with React Flow and custom nodes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-03-14 17:57:40 +08:00
Unverified
parent 39dfffc4f4
commit 377c3c72e6
2 changed files with 216 additions and 0 deletions
@@ -0,0 +1,105 @@
import { Handle, Position } from "@xyflow/react";
import type { NodeProps, Node } from "@xyflow/react";
const typeColors: Record<string, { bg: string; border: string; text: string }> =
{
file: {
bg: "bg-blue-900",
border: "border-blue-500",
text: "text-blue-300",
},
function: {
bg: "bg-green-900",
border: "border-green-500",
text: "text-green-300",
},
class: {
bg: "bg-purple-900",
border: "border-purple-500",
text: "text-purple-300",
},
module: {
bg: "bg-orange-900",
border: "border-orange-500",
text: "text-orange-300",
},
concept: {
bg: "bg-pink-900",
border: "border-pink-500",
text: "text-pink-300",
},
};
const complexityColors: Record<string, string> = {
simple: "bg-green-600",
moderate: "bg-yellow-600",
complex: "bg-red-600",
};
export interface CustomNodeData extends Record<string, unknown> {
label: string;
nodeType: string;
summary: string;
complexity: string;
isHighlighted: boolean;
isSelected: boolean;
}
export type CustomFlowNode = Node<CustomNodeData, "custom">;
export default function CustomNode({
data,
}: NodeProps<CustomFlowNode>) {
const colors = typeColors[data.nodeType] ?? typeColors.file;
const complexityColor =
complexityColors[data.complexity] ?? complexityColors.simple;
let ringClass = "";
if (data.isSelected) {
ringClass = "ring-2 ring-white";
} else if (data.isHighlighted) {
ringClass = "ring-2 ring-yellow-400";
}
const truncatedName =
data.label.length > 24 ? data.label.slice(0, 22) + "..." : data.label;
return (
<div
className={`rounded-lg border-2 ${colors.bg} ${colors.border} ${ringClass} px-3 py-2 min-w-[180px] max-w-[220px] shadow-lg`}
>
<Handle
type="target"
position={Position.Top}
className="!bg-gray-400 !w-2 !h-2"
/>
<div className="flex items-center justify-between mb-1">
<span
className={`text-[10px] font-semibold uppercase tracking-wider ${colors.text}`}
>
{data.nodeType}
</span>
<span
className={`text-[9px] px-1.5 py-0.5 rounded-full text-white font-medium ${complexityColor}`}
>
{data.complexity}
</span>
</div>
<div className="text-sm font-bold text-white truncate" title={data.label}>
{truncatedName}
</div>
<div className="text-[11px] text-gray-300 mt-1 line-clamp-2 leading-tight">
{data.summary}
</div>
<Handle
type="source"
position={Position.Bottom}
className="!bg-gray-400 !w-2 !h-2"
/>
</div>
);
}
@@ -0,0 +1,111 @@
import { useCallback, useEffect, useMemo } from "react";
import {
ReactFlow,
useNodesState,
useEdgesState,
Background,
Controls,
MiniMap,
} from "@xyflow/react";
import type { Edge } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import CustomNode from "./CustomNode";
import type { CustomFlowNode } from "./CustomNode";
import { useDashboardStore } from "../store";
const nodeTypes = { custom: CustomNode };
export default function GraphView() {
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 initialNodes = useMemo<CustomFlowNode[]>(() => {
if (!graph) return [];
return graph.nodes.map((node, i) => ({
id: node.id,
type: "custom" as const,
position: {
x: (i % 3) * 300 + 50,
y: Math.floor(i / 3) * 200 + 50,
},
data: {
label: node.name,
nodeType: node.type,
summary: node.summary,
complexity: node.complexity,
isHighlighted: searchResults.includes(node.id),
isSelected: selectedNodeId === node.id,
},
}));
}, [graph, searchResults, selectedNodeId]);
const initialEdges = useMemo<Edge[]>(() => {
if (!graph) return [];
return graph.edges.map((edge, i) => ({
id: `e-${i}`,
source: edge.source,
target: edge.target,
label: edge.type,
animated: edge.type === "calls",
style: { stroke: "#6b7280", strokeWidth: 1.5 },
labelStyle: { fill: "#9ca3af", fontSize: 10 },
}));
}, [graph]);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
useEffect(() => {
setNodes(initialNodes);
}, [initialNodes, setNodes]);
useEffect(() => {
setEdges(initialEdges);
}, [initialEdges, setEdges]);
const onNodeClick = useCallback(
(_: React.MouseEvent, node: CustomFlowNode) => {
selectNode(node.id);
},
[selectNode],
);
const onPaneClick = useCallback(() => {
selectNode(null);
}, [selectNode]);
if (!graph) {
return (
<div className="h-full w-full flex items-center justify-center bg-gray-800 rounded-lg">
<p className="text-gray-400 text-sm">No knowledge graph loaded</p>
</div>
);
}
return (
<div className="h-full w-full">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
fitView
colorMode="dark"
>
<Background />
<Controls />
<MiniMap
nodeColor="#374151"
maskColor="rgba(0,0,0,0.6)"
className="!bg-gray-800"
/>
</ReactFlow>
</div>
);
}