import { useId } from "react"; import type { ReactNode } from "react"; import type { FlowGraph, FlowNode } from "../../lib/types"; import { FLOW_TONE, STATUS_ORDER, statusOf, } from "./flow/tone"; import { layoutFlow, NODE_W, NODE_H, type FlowOrientation, type PlacedBand } from "./flow/layout"; import { edgeShapes, type EdgeShape } from "./flow/edges"; /** Lightweight SVG directed-graph renderer for a FlowGraph (no graph deps). * A node's step is its longest-path depth from a root; the steps advance along the * **time axis** and the newest one always sits at the canvas origin (top or left, see * `FlowOrientation` in flow/layout.ts). Sub-agent branches are drawn in their own framed * lanes beside the trunk (design/19 §2), so parallel branches read as separate swimlanes * instead of interleaving. Edges are cubic beziers with an arrowhead anchored on the * border the direction dictates (flow/edges.ts); node stroke/fill is coded by status and * a running node pulses with the panel's existing `.mu-blink` (no new animation dep). * * 容器**不限高**:图多高就多高(主人要求「容器高度自动拓高」,不在内部裁切);横向仅在 * 泳道把画布撑宽时滚动(`newest-top` 下就是这种情形),纵向永远随图生长。 */ /** 方向提示(画在图例里):让「哪端是最新」写在脸上,不靠用户自己猜。 */ const DIRECTION_HINT: Record = { "newest-top": "时序自下而上 ↑ · 最新在最上", "newest-left": "时序自右向左 ← · 最新在最左", }; export function FlowGraphView({ graph, orientation, onNodeClick, selectedId, }: { graph: FlowGraph; /** 渲染方向(flow/layout.ts):`newest-top` = 时序自下而上(整页 Flow); * `newest-left` = 时序自右向左(嵌在页面里的图块,容器默认露出最左边)。 */ orientation: FlowOrientation; onNodeClick?: (node: FlowNode) => void; selectedId?: string | null; }): ReactNode { const markerId = useId().replace(/[^a-zA-Z0-9]/g, ""); const laneMarkerId = `${markerId}L`; const layout = layoutFlow(graph, orientation); const { nodes, bands, lanes, width, height } = layout; const shapes = edgeShapes(layout); const laneRoots = new Set(lanes.map((l) => l.id)); return (
{bands.filter((b) => b.framed).map((band) => ( ))} {shapes.map((shape, i) => ( ))} {nodes.map((p) => ( ))}
0} orientation={orientation} />
); } /** EdgePath draws one cubic bezier between the two placed nodes; edges that run into * a lane are dashed and accent-colored so the trunk stays readable. The endpoints come * from `edgeShapes` — the component never recomputes (nor guesses) them. */ function EdgePath({ shape, markerId, laneMarkerId, }: { shape: EdgeShape; markerId: string; laneMarkerId: string; }): ReactNode { return ( ); } /** LaneFrame draws one sub-agent swimlane container: a dashed Miuix surface with * a header carrying the sub-agent label and its live status. */ function LaneFrame({ band }: { band: PlacedBand }): ReactNode { const tone = FLOW_TONE[band.status]; const running = band.status === "running"; return ( 泳道 · {clampLabel(band.label, 24)} ); } /** NodeGroup draws one node card: a status-colored rounded rect plus kind/label * text and a status dot. A running node carries a blinking halo so 运行中 is * visible at a glance without animating the text. */ function NodeGroup({ node, x, y, selected, lane, onNodeClick, }: { node: FlowNode; x: number; y: number; selected: boolean; lane: boolean; onNodeClick?: (node: FlowNode) => void; }): ReactNode { const status = statusOf(node); const tone = FLOW_TONE[status]; return ( onNodeClick(node) : undefined} className={onNodeClick ? "cursor-pointer" : undefined} > {`${node.label} · ${tone.label}`} {status === "running" ? ( ) : null} {lane ? ( 泳道根 ) : null} {node.kind ? ( {node.kind} ) : null} {clampLabel(node.label, 20)} ); } /** Legend explains the status colors, the lane edge style, and which end of the time * axis is the newest one (the direction is reversed for the whole-page view). */ function Legend({ hasLanes, orientation, }: { hasLanes: boolean; orientation: FlowOrientation; }): ReactNode { return (
{STATUS_ORDER.map((s) => ( {FLOW_TONE[s].label} ))} {hasLanes ? ( 泳道内步骤 ) : null} {DIRECTION_HINT[orientation]}
); } /** clampLabel shortens a node label to fit the fixed-width node card. */ function clampLabel(label: string, max: number): string { return label.length > max ? `${label.slice(0, max - 1)}…` : label; }