Files
worldquant-alpha-system/frontend/src/research/FlowCanvas.tsx
T

247 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}