From 377c3c72e69afdc8a54b35fb31291d76ce3f9335 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sat, 14 Mar 2026 17:57:40 +0800 Subject: [PATCH] feat(dashboard): add graph view with React Flow and custom nodes Co-Authored-By: Claude Opus 4.6 --- .../dashboard/src/components/CustomNode.tsx | 105 +++++++++++++++++ .../dashboard/src/components/GraphView.tsx | 111 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 packages/dashboard/src/components/CustomNode.tsx create mode 100644 packages/dashboard/src/components/GraphView.tsx diff --git a/packages/dashboard/src/components/CustomNode.tsx b/packages/dashboard/src/components/CustomNode.tsx new file mode 100644 index 0000000..bfbd511 --- /dev/null +++ b/packages/dashboard/src/components/CustomNode.tsx @@ -0,0 +1,105 @@ +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps, Node } from "@xyflow/react"; + +const typeColors: Record = + { + 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 = { + simple: "bg-green-600", + moderate: "bg-yellow-600", + complex: "bg-red-600", +}; + +export interface CustomNodeData extends Record { + label: string; + nodeType: string; + summary: string; + complexity: string; + isHighlighted: boolean; + isSelected: boolean; +} + +export type CustomFlowNode = Node; + +export default function CustomNode({ + data, +}: NodeProps) { + 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 ( +
+ + +
+ + {data.nodeType} + + + {data.complexity} + +
+ +
+ {truncatedName} +
+ +
+ {data.summary} +
+ + +
+ ); +} diff --git a/packages/dashboard/src/components/GraphView.tsx b/packages/dashboard/src/components/GraphView.tsx new file mode 100644 index 0000000..0a8a78f --- /dev/null +++ b/packages/dashboard/src/components/GraphView.tsx @@ -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(() => { + 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(() => { + 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 ( +
+

No knowledge graph loaded

+
+ ); + } + + return ( +
+ + + + + +
+ ); +}