197 lines
6.1 KiB
JavaScript
197 lines
6.1 KiB
JavaScript
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>
|
||
);
|
||
}
|