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>
|
|||
|
|
);
|
|||
|
|
}
|