From 2da7255c03035ba726f140a113d4047337e28267 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 13 May 2026 21:49:19 +0900 Subject: [PATCH] feat(screener): ScreenerCanvas root component (react-flow + 11 nodes + 16 edges) --- .../components/canvas/ScreenerCanvas.jsx | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx diff --git a/src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx b/src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx new file mode 100644 index 0000000..e711eb4 --- /dev/null +++ b/src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx @@ -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 ( +
+ + + + + +
+ ); +} + +export default function ScreenerCanvas(props) { + return ( + + + + ); +}