主人要求:① 全链路流程页从下往上渲染(时序自下而上 ⇒ 最新一定在最上)+ 容器高度自动拓高;② 主动思考页保持原渲染方式但最新放最左(容器默认露最左);③ 追加:改布局后多路渲染仍要合适。 - 共享布局唯一入口 flow/layout.ts:新增枚举 FlowOrientation(newest-top|newest-left) + layoutFlow(graph, orientation);排布拆成「抽象网格→像素」两步,方向开关只在 toPixelNode/toPixelBand;泳道成道逻辑零改动 - 边几何抽到新文件 flow/edges.ts:只按方向选锚点,带回 fromId/toId(不重排图数据),悬空边如实丢弃 - FlowGraphView 的 orientation 是**必填** prop(漏传=编译错误);画布不限高、无 overflow-y(不内部裁切,纵向随图生长);图例加方向小字 - 三处调用同一套布局:Flow=newest-top、Backthink=newest-left、Agents=newest-left(后者是我按同一档裁定,一行可改) - 顺带修复:画布最大列现在把「刚开出、还没跑出步骤的泳道」最小宽度算进去(否则泳道框被裁到画布外) - 测试 593 checks(orientation 逻辑 131 + 渲染 338 + lanes 40 + 既有回归);变异 7 条全红,含「边端点连到错节点」「容器限高裁切」「空泳道被裁」 - 多路:lanes.test 期望随新方向镜像更新,泳道仍成道;并行路数据互相覆盖的已知问题归后续分桶切片
302 lines
9.2 KiB
TypeScript
302 lines
9.2 KiB
TypeScript
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<FlowOrientation, string> = {
|
|
"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 (
|
|
<div className="space-y-2">
|
|
<div
|
|
data-flow-canvas="1"
|
|
className="overflow-x-auto rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] p-3"
|
|
>
|
|
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="block">
|
|
<defs>
|
|
<marker
|
|
id={markerId}
|
|
viewBox="0 0 10 10"
|
|
refX="9"
|
|
refY="5"
|
|
markerWidth="7"
|
|
markerHeight="7"
|
|
orient="auto-start-reverse"
|
|
>
|
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--mu-text-lo)" />
|
|
</marker>
|
|
<marker
|
|
id={laneMarkerId}
|
|
viewBox="0 0 10 10"
|
|
refX="9"
|
|
refY="5"
|
|
markerWidth="7"
|
|
markerHeight="7"
|
|
orient="auto-start-reverse"
|
|
>
|
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--mu-accent)" />
|
|
</marker>
|
|
</defs>
|
|
|
|
{bands.filter((b) => b.framed).map((band) => (
|
|
<LaneFrame key={band.id} band={band} />
|
|
))}
|
|
|
|
{shapes.map((shape, i) => (
|
|
<EdgePath
|
|
key={`${shape.fromId}-${shape.toId}-${i}`}
|
|
shape={shape}
|
|
markerId={markerId}
|
|
laneMarkerId={laneMarkerId}
|
|
/>
|
|
))}
|
|
|
|
{nodes.map((p) => (
|
|
<NodeGroup
|
|
key={p.node.id}
|
|
node={p.node}
|
|
x={p.x}
|
|
y={p.y}
|
|
selected={selectedId === p.node.id}
|
|
lane={laneRoots.has(p.node.id)}
|
|
onNodeClick={onNodeClick}
|
|
/>
|
|
))}
|
|
</svg>
|
|
</div>
|
|
<Legend hasLanes={lanes.length > 0} orientation={orientation} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<path
|
|
d={shape.d}
|
|
fill="none"
|
|
stroke={shape.inLane ? "var(--mu-accent)" : "var(--mu-text-lo)"}
|
|
strokeOpacity={shape.inLane ? 0.7 : 1}
|
|
strokeWidth="1.5"
|
|
strokeDasharray={shape.inLane ? "5 3" : undefined}
|
|
markerEnd={`url(#${shape.inLane ? laneMarkerId : markerId})`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<g>
|
|
<rect
|
|
x={band.x}
|
|
y={band.y}
|
|
width={band.width}
|
|
height={band.height}
|
|
rx="16"
|
|
fill="color-mix(in srgb, var(--mu-accent) 5%, transparent)"
|
|
stroke="var(--mu-glass-border)"
|
|
strokeDasharray="6 4"
|
|
strokeWidth="1"
|
|
/>
|
|
<text
|
|
x={band.x + 12}
|
|
y={band.y + 15}
|
|
fontSize="10"
|
|
fill="var(--mu-text-mid)"
|
|
>
|
|
泳道 · {clampLabel(band.label, 24)}
|
|
</text>
|
|
<circle
|
|
cx={band.x + band.width - 14}
|
|
cy={band.y + 11}
|
|
r="4"
|
|
fill={tone.stroke}
|
|
className={running ? "mu-blink" : undefined}
|
|
/>
|
|
</g>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<g
|
|
onClick={onNodeClick ? () => onNodeClick(node) : undefined}
|
|
className={onNodeClick ? "cursor-pointer" : undefined}
|
|
>
|
|
<title>{`${node.label} · ${tone.label}`}</title>
|
|
{status === "running" ? (
|
|
<rect
|
|
x={x - 4}
|
|
y={y - 4}
|
|
width={NODE_W + 8}
|
|
height={NODE_H + 8}
|
|
rx="16"
|
|
fill="none"
|
|
stroke={tone.stroke}
|
|
strokeWidth="2"
|
|
className="mu-blink"
|
|
/>
|
|
) : null}
|
|
<rect
|
|
x={x}
|
|
y={y}
|
|
width={NODE_W}
|
|
height={NODE_H}
|
|
rx="12"
|
|
fill={tone.fill}
|
|
stroke={tone.stroke}
|
|
strokeWidth={selected ? 3 : 1.5}
|
|
/>
|
|
<circle cx={x + NODE_W - 12} cy={y + 11} r="3.5" fill={tone.stroke} />
|
|
{lane ? (
|
|
<text x={x + 10} y={y + 15} fontSize="9" fill="var(--mu-accent)">
|
|
泳道根
|
|
</text>
|
|
) : null}
|
|
{node.kind ? (
|
|
<text
|
|
x={x + NODE_W / 2}
|
|
y={y + 20}
|
|
textAnchor="middle"
|
|
fontSize="9"
|
|
fill="var(--mu-text-lo)"
|
|
>
|
|
{node.kind}
|
|
</text>
|
|
) : null}
|
|
<text
|
|
x={x + NODE_W / 2}
|
|
y={y + (node.kind ? 41 : NODE_H / 2 + 5)}
|
|
textAnchor="middle"
|
|
fontSize="12"
|
|
fontWeight="500"
|
|
fill="var(--mu-text-hi)"
|
|
>
|
|
{clampLabel(node.label, 20)}
|
|
</text>
|
|
</g>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<div className="flex flex-wrap items-center gap-3 px-1">
|
|
{STATUS_ORDER.map((s) => (
|
|
<span
|
|
key={s}
|
|
className="inline-flex items-center gap-1.5 text-[10px] text-[var(--mu-text-lo)]"
|
|
>
|
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: FLOW_TONE[s].stroke }} />
|
|
{FLOW_TONE[s].label}
|
|
</span>
|
|
))}
|
|
{hasLanes ? (
|
|
<span className="inline-flex items-center gap-1.5 text-[10px] text-[var(--mu-text-lo)]">
|
|
<svg width="22" height="8" aria-hidden="true">
|
|
<line
|
|
x1="0"
|
|
y1="4"
|
|
x2="22"
|
|
y2="4"
|
|
stroke="var(--mu-accent)"
|
|
strokeWidth="1.5"
|
|
strokeDasharray="5 3"
|
|
/>
|
|
</svg>
|
|
泳道内步骤
|
|
</span>
|
|
) : null}
|
|
<span className="inline-flex items-center gap-1.5 text-[10px] text-[var(--mu-text-lo)]">
|
|
{DIRECTION_HINT[orientation]}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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;
|
|
}
|