diff --git a/frontend/web/src/admin/modules/Agents.tsx b/frontend/web/src/admin/modules/Agents.tsx index 4a1962d..6a0e0a4 100644 --- a/frontend/web/src/admin/modules/Agents.tsx +++ b/frontend/web/src/admin/modules/Agents.tsx @@ -172,7 +172,9 @@ export function AgentsFlowBlock({ } return ( - + {/* 与后台思考的图块同一档:横向渲染 + 最新在最左(FV0), + 容器默认露出最左边 ⇒ 打开就先看到最新那拍。 */} + ); diff --git a/frontend/web/src/admin/modules/Backthink.tsx b/frontend/web/src/admin/modules/Backthink.tsx index 4544112..1439297 100644 --- a/frontend/web/src/admin/modules/Backthink.tsx +++ b/frontend/web/src/admin/modules/Backthink.tsx @@ -115,7 +115,9 @@ export function BackthinkGraphBlock({ } return ( <> - + {/* 页面区域有限、容器默认只露出最左边 ⇒ 保持横向渲染,但把最新放在最左 + (FV0;时序自右向左,箭头随之指向左边)。 */} + ); diff --git a/frontend/web/src/admin/modules/Flow.tsx b/frontend/web/src/admin/modules/Flow.tsx index 0617805..f304a2f 100644 --- a/frontend/web/src/admin/modules/Flow.tsx +++ b/frontend/web/src/admin/modules/Flow.tsx @@ -146,6 +146,7 @@ export function FlowBody({ setSelectedId(n.id)} /> diff --git a/frontend/web/src/admin/modules/FlowGraph.tsx b/frontend/web/src/admin/modules/FlowGraph.tsx index 9711230..a2101df 100644 --- a/frontend/web/src/admin/modules/FlowGraph.tsx +++ b/frontend/web/src/admin/modules/FlowGraph.tsx @@ -6,34 +6,53 @@ import { STATUS_ORDER, statusOf, } from "./flow/tone"; -import { layoutFlow, NODE_W, NODE_H, type PlacedBand, type PlacedEdge, type PlacedNode } from "./flow/layout"; +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). - * Left-to-right layered layout: a node's column is its longest-path depth from - * a root, and the rows of one column stack top-down. Sub-agent branches are - * drawn in their own framed lanes below the trunk (design/19 §2), so parallel - * branches read as separate swimlanes instead of interleaving. Edges are cubic - * beziers with an arrowhead; node stroke/fill is coded by status and a running - * node pulses with the panel's existing `.mu-blink` (no new animation dep). */ + * 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 { nodes, edges, bands, lanes, width, height } = layoutFlow(graph); - const placedOf = new Map(nodes.map((p) => [p.node.id, p])); + 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 (
-
+
))} - {edges.map((e, i) => ( + {shapes.map((shape, i) => ( @@ -88,42 +105,32 @@ export function FlowGraphView({ ))}
- 0} /> + 0} orientation={orientation} />
); } -/** EdgePath draws one cubic bezier between two placed nodes; edges that run into - * a lane are dashed and accent-colored so the trunk stays readable. */ +/** 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({ - edge, - from, - to, + shape, markerId, laneMarkerId, }: { - edge: PlacedEdge; - from?: PlacedNode; - to?: PlacedNode; + shape: EdgeShape; markerId: string; laneMarkerId: string; }): ReactNode { - if (!from || !to) return null; - const sx = from.x + NODE_W; - const sy = from.y + NODE_H / 2; - const tx = to.x; - const ty = to.y + NODE_H / 2; - const dx = Math.max(24, Math.abs(tx - sx) / 2); - const inLane = edge.lane !== undefined; return ( ); } @@ -245,8 +252,15 @@ function NodeGroup({ ); } -/** Legend explains the status colors and the lane edge style. */ -function Legend({ hasLanes }: { hasLanes: boolean }): ReactNode { +/** 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) => ( @@ -274,6 +288,9 @@ function Legend({ hasLanes }: { hasLanes: boolean }): ReactNode { 泳道内步骤 ) : null} + + {DIRECTION_HINT[orientation]} +
); } diff --git a/frontend/web/src/admin/modules/flow/edges.ts b/frontend/web/src/admin/modules/flow/edges.ts new file mode 100644 index 0000000..26fb9f5 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/edges.ts @@ -0,0 +1,70 @@ +import { NODE_H, NODE_W, type FlowLayout, type FlowOrientation, type PlacedNode } from "./layout"; + +/** 一条边的像素几何(FV0):锚点贴在**节点边框**上,方向由布局方向决定 —— + * 最新在上 ⇒ 从更早那拍的上边出去、从更新那拍的下边进来;最新在左 ⇒ 左出右进。 + * `fromId` / `toId` 原样带回图数据的 id,便于逐边核对「这条线确实连在正确的两个节点之间」。 */ +export interface EdgeShape { + /** 图数据里的起点 id(绝不改写、绝不重排)。 */ + fromId: string; + /** 图数据里的终点 id(时序上更新的那一端)。 */ + toId: string; + /** 该边所属泳道(undefined = 主干)。 */ + lane?: string; + sx: number; + sy: number; + tx: number; + ty: number; + /** SVG path 的 d 属性。 */ + d: string; + inLane: boolean; +} + +/** edgeShapes resolves every placed edge into a pixel path. 端点缺一的边整条丢弃 + * (如实丢,不伪造节点)—— layout 已先按「两个端点都在图里」过滤过,这里是第二道闸。 */ +export function edgeShapes(layout: FlowLayout): EdgeShape[] { + const placedOf = new Map(layout.nodes.map((p) => [p.node.id, p])); + const shapes: EdgeShape[] = []; + layout.edges.forEach((e) => { + const from = placedOf.get(e.edge.from); + const to = placedOf.get(e.edge.to); + if (!from || !to) return; + const a = anchors(from, to, layout.orientation); + shapes.push({ + fromId: e.edge.from, + toId: e.edge.to, + lane: e.lane, + sx: a.sx, + sy: a.sy, + tx: a.tx, + ty: a.ty, + d: a.d, + inLane: e.lane !== undefined, + }); + }); + return shapes; +} + +/** anchors picks the border anchor of both nodes plus the cubic control points, so the + * arrowhead always points along the time axis of the current orientation. */ +function anchors( + from: PlacedNode, + to: PlacedNode, + orientation: FlowOrientation, +): { sx: number; sy: number; tx: number; ty: number; d: string } { + if (orientation === "newest-top") { + // 时序自下而上:从更早那拍的上边出发,进更新那拍的下边。 + const sx = from.x + NODE_W / 2; + const sy = from.y; + const tx = to.x + NODE_W / 2; + const ty = to.y + NODE_H; + const dy = Math.max(24, Math.abs(sy - ty) / 2); + return { sx, sy, tx, ty, d: `M ${sx} ${sy} C ${sx} ${sy - dy}, ${tx} ${ty + dy}, ${tx} ${ty}` }; + } + // 时序自右向左:从更早那拍的左边出发,进更新那拍的右边。 + const sx = from.x; + const sy = from.y + NODE_H / 2; + const tx = to.x + NODE_W; + const ty = to.y + NODE_H / 2; + const dx = Math.max(24, Math.abs(sx - tx) / 2); + return { sx, sy, tx, ty, d: `M ${sx} ${sy} C ${sx - dx} ${sy}, ${tx + dx} ${ty}, ${tx} ${ty}` }; +} diff --git a/frontend/web/src/admin/modules/flow/lanes.test.ts b/frontend/web/src/admin/modules/flow/lanes.test.ts index fdbba75..8c01456 100644 --- a/frontend/web/src/admin/modules/flow/lanes.test.ts +++ b/frontend/web/src/admin/modules/flow/lanes.test.ts @@ -87,7 +87,7 @@ function parallel(): FlowGraph { ); } -// --- trunk only: no lane, columns unchanged (no P1 regression) --------------- +// --- trunk only: no lane, one column grid (no P1 regression) ---------------- { const g = trunkOnly(); @@ -99,29 +99,29 @@ function parallel(): FlowGraph { const at = new Map(nodes.map((p) => [p.node.id, p])); same(bands.length, 1, "trunk graph has exactly one band"); same(bands[0].framed, false, "the trunk band is not framed"); - same(at.get("input")!.x, PAD, "input sits in the first column"); + // FV0:默认方向 newest-left ⇒ 时序自右向左,最新的一拍(output)落在最左。 + same(at.get("output")!.x, PAD, "the newest step sits leftmost"); ok( - at.get("output")!.x > at.get("tool1")!.x && - at.get("tool1")!.x > at.get("llm1")!.x && - at.get("llm1")!.x > at.get("input")!.x, - "trunk columns grow left to right", + at.get("input")!.x > at.get("llm1")!.x && + at.get("llm1")!.x > at.get("tool1")!.x && + at.get("tool1")!.x > at.get("output")!.x, + "the time axis runs right to left (newest on the left)", ); same( [at.get("input")!.y, at.get("llm1")!.y, at.get("tool1")!.y, at.get("output")!.y], [PAD, PAD, PAD, PAD], "a single-row trunk keeps one row", ); - // P1 pixel parity: with no lane in play the geometry is exactly the old - // longest-path column grid, so P2 cannot regress the trunk view. + // 同一块最长路径列网格的**镜像**:方向只决定「哪端是最新」,列距/行距分毫不动。 same( nodes.map((p) => [p.node.id, p.x, p.y]), [ - ["input", PAD, PAD], - ["llm1", PAD + (NODE_W + GAP_X), PAD], - ["tool1", PAD + 2 * (NODE_W + GAP_X), PAD], - ["output", PAD + 3 * (NODE_W + GAP_X), PAD], + ["input", PAD + 3 * (NODE_W + GAP_X), PAD], + ["llm1", PAD + 2 * (NODE_W + GAP_X), PAD], + ["tool1", PAD + (NODE_W + GAP_X), PAD], + ["output", PAD, PAD], ], - "trunk-only layout matches the P1 column grid", + "trunk-only layout is the same column grid, mirrored (newest-left)", ); } @@ -187,21 +187,22 @@ function parallel(): FlowGraph { const framed = bands.filter((b) => b.framed); same(framed.length, 2, "each lane gets its own framed band"); - // Lane steps start one column right of their sub-agent root. + // Lane steps start one column to the *newer* side of their sub-agent root — in + // newest-left that side is the left one (smaller x). same( - at.get("llmA")!.x - at.get("subA")!.x, + at.get("subA")!.x - at.get("llmA")!.x, NODE_W + GAP_X, - "lane A's first step starts one column right of its root", + "lane A's first step starts one column left (newer) of its root", ); same( - at.get("toolA")!.x, - at.get("llmA")!.x + NODE_W + GAP_X, - "lane A chains toolA after llmA", + at.get("llmA")!.x, + at.get("toolA")!.x + NODE_W + GAP_X, + "lane A chains toolA after llmA (toolA is newer ⇒ further left)", ); same( - at.get("llmB")!.x - at.get("subB")!.x, + at.get("subB")!.x - at.get("llmB")!.x, NODE_W + GAP_X, - "lane B's step starts one column right of its root", + "lane B's step starts one column left (newer) of its root", ); // Lanes are stacked below the trunk and never share rows with each other. diff --git a/frontend/web/src/admin/modules/flow/layout.ts b/frontend/web/src/admin/modules/flow/layout.ts index 48b7cdd..26ef639 100644 --- a/frontend/web/src/admin/modules/flow/layout.ts +++ b/frontend/web/src/admin/modules/flow/layout.ts @@ -1,18 +1,24 @@ import type { AgentStatus, FlowEdge, FlowGraph, FlowNode } from "../../../lib/types"; import { groupFlowLanes, type FlowGrouping, type FlowLane } from "./lanes"; -/** Lane-aware left-to-right layout for the flow graph (design/19 §2). The trunk - * keeps the P1 "longest path depth" columns; every sub-agent lane gets its own - * horizontal band below the trunk, its steps starting one column right of the - * sub-agent root and chaining rightwards. Bands never share rows, so parallel - * branches cannot overlap — the canvas just grows (and scrolls) instead. */ +/** 渲染方向(FV0):时序沿哪条轴、朝哪边推进 —— 「最新的一拍」永远落在画布坐标**最小**的一侧, + * 于是容器默认露出的那一角就是最新的时刻。 + * - `newest-top`:时序**自下而上**,泳道沿垂直方向(向右)并排 —— 整页 Flow 用; + * 画布纵向自动拓高(向下滚动看历史,向上看最新)。 + * - `newest-left`:时序**自右向左**,泳道向下叠加 —— 嵌在页面里的图块(Backthink / Agents) + * 用;横向滚动容器的可视区默认露出最左边,因此最新在最左。 + * + * 两种方向**共用同一套排布**:先按「时序轴(step)× 堆叠轴(stack)」算抽象网格, + * 再由 `toPixelNode` / `toPixelBand` 一次性映射成像素(`newest-top` 把两轴对调)。 + * 方向只影响这一处映射与边的锚点(见 flow/edges.ts),**不复制第二套布局**。 */ +export type FlowOrientation = "newest-top" | "newest-left"; export const NODE_W = 148; export const NODE_H = 52; export const GAP_X = 64; export const GAP_Y = 30; export const PAD = 20; -/** Vertical gap between two bands. */ +/** Gap between two bands, along the stack axis. */ export const BAND_GAP = 26; /** Padding between a lane frame and the nodes it holds. */ export const FRAME_PAD = 10; @@ -51,8 +57,11 @@ export interface FlowLayout { edges: PlacedEdge[]; bands: PlacedBand[]; lanes: FlowLane[]; + /** 画布像素尺寸:`newest-left` 是「宽 × 高」,`newest-top` 是同一块网格转置后的「宽 × 高」。 */ width: number; height: number; + /** 本次布局用的方向(边的锚点由渲染层按它决定,回传以避免两处口径不一致)。 */ + orientation: FlowOrientation; } interface BandPlan { @@ -68,22 +77,89 @@ interface BandPlan { toCol: number; } -/** layoutFlow computes the lane-banded geometry of a graph. */ -export function layoutFlow(graph: FlowGraph): FlowLayout { +/** 轴度量:时序轴(step)与堆叠轴(stack)各用哪套尺寸/间隙。 + * `newest-top` 把两轴对调 ⇒ 节点在时序轴上占的是**高度**,在堆叠轴上占的是**宽度**。 */ +interface AxisMetric { + stepLen: number; + stepGap: number; + stackLen: number; + stackGap: number; +} + +/** 抽象网格坐标(尚未决定横竖),像素化在 toPixelNode / toPixelBand 里做。 */ +interface GridNode { + node: FlowNode; + step: number; + stack: number; +} + +interface GridBox { + id?: string; + label: string; + status: AgentStatus; + stepStart: number; + stepSize: number; + stackStart: number; + stackSize: number; + framed: boolean; +} + +interface FlowGrid { + nodes: GridNode[]; + boxes: GridBox[]; + stackExtent: number; +} + +/** layoutFlow computes the lane-banded geometry of a graph in the given direction. + * 默认 `newest-left`:嵌在页面里的图块(Backthink / Agents)用横向、最新在左; + * 整页 Flow 显式传 `newest-top`(时序自下而上、容器纵向自动拓高)。 */ +export function layoutFlow( + graph: FlowGraph, + orientation: FlowOrientation = "newest-left", +): FlowLayout { const grouping = groupFlowLanes(graph); if (grouping.byId.size === 0) { - return { nodes: [], edges: [], bands: [], lanes: [], width: PAD * 2, height: PAD * 2 }; + return { nodes: [], edges: [], bands: [], lanes: [], width: PAD * 2, height: PAD * 2, orientation }; } + const metric = axisMetric(orientation); const index = orderIndex(grouping.nodes); const plans = planBands(grouping); - const { nodes, bands, width, height } = placeBands(plans, index); + const maxCol = canvasMaxCol(plans); + const grid = buildGrid(plans, index, metric, maxCol); + const nodes = grid.nodes.map((n) => toPixelNode(n, orientation)); + const bands = grid.boxes.map((b) => toPixelBand(b, orientation)); const placed = new Set(nodes.map((p) => p.node.id)); const edges: PlacedEdge[] = grouping.edges .filter((e) => placed.has(e.from) && placed.has(e.to)) .map((edge) => ({ edge, lane: grouping.bandOf.get(edge.to) })); - return { nodes, edges, bands, lanes: grouping.lanes, width, height }; + const stepExtent = axisExtent(maxCol, metric.stepLen, metric.stepGap); + const size = + orientation === "newest-top" + ? { width: grid.stackExtent, height: stepExtent } + : { width: stepExtent, height: grid.stackExtent }; + + return { nodes, edges, bands, lanes: grouping.lanes, ...size, orientation }; +} + +/** axisMetric picks which pixel size / gap each axis uses for the direction. */ +function axisMetric(orientation: FlowOrientation): AxisMetric { + return orientation === "newest-top" + ? { stepLen: NODE_H, stepGap: GAP_Y, stackLen: NODE_W, stackGap: GAP_X } + : { stepLen: NODE_W, stepGap: GAP_X, stackLen: NODE_H, stackGap: GAP_Y }; +} + +/** axisExtent is the total pixel length of one axis (PAD on both ends). */ +function axisExtent(maxCol: number, len: number, gap: number): number { + return PAD * 2 + (maxCol + 1) * len + maxCol * gap; +} + +/** canvasMaxCol is the last column the canvas must cover: the widest band, including the + * minimum span of an idle lane — otherwise a lane frame that just opened (no step yet) + * would be drawn outside the canvas and clipped. */ +function canvasMaxCol(plans: BandPlan[]): number { + return plans.reduce((max, p) => Math.max(max, p.toCol), 0); } /** orderIndex maps every node id to its graph-order position. */ @@ -138,25 +214,25 @@ function planBands(grouping: FlowGrouping): BandPlan[] { return plans; } -/** placeBands turns the plans into pixel geometry: bands stack top-down (lanes - * framed, with a header row for their label) and every node gets its own slot. */ -function placeBands( +/** buildGrid lays the planned bands onto the abstract step × stack grid: bands are + * placed one after another along the stack axis (lane frames carry a label row), and + * the siblings of one step occupy successive slots along that same axis. */ +function buildGrid( plans: BandPlan[], index: Map, -): { nodes: PlacedNode[]; bands: PlacedBand[]; width: number; height: number } { - const pos = new Map(); - const bands: PlacedBand[] = []; - /** Top of the next band (its frame top, header included). */ - let limitY = PAD; - let contentBottom = PAD; - let maxCol = 0; + metric: AxisMetric, + maxCol: number, +): FlowGrid { + const nodes: GridNode[] = []; + const boxes: GridBox[] = []; + const stepPitch = metric.stepLen + metric.stepGap; + const stackPitch = metric.stackLen + metric.stackGap; + let stackCursor = PAD; plans.forEach((plan) => { const framed = plan.id !== undefined; - const frameY = limitY; - const contentY = framed ? frameY + LANE_HEADER + FRAME_PAD : frameY; - const bandHeight = rowsHeight(plan.rows); - const frameHeight = framed ? bandHeight + LANE_HEADER + FRAME_PAD * 2 : bandHeight; + const stackSize = bandStackSize(plan.rows, metric, framed); + const contentStack = stackCursor + (framed ? LANE_HEADER + FRAME_PAD : 0); const byCol = new Map(); plan.members.forEach((n) => { @@ -164,41 +240,64 @@ function placeBands( const arr = byCol.get(col) ?? []; arr.push(n); byCol.set(col, arr); - maxCol = Math.max(maxCol, col); }); byCol.forEach((arr, col) => { arr .sort((a, b) => index.get(a.id)! - index.get(b.id)!) .forEach((n, row) => { - pos.set(n.id, { + nodes.push({ node: n, - x: PAD + col * (NODE_W + GAP_X), - y: contentY + row * (NODE_H + GAP_Y), + step: stepPos(col, maxCol, stepPitch), + stack: contentStack + row * stackPitch, }); }); }); - bands.push({ + boxes.push({ id: plan.id, label: plan.lane ? plan.lane.root.label : "主干", status: plan.lane ? plan.lane.status : "idle", - x: PAD + plan.fromCol * (NODE_W + GAP_X) - (framed ? FRAME_PAD : 0), - y: frameY, - width: (plan.toCol - plan.fromCol + 1) * (NODE_W + GAP_X) - GAP_X + (framed ? FRAME_PAD * 2 : 0), - height: frameHeight, + stepStart: stepPos(plan.toCol, maxCol, stepPitch) - (framed ? FRAME_PAD : 0), + stepSize: (plan.toCol - plan.fromCol) * stepPitch + metric.stepLen + (framed ? FRAME_PAD * 2 : 0), + stackStart: stackCursor, + stackSize, framed, }); - contentBottom = frameY + frameHeight; - limitY = contentBottom + BAND_GAP; + stackCursor += stackSize + BAND_GAP; }); - return { - nodes: [...pos.values()].sort((a, b) => index.get(a.node.id)! - index.get(b.node.id)!), - bands, - width: PAD * 2 + (maxCol + 1) * NODE_W + maxCol * GAP_X, - height: Math.max(contentBottom + PAD, PAD * 2), - }; + nodes.sort((a, b) => index.get(a.node.id)! - index.get(b.node.id)!); + return { nodes, boxes, stackExtent: stackCursor - BAND_GAP + PAD }; +} + +/** stepPos projects a step (column) onto the step axis: the bigger the column, the newer + * the moment, the **smaller** the coordinate — newest ends up at the top / on the left. */ +function stepPos(col: number, maxCol: number, stepPitch: number): number { + return PAD + (maxCol - col) * stepPitch; +} + +/** bandStackSize is the size a band takes on the stack axis (a lane frame also carries + * its label row and the inner padding). */ +function bandStackSize(rows: number, metric: AxisMetric, framed: boolean): number { + const content = rows * metric.stackLen + (rows - 1) * metric.stackGap; + return framed ? content + LANE_HEADER + FRAME_PAD * 2 : content; +} + +/** toPixelNode is the only direction switch for nodes: `newest-top` swaps both axes. */ +function toPixelNode(grid: GridNode, orientation: FlowOrientation): PlacedNode { + return orientation === "newest-top" + ? { node: grid.node, x: grid.stack, y: grid.step } + : { node: grid.node, x: grid.step, y: grid.stack }; +} + +/** toPixelBand is the same direction switch for band frames. */ +function toPixelBand(box: GridBox, orientation: FlowOrientation): PlacedBand { + const rect = + orientation === "newest-top" + ? { x: box.stackStart, y: box.stepStart, width: box.stackSize, height: box.stepSize } + : { x: box.stepStart, y: box.stackStart, width: box.stepSize, height: box.stackSize }; + return { id: box.id, label: box.label, status: box.status, framed: box.framed, ...rect }; } /** computeBandColumns fills a band's per-node depth (longest path inside the @@ -235,8 +334,3 @@ function computeBandColumns(plan: BandPlan, edges: FlowEdge[]): void { }); plan.rows = perCol.size > 0 ? Math.max(...perCol.values()) : 1; } - -/** rowsHeight is the pixel height of rows stacked node cards. */ -function rowsHeight(rows: number): number { - return rows * NODE_H + (rows - 1) * GAP_Y; -} diff --git a/frontend/web/src/admin/modules/flow/orientation.render.test.ts b/frontend/web/src/admin/modules/flow/orientation.render.test.ts new file mode 100644 index 0000000..7bbdae4 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/orientation.render.test.ts @@ -0,0 +1,332 @@ +// 方向(FV0)的**真实渲染**测试:主人报的是「看得见」的体验(最新的必须在最上 / 最左、 +// 容器高度要跟着图长),所以这里不信逻辑返回值 —— 直接从 SSR 出来的 SVG 里把**节点矩形** +// 与**边路径**解析出来,核对: +// ① Flow 页(整页)最新的一拍在**最上**(时序自下而上); +// ② 主动思考 / Agents 的图块保持横向渲染,但最新的一拍在**最左**; +// ③ 每条边都连在**正确的两个节点**之间(端点贴在正确节点的正确边框中点,箭头指向更新的那端); +// ④ 画布高度随图生长、容器不限高(不内部裁切)。 +// +// 本项目没有 jsdom ⇒ 用 react-dom/server 渲染 **props 驱动**的组件(同 +// flowSnapshot.render.test.ts 头注释的理由)。 +// +// 跑法: +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/flow/orientation.render.test.ts \ +// --bundle --platform=node --format=cjs \ +// --outfile=/tmp/cyrene-flow-orientation-model.test.cjs \ +// && node /tmp/cyrene-flow-orientation-model.test.cjs +// +// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 + +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { FlowEdge, FlowGraph, FlowNode } from "../../../lib/types"; +import { BACKTHINK_SESSION_ID, MAIN_SESSION_ID } from "../../../store/sessionCache"; +import { AgentsFlowBlock } from "../Agents"; +import { BackthinkGraphBlock } from "../Backthink"; +import { FlowBody } from "../Flow"; +import { NODE_H, NODE_W, layoutFlow } from "./layout"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function has(haystack: string, needle: string): boolean { + return haystack.includes(needle); +} + +function node(id: string, label: string, kind: string): FlowNode { + return { id, label, kind, status: "done" }; +} + +function graph(nodes: FlowNode[], edges: FlowEdge[]): FlowGraph { + return { id: "web-fv0", sessionId: MAIN_SESSION_ID, nodes, edges }; +} + +/** 「输入 → 思考 → 工具 → 回复」主干;追加一拍用来验证画布继续长高。 */ +function chain(extra = 0): FlowGraph { + const nodes = [ + node("input", "哨兵输入", "input"), + node("llm1", "哨兵思考", "llm"), + node("tool1", "哨兵工具", "tool"), + node("output", "哨兵回复", "output"), + ]; + const edges: FlowEdge[] = [ + { from: "input", to: "llm1" }, + { from: "llm1", to: "tool1" }, + { from: "tool1", to: "output" }, + ]; + for (let i = 0; i < extra; i += 1) { + const prev = nodes[nodes.length - 1].id; + nodes.push(node(`output${i + 2}`, `哨兵追加${i + 1}`, "output")); + edges.push({ from: prev, to: `output${i + 2}` }); + } + return graph(nodes, edges); +} + +/** 主干 + 一条并行子代理泳道。 */ +function withLane(): FlowGraph { + return graph( + [ + node("input", "哨兵输入", "input"), + node("t1", "哨兵工具", "tool"), + node("subA", "哨兵子代理", "subagent"), + node("llmA", "哨兵子思考", "llm"), + node("toolA", "哨兵子工具", "tool"), + node("output", "哨兵回复", "output"), + ], + [ + { from: "input", to: "t1" }, + { from: "t1", to: "subA" }, + { from: "subA", to: "llmA" }, + { from: "llmA", to: "toolA" }, + { from: "t1", to: "output" }, + ], + ); +} + +/** canvasOf slices out just the graph canvas: the page markup also carries Icon s + * whose paths would otherwise be mistaken for graph edges. */ +function canvasOf(markup: string): string { + const start = markup.indexOf("data-flow-canvas"); + ok(start >= 0, "必须渲染出图布容器"); + const end = markup.indexOf("", start); + ok(end > start, "画布必须含 svg"); + return markup.slice(start, end); +} + +/** rectOf reads the坐标 of the card of the node whose label is `label` + * (the title line precedes the card rect in the markup). */ +function rectOf(markup: string, label: string): { x: number; y: number } { + const canvas = canvasOf(markup); + const anchor = canvas.indexOf(`${label} · `); + ok(anchor >= 0, `节点 ${label} 必须被画出来`); + const m = /<rect x="(-?[\d.]+)" y="(-?[\d.]+)"/.exec(canvas.slice(anchor)); + ok(m !== null, `节点 ${label} 必须有坐标`); + return { x: Number(m![1]), y: Number(m![2]) }; +} + +/** edgePaths returns the rendered cubic edge paths, in draw order (marker shapes + * use a straight "L" path and are therefore skipped). */ +function edgePaths(markup: string): string[] { + const canvas = canvasOf(markup); + const out: string[] = []; + const re = /d="(M -?[\d.]+ -?[\d.]+ C [^"]+)"/g; + let m = re.exec(canvas); + while (m !== null) { + out.push(m[1]); + m = re.exec(canvas); + } + return out; +} + +/** curve parses a cubic path into anchors + control points. */ +function curve(d: string): { + sx: number; + sy: number; + c1x: number; + c1y: number; + c2x: number; + c2y: number; + tx: number; + ty: number; +} { + const [head, rest] = d.replace("M ", "").split(" C "); + const [sx, sy] = head.split(" ").map(Number); + const [c1, c2, tail] = rest.split(", "); + const [c1x, c1y] = c1.split(" ").map(Number); + const [c2x, c2y] = c2.split(" ").map(Number); + const [tx, ty] = tail.split(" ").map(Number); + return { sx, sy, c1x, c1y, c2x, c2y, tx, ty }; +} + +/** tagAt returns the whole opening tag that carries `needle` (for class inspection). */ +function tagAt(markup: string, needle: string): string { + const at = markup.indexOf(needle); + ok(at >= 0, `必须存在带 ${needle} 的容器`); + const start = markup.lastIndexOf("<", at); + const end = markup.indexOf(">", at); + return markup.slice(start, end + 1); +} + +/** svgSize reads the rendered canvas size (of the graph canvas, not an Icon). */ +function svgSize(markup: string): { width: number; height: number } { + const m = /<svg width="(-?[\d.]+)" height="(-?[\d.]+)"/.exec(canvasOf(markup)); + ok(m !== null, "必须画出 svg 画布"); + return { width: Number(m![1]), height: Number(m![2]) }; +} + +/** checkContainer asserts the canvas grows with the graph and never clips vertically. */ +function checkContainer(markup: string, orientation: "newest-top" | "newest-left", what: string): void { + const tag = tagAt(markup, "data-flow-canvas"); + ok(has(tag, "overflow-x-auto"), `${what}:画布只在横向需要时滚动`); + ok(!has(tag, "max-h") && !has(tag, "overflow-hidden"), `${what}:容器不得限高/裁切`); + ok(!has(tag, "overflow-y"), `${what}:容器纵向随图生长(不内部滚动截断)`); + ok(tag.indexOf("<svg") < 0, `${what}:画布容器是外层 div,不把限高带进 svg`); + void orientation; +} + +/** checkEdges asserts每一条边都画在正确的两个节点之间(逐边核对端点 id 对应的矩形)。 */ +function checkEdges( + markup: string, + g: FlowGraph, + orientation: "newest-top" | "newest-left", + what: string, +): void { + const paths = edgePaths(markup); + ok( + paths.length === g.edges.length, + `${what}:${g.edges.length} 条边必须全部画出来(实际 ${paths.length})`, + ); + const labelOf = new Map(g.nodes.map((n) => [n.id, n.label])); + g.edges.forEach((e, i) => { + const from = rectOf(markup, labelOf.get(e.from)!); + const to = rectOf(markup, labelOf.get(e.to)!); + const c = curve(paths[i]); + if (orientation === "newest-top") { + ok( + c.sx === from.x + NODE_W / 2 && c.sy === from.y, + `${what}:边 ${i}(${e.from}→${e.to})起点贴在 ${e.from} 的上边中点`, + ); + ok( + c.tx === to.x + NODE_W / 2 && c.ty === to.y + NODE_H, + `${what}:边 ${i}(${e.from}→${e.to})终点贴在 ${e.to} 的下边中点`, + ); + ok(c.c1y < c.sy && c.c2y > c.ty, `${what}:边 ${i} 箭头自下而上,指向更新的节点`); + } else { + ok( + c.sx === from.x && c.sy === from.y + NODE_H / 2, + `${what}:边 ${i}(${e.from}→${e.to})起点贴在 ${e.from} 的左边中点`, + ); + ok( + c.tx === to.x + NODE_W && c.ty === to.y + NODE_H / 2, + `${what}:边 ${i}(${e.from}→${e.to})终点贴在 ${e.to} 的右边中点`, + ); + ok(c.c1x < c.sx && c.c2x > c.tx, `${what}:边 ${i} 箭头自右向左,指向更新的节点`); + } + }); +} + +// ── ① 全链路流程页:时序自下而上,最新在最上,容器只向高处生长 ──────────────── + +{ + const g = chain(); + const html = renderToStaticMarkup( + createElement(FlowBody, { graph: g, sessionId: MAIN_SESSION_ID, source: "live" }), + ); + ok(has(html, "哨兵回复") && has(html, "哨兵输入"), "Flow 页把该轮的节点都画出来"); + + const yInput = rectOf(html, "哨兵输入").y; + const yLlm = rectOf(html, "哨兵思考").y; + const yTool = rectOf(html, "哨兵工具").y; + const yOut = rectOf(html, "哨兵回复").y; + ok(yOut < yTool && yTool < yLlm && yLlm < yInput, "Flow 页:最新的一拍在最上,旧的在下面"); + ok( + rectOf(html, "哨兵输入").x === rectOf(html, "哨兵回复").x, + "Flow 页:时序沿纵向推进(同一拍不横向铺开)", + ); + ok(has(html, "最新在最上"), "Flow 页必须写明「最新在最上」(方向反了要让人看得懂)"); + ok(has(html, "时序自下而上"), "Flow 页必须写明时序自下而上"); + + const size = svgSize(html); + const layout = layoutFlow(g, "newest-top"); + ok(size.height === layout.height, "Flow 页画布高度 = 布局高度(图多高就多高)"); + ok(size.width === layout.width, "Flow 页画布宽度 = 布局宽度"); + ok(yOut === layout.nodes.find((n) => n.node.id === "output")!.y, "Flow 页最新节点的 y 就是布局给的上沿"); + ok(yInput + NODE_H <= size.height, "Flow 页最旧节点的下沿仍在画布内(没有裁掉历史)"); + checkContainer(html, "newest-top", "Flow 页"); + checkEdges(html, g, "newest-top", "Flow 页"); + + const taller = renderToStaticMarkup( + createElement(FlowBody, { graph: chain(1), sessionId: MAIN_SESSION_ID, source: "live" }), + ); + ok( + svgSize(taller).height > size.height, + "Flow 页:多一拍 ⇒ 容器更高(高度自动拓高,不是固定框内裁切)", + ); +} + +// ── ② 主动思考页的图块:保持横向渲染,但最新在最左 ─────────────────────────── + +{ + const g = chain(); + const html = renderToStaticMarkup( + createElement(BackthinkGraphBlock, { + graph: g, + sessionId: BACKTHINK_SESSION_ID, + source: "snapshot", + wsStatus: "connected", + }), + ); + const xInput = rectOf(html, "哨兵输入").x; + const xLlm = rectOf(html, "哨兵思考").x; + const xTool = rectOf(html, "哨兵工具").x; + const xOut = rectOf(html, "哨兵回复").x; + ok(xOut < xTool && xTool < xLlm && xLlm < xInput, "后台思考图块:最新的一拍在最左,旧的在右边"); + ok( + rectOf(html, "哨兵输入").y === rectOf(html, "哨兵回复").y, + "后台思考图块:仍是横向渲染(同一拍同一行),没有改成竖版", + ); + ok(has(html, "最新在最左"), "后台思考图块必须写明「最新在最左」"); + ok(has(html, "时序自右向左"), "后台思考图块必须写明时序自右向左"); + + const layout = layoutFlow(g, "newest-left"); + ok(rectOf(html, "哨兵回复").x === layout.nodes.find((n) => n.node.id === "output")!.x, "图块最新节点的 x 就是布局给的最左端"); + checkContainer(html, "newest-left", "后台思考图块"); + checkEdges(html, g, "newest-left", "后台思考图块"); + + // Agents 页的图块同一档(同一套布局代码、同一个方向值)。 + const agents = renderToStaticMarkup( + createElement(AgentsFlowBlock, { graph: g, sessionId: MAIN_SESSION_ID, source: "live" }), + ); + ok(has(agents, "最新在最左"), "Agents 页图块与后台思考图块同一方向(最新在最左)"); + checkEdges(agents, g, "newest-left", "Agents 页图块"); +} + +// ── ③ 有泳道时:方向仍成立、泳道仍成道、边仍连对 ───────────────────────────── + +{ + const g = withLane(); + const top = renderToStaticMarkup( + createElement(FlowBody, { graph: g, sessionId: MAIN_SESSION_ID, source: "live" }), + ); + ok(has(top, "泳道 · 哨兵子代理"), "Flow 页必须把子代理画成独立泳道"); + const frame = /<rect x="(-?[\d.]+)" y="(-?[\d.]+)" width="([\d.]+)" height="([\d.]+)"[^>]*stroke-dasharray="6 4"/.exec(top); + ok(frame !== null, "泳道必须有框"); + const fx = Number(frame![1]); + const fy = Number(frame![2]); + const fw = Number(frame![3]); + const fh = Number(frame![4]); + ["哨兵子思考", "哨兵子工具"].forEach((label) => { + const r = rectOf(top, label); + ok( + r.x >= fx && r.x + NODE_W <= fx + fw && r.y >= fy && r.y + NODE_H <= fy + fh, + `Flow 页:${label} 落在自己的泳道框内(方向反转后仍成道)`, + ); + }); + ok( + rectOf(top, "哨兵子思考").y < rectOf(top, "哨兵子代理").y, + "Flow 页:泳道内的步骤比泳道根更新(更靠上)", + ); + checkEdges(top, g, "newest-top", "Flow 页(带泳道)"); + + const left = renderToStaticMarkup( + createElement(BackthinkGraphBlock, { + graph: g, + sessionId: BACKTHINK_SESSION_ID, + source: "live", + wsStatus: "connected", + }), + ); + ok( + rectOf(left, "哨兵子思考").x < rectOf(left, "哨兵子代理").x, + "后台思考图块:泳道内的步骤比泳道根更新(更靠左)", + ); + checkEdges(left, g, "newest-left", "后台思考图块(带泳道)"); +} + +console.log(`flow orientation render test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/flow/orientation.test.ts b/frontend/web/src/admin/modules/flow/orientation.test.ts new file mode 100644 index 0000000..3b0750c --- /dev/null +++ b/frontend/web/src/admin/modules/flow/orientation.test.ts @@ -0,0 +1,337 @@ +// 方向(FV0)逻辑测试:两种方向**共用同一套布局代码**(flow/layout.ts 的 `FlowOrientation`), +// 这里钉住四条不能退让的事实: +// ① 时序朝向 —— `newest-top` 最新的节点在**最上**,`newest-left` 最新的在**最左**; +// ② 画布随图生长且**不裁切** —— 每个节点、每个泳道框都落在画布尺寸内; +// ③ 连线正确性 —— 每条边仍连在**正确的两个节点**之间(逐边核对端点 id 与像素锚点), +// 端点缺一的边如实丢弃、不伪造; +// ④ 泳道仍成道 —— 泳道框罩住自己的成员,且落在主干**更新的一侧**。 +// +// 跑法(同 lanes.test.ts,用 vite 自带的 esbuild,不加依赖): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/flow/orientation.test.ts \ +// --bundle --platform=node --format=esm \ +// --outfile=/tmp/cyrene-flow-orientation.test.mjs \ +// && node /tmp/cyrene-flow-orientation.test.mjs +// +// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 + +import { GAP_X, GAP_Y, NODE_H, NODE_W, PAD, layoutFlow, type FlowLayout } from "./layout"; +import { edgeShapes } from "./edges"; +import type { FlowEdge, FlowGraph, FlowNode } from "../../../lib/types"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function same<T>(got: T, want: T, what: string): void { + const g = JSON.stringify(got); + const w = JSON.stringify(want); + ok(g === w, `${what} — got ${g}, want ${w}`); +} + +function node(id: string, kind: string, status?: FlowNode["status"]): FlowNode { + return { id, label: `节点-${id}`, kind, status }; +} + +function graph(nodes: FlowNode[], edges: FlowEdge[]): FlowGraph { + return { id: "web-fv0", nodes, edges, sessionId: "web" }; +} + +/** 一条「输入 → 思考 → 工具 → 回复」的主干链(4 拍,无泳道)。 */ +function chain(extra = 0): FlowGraph { + const nodes = [ + node("input", "input", "done"), + node("llm1", "llm", "done"), + node("tool1", "tool", "done"), + node("output", "output", "done"), + ]; + const edges: FlowEdge[] = [ + { from: "input", to: "llm1" }, + { from: "llm1", to: "tool1" }, + { from: "tool1", to: "output" }, + ]; + for (let i = 0; i < extra; i += 1) { + const prev = nodes[nodes.length - 1].id; + const id = `output${i + 2}`; + nodes.push(node(id, "output", "done")); + edges.push({ from: prev, to: id }); + } + return graph(nodes, edges); +} + +/** 主干 + 一条并行子代理泳道(泳道的每一步都连在泳道根之后)。 */ +function withLane(): FlowGraph { + return graph( + [ + node("input", "input", "done"), + node("t1", "tool", "done"), + node("subA", "subagent", "running"), + node("llmA", "llm", "running"), + node("toolA", "tool", "done"), + node("output", "output", "done"), + ], + [ + { from: "input", to: "t1" }, + { from: "t1", to: "subA" }, + { from: "subA", to: "llmA" }, + { from: "llmA", to: "toolA" }, + { from: "t1", to: "output" }, + ], + ); +} + +/** 泳道只开了头、还没有步骤(实时图上很常见)——泳道框不能被画到画布外。 */ +function idleLane(): FlowGraph { + return graph( + [node("input", "input", "done"), node("subA", "subagent", "running")], + [{ from: "input", to: "subA" }], + ); +} + +function at(layout: FlowLayout): Map<string, { x: number; y: number }> { + return new Map(layout.nodes.map((p) => [p.node.id, { x: p.x, y: p.y }])); +} + +/** fits asserts every card / band frame lies inside the canvas (nothing is clipped). */ +function fits(layout: FlowLayout, what: string): void { + layout.nodes.forEach((p) => { + ok( + p.x >= 0 && p.y >= 0 && p.x + NODE_W <= layout.width && p.y + NODE_H <= layout.height, + `${what}: 节点 ${p.node.id} 必须整块落在画布内(不裁切)`, + ); + }); + layout.bands.forEach((b) => { + ok( + b.x >= 0 && b.y >= 0 && b.x + b.width <= layout.width && b.y + b.height <= layout.height, + `${what}: 泳道框 ${b.id ?? "主干"} 必须整块落在画布内(不裁切)`, + ); + }); +} + +/** curve parses a cubic path back into its two anchors and two control points. */ +function curve(d: string): { + sx: number; + sy: number; + c1x: number; + c1y: number; + c2x: number; + c2y: number; + tx: number; + ty: number; +} { + const [head, rest] = d.replace("M ", "").split(" C "); + const [sx, sy] = head.split(" ").map(Number); + const [c1, c2, tail] = rest.split(", "); + const [c1x, c1y] = c1.split(" ").map(Number); + const [c2x, c2y] = c2.split(" ").map(Number); + const [tx, ty] = tail.split(" ").map(Number); + return { sx, sy, c1x, c1y, c2x, c2y, tx, ty }; +} + +// ── ① 时序方向:newest-top 最新在上;newest-left 最新在左 ────────────────────── + +{ + const g = chain(); + const top = layoutFlow(g, "newest-top"); + const left = layoutFlow(g, "newest-left"); + same(top.orientation, "newest-top", "newest-top 布局回传自己的方向"); + same(left.orientation, "newest-left", "newest-left 布局回传自己的方向"); + + // 两种方向画的是同一张图:节点集合/数量/顺序分毫不差(不丢节点、不造节点)。 + same( + top.nodes.map((p) => p.node.id), + left.nodes.map((p) => p.node.id), + "方向不改变节点集合与顺序", + ); + same(top.nodes.length, g.nodes.length, "方向不丢节点"); + same(left.bands.length, top.bands.length, "方向不改变泳道/主干的分带"); + + const t = at(top); + same( + [t.get("input")!.y, t.get("llm1")!.y, t.get("tool1")!.y, t.get("output")!.y], + [PAD + 3 * (NODE_H + GAP_Y), PAD + 2 * (NODE_H + GAP_Y), PAD + (NODE_H + GAP_Y), PAD], + "newest-top:时序自下而上,最新的一拍贴画布上沿", + ); + ok( + t.get("input")!.y > t.get("llm1")!.y && + t.get("llm1")!.y > t.get("tool1")!.y && + t.get("tool1")!.y > t.get("output")!.y, + "newest-top:越新越靠上(最新一定在最上方)", + ); + same( + [t.get("input")!.x, t.get("llm1")!.x, t.get("tool1")!.x, t.get("output")!.x], + [PAD, PAD, PAD, PAD], + "newest-top:单拍主干占一列(横向不铺开)", + ); + same( + [top.width, top.height], + [PAD * 2 + NODE_W, PAD * 2 + 4 * NODE_H + 3 * GAP_Y], + "newest-top:画布 = 单列宽 × 四拍高(容器纵向自动拓高)", + ); + + const l = at(left); + same( + [l.get("input")!.x, l.get("llm1")!.x, l.get("tool1")!.x, l.get("output")!.x], + [PAD + 3 * (NODE_W + GAP_X), PAD + 2 * (NODE_W + GAP_X), PAD + (NODE_W + GAP_X), PAD], + "newest-left:时序自右向左,最新的一拍贴画布左沿", + ); + ok( + l.get("input")!.x > l.get("llm1")!.x && + l.get("llm1")!.x > l.get("tool1")!.x && + l.get("tool1")!.x > l.get("output")!.x, + "newest-left:越新越靠左(容器默认露出最左边 ⇒ 先看到最新)", + ); + same( + [l.get("input")!.y, l.get("llm1")!.y, l.get("tool1")!.y, l.get("output")!.y], + [PAD, PAD, PAD, PAD], + "newest-left:单拍主干占一行(保持横向渲染方式)", + ); + same( + [left.width, left.height], + [PAD * 2 + 4 * NODE_W + 3 * GAP_X, PAD * 2 + NODE_H], + "newest-left:画布 = 四拍宽 × 单行高", + ); + + fits(top, "newest-top 主干"); + fits(left, "newest-left 主干"); +} + +// ── ② 容器随图生长:多一拍就多一段高度,且永远装得下 ───────────────────────── + +{ + const four = layoutFlow(chain(), "newest-top"); + const five = layoutFlow(chain(1), "newest-top"); + same( + five.height - four.height, + NODE_H + GAP_Y, + "newest-top:多一拍 ⇒ 画布高一个「节点高 + 行距」,不挤在固定框里", + ); + same(five.width, four.width, "newest-top:多一拍不改变横向尺寸(主干是一条竖列)"); + fits(five, "newest-top 五拍"); + fits(layoutFlow(chain(1), "newest-left"), "newest-left 五拍"); + + // 只开了头、还没有步骤的泳道:框也必须落在画布内(旧代码会把它画出画布右沿)。 + fits(layoutFlow(idleLane(), "newest-top"), "newest-top 空泳道"); + fits(layoutFlow(idleLane(), "newest-left"), "newest-left 空泳道"); +} + +// ── ③ 连线正确性:逐边核对端点 id 与像素锚点(两个方向都核对) ─────────────── + +{ + const g = withLane(); + (["newest-top", "newest-left"] as const).forEach((orientation) => { + const layout = layoutFlow(g, orientation); + const placed = at(layout); + const shapes = edgeShapes(layout); + + same( + shapes.map((s) => `${s.fromId}>${s.toId}`), + g.edges.map((e) => `${e.from}>${e.to}`), + `${orientation}:每条边都画出来,且顺序与图数据一致(不丢边、不重排)`, + ); + + shapes.forEach((s) => { + const from = placed.get(s.fromId); + const to = placed.get(s.toId); + ok(from !== undefined && to !== undefined, `${orientation}:边 ${s.fromId}>${s.toId} 的端点都在图里`); + const c = curve(s.d); + same([c.sx, c.sy], [s.sx, s.sy], `${orientation}:path 起点 = 声明起点`); + if (orientation === "newest-top") { + // 更早那拍在上方节点的**下沿**,更新那拍从**下沿**进 —— 箭头指向时间前进的方向。 + same( + [c.sx, c.sy], + [from!.x + NODE_W / 2, from!.y], + `${orientation}:边 ${s.fromId}>${s.toId} 起点贴在 ${s.fromId}(更早那拍)的上边中点`, + ); + same( + [c.tx, c.ty], + [to!.x + NODE_W / 2, to!.y + NODE_H], + `${orientation}:边 ${s.fromId}>${s.toId} 终点贴在 ${s.toId}(更新那拍)的下边中点`, + ); + ok(c.c1y < c.sy && c.c2y > c.ty, `${orientation}:箭头自下而上,指向更新的节点`); + } else { + same( + [c.sx, c.sy], + [from!.x, from!.y + NODE_H / 2], + `${orientation}:边 ${s.fromId}>${s.toId} 起点贴在 ${s.fromId}(更早那拍)的左边中点`, + ); + same( + [c.tx, c.ty], + [to!.x + NODE_W, to!.y + NODE_H / 2], + `${orientation}:边 ${s.fromId}>${s.toId} 终点贴在 ${s.toId}(更新那拍)的右边中点`, + ); + ok(c.c1x < c.sx && c.c2x > c.tx, `${orientation}:箭头自右向左,指向更新的节点`); + } + }); + + // 泳道内的边仍然标成泳道边(虚线 + accent),主干边仍是实线。 + const byKey = new Map(shapes.map((s) => [`${s.fromId}>${s.toId}`, s.inLane])); + same(byKey.get("subA>llmA"), true, `${orientation}:泳道内步骤仍是泳道边`); + same(byKey.get("t1>output"), false, `${orientation}:主干续接仍是主干边`); + }); + + // 端点缺失的边如实丢弃:3 条边里 1 条悬空 ⇒ 只画 2 条(不伪造节点补上)。 + const dangling = graph( + [node("a", "llm", "done"), node("b", "output", "done")], + [ + { from: "a", to: "ghost" }, + { from: "a", to: "b" }, + ], + ); + same( + edgeShapes(layoutFlow(dangling, "newest-top")).map((s) => `${s.fromId}>${s.toId}`), + ["a>b"], + "端点缺失的边被丢掉,不伪造", + ); +} + +// ── ④ 泳道仍成道:泳道框罩住自己的成员,且落在主干更新的一侧 ───────────────── + +{ + const g = withLane(); + const top = layoutFlow(g, "newest-top"); + same(top.lanes.map((l) => l.id), ["subA"], "子代理各自成道"); + const framed = top.bands.filter((b) => b.framed); + same(framed.length, 1, "泳道有独立的框"); + const t = at(top); + const frame = framed[0]; + ok(frame.x > t.get("input")!.x, "newest-top:泳道在主干右侧(时序轴之外并排),不挤占时序轴"); + ["llmA", "toolA"].forEach((id) => { + const p = t.get(id)!; + ok( + p.x >= frame.x && p.x + NODE_W <= frame.x + frame.width && + p.y >= frame.y && p.y + NODE_H <= frame.y + frame.height, + `newest-top:${id} 落在自己的泳道框内(仍成道)`, + ); + }); + ok(t.get("llmA")!.y < t.get("subA")!.y, "newest-top:泳道第一步比泳道根更新(更靠上)"); + ok(t.get("toolA")!.y < t.get("llmA")!.y, "newest-top:泳道内链路继续向新的一端推进"); + fits(top, "newest-top 带泳道"); + + const left = layoutFlow(g, "newest-left"); + const l = at(left); + const lane = left.bands.filter((b) => b.framed)[0]; + ok(lane.y > l.get("input")!.y + NODE_H, "newest-left:泳道叠在主干下方,不与主干同拍混排"); + ["llmA", "toolA"].forEach((id) => { + const p = l.get(id)!; + ok( + p.x >= lane.x && p.x + NODE_W <= lane.x + lane.width && + p.y >= lane.y && p.y + NODE_H <= lane.y + lane.height, + `newest-left:${id} 落在自己的泳道框内(仍成道)`, + ); + }); + ok(l.get("llmA")!.x < l.get("subA")!.x, "newest-left:泳道第一步比泳道根更新(更靠左)"); + fits(left, "newest-left 带泳道"); + + // 没有两个节点落在同一个格子上(方向镜像不能让卡片重叠)。 + ([top, left] as const).forEach((layout) => { + const slots = new Set(layout.nodes.map((p) => `${p.x},${p.y}`)); + same(slots.size, layout.nodes.length, "每个节点都有自己的格子(不重叠)"); + }); +} + +console.log(`flow/orientation.test.ts OK — ${checks} checks`);