feat(admin): 流程图方向——Flow 页自下而上(最新在上),Backthink/Agents 图块最新在左
主人要求:① 全链路流程页从下往上渲染(时序自下而上 ⇒ 最新一定在最上)+ 容器高度自动拓高;② 主动思考页保持原渲染方式但最新放最左(容器默认露最左);③ 追加:改布局后多路渲染仍要合适。 - 共享布局唯一入口 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 期望随新方向镜像更新,泳道仍成道;并行路数据互相覆盖的已知问题归后续分桶切片
This commit is contained in:
1 parent
f4adf0b526
commit
14e76b6480
9 files changed
+964
-108
No files matched your search
@@ -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<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 { 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 (
|
||||
<div className="space-y-2">
|
||||
<div className="overflow-x-auto rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] p-3">
|
||||
<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
|
||||
@@ -64,12 +83,10 @@ export function FlowGraphView({
|
||||
<LaneFrame key={band.id} band={band} />
|
||||
))}
|
||||
|
||||
{edges.map((e, i) => (
|
||||
{shapes.map((shape, i) => (
|
||||
<EdgePath
|
||||
key={`${e.edge.from}-${e.edge.to}-${i}`}
|
||||
edge={e}
|
||||
from={placedOf.get(e.edge.from)}
|
||||
to={placedOf.get(e.edge.to)}
|
||||
key={`${shape.fromId}-${shape.toId}-${i}`}
|
||||
shape={shape}
|
||||
markerId={markerId}
|
||||
laneMarkerId={laneMarkerId}
|
||||
/>
|
||||
@@ -88,42 +105,32 @@ export function FlowGraphView({
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<Legend hasLanes={lanes.length > 0} />
|
||||
<Legend hasLanes={lanes.length > 0} orientation={orientation} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<path
|
||||
d={`M ${sx} ${sy} C ${sx + dx} ${sy}, ${tx - dx} ${ty}, ${tx} ${ty}`}
|
||||
d={shape.d}
|
||||
fill="none"
|
||||
stroke={inLane ? "var(--mu-accent)" : "var(--mu-text-lo)"}
|
||||
strokeOpacity={inLane ? 0.7 : 1}
|
||||
stroke={shape.inLane ? "var(--mu-accent)" : "var(--mu-text-lo)"}
|
||||
strokeOpacity={shape.inLane ? 0.7 : 1}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={inLane ? "5 3" : undefined}
|
||||
markerEnd={`url(#${inLane ? laneMarkerId : markerId})`}
|
||||
strokeDasharray={shape.inLane ? "5 3" : undefined}
|
||||
markerEnd={`url(#${shape.inLane ? laneMarkerId : markerId})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap items-center gap-3 px-1">
|
||||
{STATUS_ORDER.map((s) => (
|
||||
@@ -274,6 +288,9 @@ function Legend({ hasLanes }: { hasLanes: boolean }): ReactNode {
|
||||
泳道内步骤
|
||||
</span>
|
||||
) : null}
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] text-[var(--mu-text-lo)]">
|
||||
{DIRECTION_HINT[orientation]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user