Jelajahi Sumber

feat(web): replace walk graph grid with React Flow canvas (zoom/pan/drag)

- 游走路径图从 index%4 死板网格(节点重叠、边标签盖节点)换成真画布:
  @xyflow/react + dagre 有向图分层布局(LR,按拓扑排,间距大不重叠)
- 自带能力:滚轮缩放、拖动画布、拖动节点、minimap、一键 fitView、Controls
- 自定义节点按状态上色(success 绿/rejected-failed 红/pending 黄/skipped 灰);
  边带 label 与归属执行标注(已归属未运行/执行:内容包);点击节点或边仍打开详情 drawer
- 删除旧 WalkGraphCanvas + toBoardGraph(102 行);新增 WalkGraphFlow 独立组件;
  +@xyflow/react +dagre 依赖;tsc + build 通过(/runs bundle 9→92KB,React Flow 成本)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sam Lee 2 hari lalu
induk
melakukan
d1c5b5a39d

+ 15 - 0
web/app/globals.css

@@ -1406,3 +1406,18 @@ a {
   color: #2563eb;
   border-color: #2563eb;
 }
+
+/* 游走路径图:React Flow 画布容器(需明确高度) */
+.walk-flow-shell {
+  height: 560px;
+  border: 1px solid #e4e8f0;
+  border-radius: 12px;
+  overflow: hidden;
+  background: #fafbfd;
+}
+.walk-flow-shell .react-flow__node {
+  cursor: pointer;
+}
+.walk-flow-shell .react-flow__attribution {
+  display: none;
+}

+ 2 - 101
web/features/runs/RunDashboardPage.tsx

@@ -1,6 +1,7 @@
 "use client";
 
 import Link from "next/link";
