feat: compose native research workflows in QuantFlow
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user