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