+import { WalkGraphFlow } from "@/features/runs/WalkGraphFlow";
 
 import {
   ChevronDown,
@@ -575,7 +576,7 @@ function StagePanel({
     return (
       <BusinessSection title="游走路径图" icon={<GitBranch size={17} />}>
         <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "walk")} />
-        <WalkGraphCanvas
+        <WalkGraphFlow
           graph={data.dashboard.walk_graph}
           onOpen={(payload) =>
             onOpenDrawer({
@@ -838,77 +839,7 @@ function reasonLabel(reason: unknown): string {
   return labels[value] || compactValue(reason);
 }
 
-function WalkGraphCanvas({
-  graph,
-  onOpen
-}: {
-  graph: DashboardResponse["walk_graph"];
-  onOpen: (payload: Record<string, unknown>) => void;
-}) {
-  const { nodes, edges } = useMemo(() => toBoardGraph(graph), [graph]);
-  if (!nodes.length || nodes.length === 1 && !edges.length) {
-    return (
-      <div className="walk-empty-board">
-        <GitBranch size={28} />
-        <strong>当前 run 没有可执行游走路径</strong>
-        <span>如果 query 或平台阶段失败,游走图会保持为空;这不是伪造缺口。</span>
-      </div>
-    );
-  }
-  return (
-    <div className="walk-board">
-      <div className="walk-board-inner">
-        <svg className="walk-lines" aria-hidden="true">
-          {edges.map((edge) => (
-            <g key={edge.id}>
-              <line
-                x1={edge.sourcePoint.x}
-                y1={edge.sourcePoint.y}
-                x2={edge.targetPoint.x}
-                y2={edge.targetPoint.y}
-              />
-              <text
-                x={(edge.sourcePoint.x + edge.targetPoint.x) / 2}
-                y={(edge.sourcePoint.y + edge.targetPoint.y) / 2 - 6}
-              >
-                {edge.label}
-              </text>
-            </g>
-          ))}
-        </svg>
-        {nodes.map((node) => (
-          <button
-            className={`walk-node-card ${node.status}`}
-            key={node.id}
-            onClick={() => onOpen(node.payload)}
-            style={{ left: node.x, top: node.y }}
-            type="button"
-          >
-            <strong>{node.label}</strong>
-            <span>{node.type} · {node.status}</span>
-          </button>
-        ))}
-        {edges.map((edge) => (
-          <button
-            className={`walk-edge-chip ${edge.status || "pending"}`}
-            key={`${edge.id}-chip`}
-            onClick={() => onOpen(edge.payload)}
-            style={{
-              left: (edge.sourcePoint.x + edge.targetPoint.x) / 2 - 70,
-              top: (edge.sourcePoint.y + edge.targetPoint.y) / 2 + 8
-            }}
-            type="button"
-          >
-            {edge.label}
-            {edgeExecutionSuffix(edge.payload)}
-          </button>
-        ))}
-      </div>
-    </div>
-  );
-}
 
-// M4 归属/执行分离:边归属包(rule_pack)与实际执行包(executed_rule_pack_id)可能不同。
 function edgeExecutionSuffix(payload: Record<string, unknown>): string {
   if (!payload.rule_pack) return "";
   if (payload.rule_pack_executed === false) return " · 已归属未运行";
@@ -998,33 +929,3 @@ function RuleDrawerContent({ payload }: { payload: RuleApplicationSummary }) {
   );
 }
 
-function toBoardGraph(graph: DashboardResponse["walk_graph"]) {
-  const nodes = graph.nodes.map((node, index) => {
-    const col = index % 4;
-    const row = Math.floor(index / 4);
-    return {
-      ...node,
-      x: 38 + col * 230,
-      y: 48 + row * 120,
-      payload: node as unknown as Record<string, unknown>
-    };
-  });
-  const nodeById = new Map(nodes.map((node) => [node.id, node]));
-  const edges = graph.edges
-    .map((edge) => {
-      const source = nodeById.get(edge.source);
-      const target = nodeById.get(edge.target);
-      if (!source || !target) {
-        return null;
-      }
-      return {
-        ...edge,
-        label: edge.label || edge.status || "edge",
-        sourcePoint: { x: source.x + 155, y: source.y + 36 },
-        targetPoint: { x: target.x, y: target.y + 36 },
-        payload: edge as unknown as Record<string, unknown>
-      };
-    })
-    .filter((edge): edge is NonNullable<typeof edge> => Boolean(edge));
-  return { nodes, edges };
-}

+ 180 - 0
web/features/runs/WalkGraphFlow.tsx

@@ -0,0 +1,180 @@
+"use client";
+
+import { useMemo } from "react";
+import dagre from "dagre";
+import {
+  Background,
+  Controls,
+  Handle,
+  MiniMap,
+  Position,
+  ReactFlow,
+  type Edge,
+  type Node,
+  type NodeProps
+} from "@xyflow/react";
+import "@xyflow/react/dist/style.css";
+import { GitBranch } from "lucide-react";
+import type { WalkGraph } from "@/lib/api/types";
+
+const NODE_W = 200;
+const NODE_H = 62;
+
+function statusColor(status?: string | null): { bg: string; border: string; text: string } {
+  switch (status) {
+    case "success":
+    case "matched":
+      return { bg: "#e9f7ef", border: "#2e9b5b", text: "#1d6b3e" };
+    case "rejected":
+    case "failed":
+    case "rule_blocked":
+      return { bg: "#fdecec", border: "#dc4040", text: "#a82626" };
+    case "pending":
+      return { bg: "#fff6e5", border: "#e0a020", text: "#9a6a0a" };
+    case "skipped":
+      return { bg: "#eef0f4", border: "#9aa4b5", text: "#5b6678" };
+    default:
+      return { bg: "#ffffff", border: "#cdd5e3", text: "#3a4658" };
+  }
+}
+
+type WalkNodeData = {
+  label: string;
+  type: string;
+  status: string;
+  payload: Record<string, unknown>;
+};
+
+function WalkNode({ data }: NodeProps) {
+  const d = data as unknown as WalkNodeData;
+  const c = statusColor(d.status);
+  return (
+    <div
+      style={{
+        width: NODE_W,
+        minHeight: NODE_H,
+        padding: "8px 12px",
+        borderRadius: 12,
+        border: `1px solid ${c.border}`,
+        background: c.bg,
+        boxShadow: "0 1px 3px rgba(20,30,55,0.08)",
+        display: "flex",
+        flexDirection: "column",
+        gap: 3
+      }}
+    >
+      <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
+      <strong
+        style={{
+          fontSize: 13,
+          color: "#172033",
+          overflow: "hidden",
+          textOverflow: "ellipsis",
+          whiteSpace: "nowrap"
+        }}
+      >
+        {d.label}
+      </strong>
+      <span style={{ fontSize: 11, color: c.text, fontWeight: 600 }}>
+        {d.type} · {d.status}
+      </span>
+      <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
+    </div>
+  );
+}
+
+const nodeTypes = { walk: WalkNode };
+
+function buildFlow(graph: WalkGraph): { nodes: Node[]; edges: Edge[] } {
+  const g = new dagre.graphlib.Graph();
+  g.setGraph({ rankdir: "LR", nodesep: 48, ranksep: 110, marginx: 24, marginy: 24 });
+  g.setDefaultEdgeLabel(() => ({}));
+
+  const nodeIds = new Set(graph.nodes.map((n) => n.id));
+  graph.nodes.forEach((n) => g.setNode(n.id, { width: NODE_W, height: NODE_H }));
+  const validEdges = graph.edges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target));
+  validEdges.forEach((e) => g.setEdge(e.source, e.target));
+  dagre.layout(g);
+
+  const nodes: Node[] = graph.nodes.map((n) => {
+    const p = g.node(n.id);
+    return {
+      id: n.id,
+      type: "walk",
+      position: { x: (p?.x ?? 0) - NODE_W / 2, y: (p?.y ?? 0) - NODE_H / 2 },
+      data: {
+        label: n.label,
+        type: n.type,
+        status: n.status,
+        payload: n as unknown as Record<string, unknown>
+      }
+    };
+  });
+
+  const edges: Edge[] = validEdges.map((e, i) => {
+    const c = statusColor(e.status);
+    const exec =
+      e.rule_pack_executed === false
+        ? " · 已归属未运行"
+        : e.executed_rule_pack_id && e.executed_rule_pack_id !== e.rule_pack
+          ? " · 执行:内容包"
+          : "";
+    return {
+      id: e.id || `e${i}`,
+      source: e.source,
+      target: e.target,
+      label: `${e.label || e.status || ""}${exec}`,
+      data: { payload: e as unknown as Record<string, unknown> },
+      style: { stroke: c.border, strokeWidth: 1.5 },
+      labelStyle: { fontSize: 11, fill: "#5b6678", fontWeight: 600 },
+      labelBgStyle: { fill: "#ffffff", fillOpacity: 0.9 },
+      labelBgPadding: [4, 2] as [number, number],
+      labelBgBorderRadius: 4
+    };
+  });
+
+  return { nodes, edges };
+}
+
+export function WalkGraphFlow({
+  graph,
+  onOpen
+}: {
+  graph: WalkGraph;
+  onOpen: (payload: Record<string, unknown>) => void;
+}) {
+  const { nodes, edges } = useMemo(() => buildFlow(graph), [graph]);
+
+  if (!nodes.length || (nodes.length === 1 && !edges.length)) {
+    return (
+      <div className="walk-empty-board">
+        <GitBranch size={28} />
+        <strong>当前 run 没有可执行游走路径</strong>
+        <span>如果 query 或平台阶段失败,游走图会保持为空;这不是伪造缺口。</span>
+      </div>
+    );
+  }
+
+  return (
+    <div className="walk-flow-shell">
+      <ReactFlow
+        nodes={nodes}
+        edges={edges}
+        nodeTypes={nodeTypes}
+        fitView
+        minZoom={0.2}
+        maxZoom={2}
+        nodesConnectable={false}
+        onNodeClick={(_, node) => onOpen((node.data as unknown as WalkNodeData).payload)}
+        onEdgeClick={(_, edge) =>
+          onOpen(((edge.data as { payload?: Record<string, unknown> })?.payload) || {})
+        }
+        proOptions={{ hideAttribution: true }}
+      >
+        <Background gap={18} color="#e4e8f0" />
+        <Controls showInteractive={false} />
+        <MiniMap pannable zoomable nodeStrokeWidth={2} />
+      </ReactFlow>
+    </div>
+  );
+}

+ 277 - 3
web/package-lock.json

@@ -8,12 +8,15 @@
       "name": "content-find-agent-web",
       "version": "0.1.0",
       "dependencies": {
+        "@xyflow/react": "^12.11.0",
+        "dagre": "^0.8.5",
         "lucide-react": "^0.468.0",
         "next": "^15.1.0",
         "react": "^19.0.0",
         "react-dom": "^19.0.0"
       },
       "devDependencies": {
+        "@types/dagre": "^0.7.54",
         "@types/node": "^22.10.0",
         "@types/react": "^19.0.0",
         "@types/react-dom": "^19.0.0",
@@ -699,6 +702,62 @@
         "tslib": "^2.8.0"
       }
     },
+    "node_modules/@types/d3-color": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+      "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-drag": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+      "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-selection": "*"
+      }
+    },
+    "node_modules/@types/d3-interpolate": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+      "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-color": "*"
+      }
+    },
+    "node_modules/@types/d3-selection": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+      "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-transition": {
+      "version": "3.0.9",
+      "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+      "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-selection": "*"
+      }
+    },
+    "node_modules/@types/d3-zoom": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+      "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-interpolate": "*",
+        "@types/d3-selection": "*"
+      }
+    },
+    "node_modules/@types/dagre": {
+      "version": "0.7.54",
+      "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz",
+      "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/node": {
       "version": "22.19.20",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz",
@@ -713,7 +772,7 @@
       "version": "19.2.17",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
       "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
-      "dev": true,
+      "devOptional": true,
       "license": "MIT",
       "dependencies": {
         "csstype": "^3.2.2"
@@ -723,12 +782,54 @@
       "version": "19.2.3",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
       "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
-      "dev": true,
+      "devOptional": true,
       "license": "MIT",
       "peerDependencies": {
         "@types/react": "^19.2.0"
       }
     },
+    "node_modules/@xyflow/react": {
+      "version": "12.11.0",
+      "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz",
+      "integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==",
+      "license": "MIT",
+      "dependencies": {
+        "@xyflow/system": "0.0.77",
+        "classcat": "^5.0.3",
+        "zustand": "^4.4.0"
+      },
+      "peerDependencies": {
+        "@types/react": ">=17",
+        "@types/react-dom": ">=17",
+        "react": ">=17",
+        "react-dom": ">=17"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@xyflow/system": {
+      "version": "0.0.77",
+      "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz",
+      "integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-drag": "^3.0.7",
+        "@types/d3-interpolate": "^3.0.4",
+        "@types/d3-selection": "^3.0.10",
+        "@types/d3-transition": "^3.0.8",
+        "@types/d3-zoom": "^3.0.8",
+        "d3-drag": "^3.0.0",
+        "d3-interpolate": "^3.0.1",
+        "d3-selection": "^3.0.0",
+        "d3-zoom": "^3.0.0"
+      }
+    },
     "node_modules/caniuse-lite": {
       "version": "1.0.30001797",
       "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
@@ -749,6 +850,12 @@
       ],
       "license": "CC-BY-4.0"
     },
+    "node_modules/classcat": {
+      "version": "5.0.5",
+      "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+      "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+      "license": "MIT"
+    },
     "node_modules/client-only": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -759,9 +866,124 @@
       "version": "3.2.3",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
       "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
-      "dev": true,
+      "devOptional": true,
       "license": "MIT"
     },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-dispatch": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+      "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-drag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+      "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-selection": "3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-selection": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+      "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-transition": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+      "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3",
