feat(screener): ScreenerCanvas root component (react-flow + 11 nodes + 16 edges)
This commit is contained in:
196
src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx
Normal file
196
src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user