feat(screener): ScreenerCanvas root component (react-flow + 11 nodes + 16 edges)

This commit is contained in:
2026-05-13 21:49:19 +09:00
parent b4ad0b1abf
commit 2da7255c03

View File

@@ -0,0 +1,196 @@
import React, { useMemo, useCallback } from 'react';
import {
ReactFlow, Background, Controls, ReactFlowProvider,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {
NODE_IDS, NODE_KIND_MAP, SCORE_NODE_NAME_MAP,
EDGES, SCORE_NODE_LABEL, INITIAL_NODE_POSITIONS,
} from './constants/canvasLayout';
import { useCanvasLayout } from '../../hooks/useCanvasLayout';
import ScoreNodeCard from './nodes/ScoreNodeCard';
import GateNodeCard from './nodes/GateNodeCard';
import FixedNodeCard from './nodes/FixedNodeCard';
import CanvasToolbar from './CanvasToolbar';
const nodeTypes = {
score: ScoreNodeCard,
gate: GateNodeCard,
fixed: FixedNodeCard,
};
function buildEdges(weights) {
return EDGES.map((e) => {
const targetKind = NODE_KIND_MAP[e.target];
const sourceKind = NODE_KIND_MAP[e.source];
// gate → 점수: 해당 점수 노드 weight 가 활성인지에 따라 stroke
let active = true;
if (sourceKind === 'gate' && targetKind === 'score') {
const nodeName = SCORE_NODE_NAME_MAP[e.target];
active = (weights?.[nodeName] ?? 0) > 0;
} else if (sourceKind === 'score' && targetKind === 'combine') {
const nodeName = SCORE_NODE_NAME_MAP[e.source];
active = (weights?.[nodeName] ?? 0) > 0;
}
return {
...e,
animated: active,
style: {
stroke: active ? '#fbbf24' : '#374151',
strokeWidth: active ? 1.5 : 1,
strokeDasharray: active ? undefined : '4 4',
},
};
});
}
function ScreenerCanvasInner({
meta, settings, setLocal, result, running, dirty,
onRunPreview, onRunSave, onPersistSettings,
}) {
const { positions, updateNodePosition, reset } = useCanvasLayout(INITIAL_NODE_POSITIONS);
const onWeightChange = useCallback((nodeId, weight) => {
const name = SCORE_NODE_NAME_MAP[nodeId];
if (!name) return;
setLocal({ ...settings, weights: { ...settings.weights, [name]: weight } });
}, [settings, setLocal]);
const onParamsChange = useCallback((nodeId, params) => {
const name = SCORE_NODE_NAME_MAP[nodeId];
if (!name) return;
setLocal({ ...settings, node_params: { ...settings.node_params, [name]: params } });
}, [settings, setLocal]);
const onGateParamsChange = useCallback((params) => {
setLocal({ ...settings, gate_params: params });
}, [settings, setLocal]);
const scoreMetaByName = useMemo(() => {
const map = {};
for (const m of meta?.score_nodes ?? []) map[m.name] = m;
return map;
}, [meta]);
const gateMeta = meta?.gate_nodes?.[0];
const nodes = useMemo(() => {
const arr = [];
arr.push({
id: NODE_IDS.DATA,
type: 'fixed',
position: positions[NODE_IDS.DATA],
data: { icon: '📥', title: 'KRX 데이터', subtitle: '~2,800종목 · FDR', kind: 'data' },
draggable: true,
});
arr.push({
id: NODE_IDS.GATE,
type: 'gate',
position: positions[NODE_IDS.GATE],
data: {
meta: gateMeta,
params: settings.gate_params,
description: gateMeta?.label || '위생 게이트',
onChange: onGateParamsChange,
},
draggable: true,
});
for (const [nodeId, backendName] of Object.entries(SCORE_NODE_NAME_MAP)) {
const m = scoreMetaByName[backendName];
const label = SCORE_NODE_LABEL[nodeId] || { icon: '📈', title: backendName };
arr.push({
id: nodeId,
type: 'score',
position: positions[nodeId],
data: {
meta: m ? { ...m, label: label.title } : { name: backendName, label: label.title },
weight: settings.weights?.[backendName] ?? 0,
params: settings.node_params?.[backendName] ?? {},
summary: m?.summary || '',
description: m?.description || m?.label || '',
accent: m?.color || '#3b82f6',
icon: label.icon,
onWeightChange: (w) => onWeightChange(nodeId, w),
onParamsChange: (p) => onParamsChange(nodeId, p),
},
draggable: true,
});
}
const tp = settings.top_n;
const rr = settings.rr_ratio;
const am = settings.atr_stop_mult;
arr.push({
id: NODE_IDS.COMBINE,
type: 'fixed',
position: positions[NODE_IDS.COMBINE],
data: {
icon: '⚙️',
title: '가중합 + TopN + ATR',
subtitle: `Top ${tp} · RR ${rr} · ATR×${am}`,
kind: 'combine',
},
draggable: true,
});
const survivors = result?.survivors_count;
const asof = result?.asof;
arr.push({
id: NODE_IDS.RESULT,
type: 'fixed',
position: positions[NODE_IDS.RESULT],
data: {
icon: '📊',
title: '결과',
subtitle: asof ? `${asof} · ${survivors ?? '-'}종목 통과` : '아직 실행 안 됨',
kind: 'result',
},
draggable: true,
});
return arr;
}, [positions, settings, meta, scoreMetaByName, gateMeta,
onWeightChange, onParamsChange, onGateParamsChange, result]);
const edges = useMemo(() => buildEdges(settings.weights), [settings.weights]);
const handleNodeDragStop = useCallback((_evt, node) => {
updateNodePosition(node.id, node.position);
}, [updateNodePosition]);
return (
<div className="screener-canvas-wrap">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
nodesConnectable={false}
edgesUpdatable={false}
edgesFocusable={false}
onNodeDragStop={handleNodeDragStop}
defaultViewport={{ x: 0, y: 0, zoom: 0.85 }}
fitView
fitViewOptions={{ padding: 0.2 }}
proOptions={{ hideAttribution: true }}
>
<Background gap={20} size={1} color="#1f2937" />
<Controls showInteractive={false} />
<CanvasToolbar
onRunPreview={onRunPreview}
onRunSave={onRunSave}
onPersistSettings={onPersistSettings}
onResetLayout={reset}
dirty={dirty}
running={running}
/>
</ReactFlow>
</div>
);
}
export default function ScreenerCanvas(props) {
return (
<ReactFlowProvider>
<ScreenerCanvasInner {...props} />
</ReactFlowProvider>
);
}