+        "d3-dispatch": "1 - 3",
+        "d3-ease": "1 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-timer": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "d3-selection": "2 - 3"
+      }
+    },
+    "node_modules/d3-zoom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+      "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-drag": "2 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-selection": "2 - 3",
+        "d3-transition": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/dagre": {
+      "version": "0.8.5",
+      "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
+      "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
+      "license": "MIT",
+      "dependencies": {
+        "graphlib": "^2.1.8",
+        "lodash": "^4.17.15"
+      }
+    },
     "node_modules/detect-libc": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -772,6 +994,21 @@
         "node": ">=8"
       }
     },
+    "node_modules/graphlib": {
+      "version": "2.1.8",
+      "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
+      "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash": "^4.17.15"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+      "license": "MIT"
+    },
     "node_modules/lucide-react": {
       "version": "0.468.0",
       "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
@@ -1028,6 +1265,43 @@
       "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
       "dev": true,
       "license": "MIT"
+    },
+    "node_modules/use-sync-external-store": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+      "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/zustand": {
+      "version": "4.5.7",
+      "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+      "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+      "license": "MIT",
+      "dependencies": {
+        "use-sync-external-store": "^1.2.2"
+      },
+      "engines": {
+        "node": ">=12.7.0"
+      },
+      "peerDependencies": {
+        "@types/react": ">=16.8",
+        "immer": ">=9.0.6",
+        "react": ">=16.8"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "immer": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        }
+      }
     }
   }
 }

+ 3 - 0
web/package.json

@@ -8,12 +8,15 @@
     "build": "next build"
   },
   "dependencies": {
+    "@xyflow/react": "^12.11.0",
+    "dagre": "^0.8.5",
     "lucide-react": "^0.468.0",
     "next": "^15.1.0",
     "react": "^19.0.0",
     "react-dom": "^19.0.0"
   },
   "devDependencies": {
+    "@types/dagre": "^0.7.54",
     "@types/node": "^22.10.0",
     "@types/react": "^19.0.0",
     "@types/react-dom": "^19.0.0",