Files
web-page/src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx

197 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}