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 (