247 lines
8.1 KiB
TypeScript
247 lines
8.1 KiB
TypeScript
import { useRef, useState } from "react";
|
||
import { Button } from "@douyinfe/semi-ui-19";
|
||
import type { FlowNode, FlowRun, Workflow } from "./flowTypes";
|
||
import { flowStatus } from "./flowTypes";
|
||
export type NodeType = {
|
||
type: string;
|
||
label: string;
|
||
accepts: string[];
|
||
produces: string;
|
||
};
|
||
export function FlowCanvas({
|
||
graph,
|
||
types,
|
||
selected,
|
||
onSelect,
|
||
onChange,
|
||
run,
|
||
}: {
|
||
graph: Workflow;
|
||
types: NodeType[];
|
||
selected: string | null;
|
||
onSelect: (id: string) => void;
|
||
onChange?: (graph: Workflow) => void;
|
||
run?: FlowRun | null;
|
||
}) {
|
||
const [zoom, setZoom] = useState(0.8);
|
||
const viewport = useRef<HTMLDivElement>(null);
|
||
const portNames: Record<string, string> = {
|
||
context: "输入范围",
|
||
template: "模板",
|
||
candidates: "候选表达式",
|
||
results: "回测结果",
|
||
evaluation: "评估报告",
|
||
summary: "研究汇总",
|
||
iteration: "迭代产物",
|
||
};
|
||
const drag = useRef<{
|
||
id: string;
|
||
x: number;
|
||
y: number;
|
||
left: number;
|
||
top: number;
|
||
} | null>(null);
|
||
const width = Math.max(1120, ...graph.nodes.map((n) => n.x + 260));
|
||
const height = Math.max(520, ...graph.nodes.map((n) => n.y + 180));
|
||
function move(node: FlowNode, x: number, y: number) {
|
||
onChange?.({
|
||
...graph,
|
||
nodes: graph.nodes.map((n) =>
|
||
n.id === node.id
|
||
? {
|
||
...n,
|
||
x: Math.max(0, Math.min(10000, x)),
|
||
y: Math.max(0, Math.min(10000, y)),
|
||
}
|
||
: n,
|
||
),
|
||
});
|
||
}
|
||
return (
|
||
<div className="flow-canvas-shell">
|
||
<div className="flow-canvas-toolbar">
|
||
<span>
|
||
{onChange
|
||
? "拖动节点排列 · 选中后可用方向键微调"
|
||
: "节点颜色与状态显示当前轮次"}
|
||
</span>
|
||
<div className="inline-actions">
|
||
<Button
|
||
size="small"
|
||
aria-label="缩小画布"
|
||
onClick={() => setZoom((z) => Math.max(0.4, z - 0.1))}
|
||
>
|
||
−
|
||
</Button>
|
||
<span>{Math.round(zoom * 100)}%</span>
|
||
<Button
|
||
size="small"
|
||
aria-label="放大画布"
|
||
onClick={() => setZoom((z) => Math.min(1.5, z + 0.1))}
|
||
>
|
||
+
|
||
</Button>
|
||
<Button size="small" onClick={() => setZoom(0.8)}>
|
||
重置缩放
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
onClick={() =>
|
||
setZoom(
|
||
Math.max(
|
||
0.25,
|
||
Math.min(1, (viewport.current?.clientWidth || width) / width),
|
||
),
|
||
)
|
||
}
|
||
>
|
||
适应画布
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
ref={viewport}
|
||
className="flow-canvas-viewport"
|
||
aria-label="QuantFlow 画布"
|
||
>
|
||
<div style={{ width: width * zoom, height: height * zoom }}>
|
||
<div
|
||
className="flow-canvas"
|
||
style={{
|
||
width,
|
||
height,
|
||
transform: `scale(${zoom})`,
|
||
transformOrigin: "top left",
|
||
}}
|
||
>
|
||
<svg
|
||
width={width}
|
||
height={height}
|
||
className="flow-edges"
|
||
aria-label="节点连线"
|
||
>
|
||
{graph.edges.map((e, index) => {
|
||
const a = graph.nodes.find((n) => n.id === e.source),
|
||
b = graph.nodes.find((n) => n.id === e.target);
|
||
if (!a || !b) return null;
|
||
const x = a.x + 200,
|
||
y = a.y + 50,
|
||
tx = b.x,
|
||
ty = b.y + 50;
|
||
const sibling = graph.edges
|
||
.filter((edge) => edge.source === e.source)
|
||
.indexOf(e);
|
||
const routed = Boolean(e.branch) || tx < x || tx - x > 130;
|
||
const lane =
|
||
tx < x
|
||
? a.y + 130 + sibling * 14
|
||
: Math.max(10, Math.min(a.y, b.y) - 30 - sibling * 14);
|
||
const path = routed
|
||
? `M${x} ${y} H${x + 18} V${lane} H${tx - 18} V${ty} H${tx}`
|
||
: `M${x} ${y} C${x + 30} ${y}, ${tx - 30} ${ty}, ${tx} ${ty}`;
|
||
const upstream = run?.steps.find(
|
||
(step) =>
|
||
step.node_id === e.source && step.round === run.round,
|
||
);
|
||
const inactive =
|
||
upstream &&
|
||
(upstream.status === "skipped" ||
|
||
(e.branch && upstream.output.verdict !== e.branch));
|
||
return (
|
||
<g
|
||
key={`${e.source}-${e.target}-${index}`}
|
||
opacity={inactive ? 0.3 : 1}
|
||
>
|
||
<path d={path} />
|
||
<path
|
||
d={`M${tx - 8} ${ty - 5} L${tx} ${ty} L${tx - 8} ${ty + 5}`}
|
||
/>
|
||
{e.branch && (
|
||
<text
|
||
x={(x + tx) / 2}
|
||
y={routed ? lane - 5 : (y + ty) / 2 - 8}
|
||
>
|
||
{e.branch}
|
||
</text>
|
||
)}
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
{graph.nodes.map((node) => {
|
||
const type = types.find((t) => t.type === node.type);
|
||
const step = run?.steps.find(
|
||
(s) => s.node_id === node.id && s.round === run.round,
|
||
);
|
||
return (
|
||
<button
|
||
key={node.id}
|
||
type="button"
|
||
aria-label={`节点 ${node.label || node.id}`}
|
||
aria-pressed={selected === node.id}
|
||
className={`flow-node ${selected === node.id ? "selected" : ""} ${step?.status || ""}`}
|
||
style={{ left: node.x, top: node.y, touchAction: "none" }}
|
||
onClick={() => onSelect(node.id)}
|
||
onPointerDown={(e) => {
|
||
onSelect(node.id);
|
||
if (!onChange) return;
|
||
drag.current = {
|
||
id: node.id,
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
left: node.x,
|
||
top: node.y,
|
||
};
|
||
e.currentTarget.setPointerCapture(e.pointerId);
|
||
}}
|
||
onPointerMove={(e) => {
|
||
const d = drag.current;
|
||
if (d?.id === node.id)
|
||
move(
|
||
node,
|
||
d.left + (e.clientX - d.x) / zoom,
|
||
d.top + (e.clientY - d.y) / zoom,
|
||
);
|
||
}}
|
||
onPointerUp={() => {
|
||
drag.current = null;
|
||
}}
|
||
onPointerCancel={() => {
|
||
drag.current = null;
|
||
}}
|
||
onKeyDown={(e) => {
|
||
const delta: Record<string, [number, number]> = {
|
||
ArrowLeft: [-10, 0],
|
||
ArrowRight: [10, 0],
|
||
ArrowUp: [0, -10],
|
||
ArrowDown: [0, 10],
|
||
};
|
||
if (onChange && delta[e.key]) {
|
||
e.preventDefault();
|
||
move(
|
||
node,
|
||
node.x + delta[e.key][0],
|
||
node.y + delta[e.key][1],
|
||
);
|
||
}
|
||
}}
|
||
>
|
||
<span className="flow-node-kind">
|
||
{type?.label || node.type}
|
||
</span>
|
||
<strong>{node.label || node.id}</strong>
|
||
<span>
|
||
{step
|
||
? flowStatus[step.status] || step.status
|
||
: `${type && type.accepts.length > 2 ? "多种研究产物" : type?.accepts.map((p) => portNames[p] || p).join(" / ") || "固定数据"} → ${portNames[type?.produces || ""] || "产物"}`}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|