diff --git a/src/pages/stock/screener/components/canvas/constants/canvasLayout.js b/src/pages/stock/screener/components/canvas/constants/canvasLayout.js new file mode 100644 index 0000000..a2363f1 --- /dev/null +++ b/src/pages/stock/screener/components/canvas/constants/canvasLayout.js @@ -0,0 +1,80 @@ +export const NODE_IDS = { + DATA: 'data', + GATE: 'gate-hygiene', + FOREIGN: 'score-foreign-buy', + VOLUME: 'score-volume-surge', + MOMENTUM: 'score-momentum', + HIGH52W: 'score-high52w', + RS: 'score-rs-rating', + MA: 'score-ma-alignment', + VCP: 'score-vcp-lite', + COMBINE: 'combine', + RESULT: 'result', +}; + +export const NODE_KIND_MAP = { + [NODE_IDS.DATA]: 'data', + [NODE_IDS.GATE]: 'gate', + [NODE_IDS.FOREIGN]: 'score', + [NODE_IDS.VOLUME]: 'score', + [NODE_IDS.MOMENTUM]: 'score', + [NODE_IDS.HIGH52W]: 'score', + [NODE_IDS.RS]: 'score', + [NODE_IDS.MA]: 'score', + [NODE_IDS.VCP]: 'score', + [NODE_IDS.COMBINE]: 'combine', + [NODE_IDS.RESULT]: 'result', +}; + +// 캔버스 노드 ID → 백엔드 score node name (registry 키) +export const SCORE_NODE_NAME_MAP = { + [NODE_IDS.FOREIGN]: 'foreign_buy', + [NODE_IDS.VOLUME]: 'volume_surge', + [NODE_IDS.MOMENTUM]: 'momentum', + [NODE_IDS.HIGH52W]: 'high52w', + [NODE_IDS.RS]: 'rs_rating', + [NODE_IDS.MA]: 'ma_alignment', + [NODE_IDS.VCP]: 'vcp_lite', +}; + +// 4단 layout: DATA → GATE → (점수 7개 세로) → COMBINE → RESULT +export const INITIAL_NODE_POSITIONS = { + [NODE_IDS.DATA]: { x: 40, y: 280 }, + [NODE_IDS.GATE]: { x: 240, y: 280 }, + [NODE_IDS.FOREIGN]: { x: 480, y: 0 }, + [NODE_IDS.VOLUME]: { x: 480, y: 90 }, + [NODE_IDS.MOMENTUM]: { x: 480, y: 180 }, + [NODE_IDS.HIGH52W]: { x: 480, y: 270 }, + [NODE_IDS.RS]: { x: 480, y: 360 }, + [NODE_IDS.MA]: { x: 480, y: 450 }, + [NODE_IDS.VCP]: { x: 480, y: 540 }, + [NODE_IDS.COMBINE]: { x: 800, y: 280 }, + [NODE_IDS.RESULT]: { x: 1080, y: 280 }, +}; + +const SCORE_KEYS = ['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP']; + +export const EDGES = [ + { id: 'e-data-gate', source: NODE_IDS.DATA, target: NODE_IDS.GATE }, + ...SCORE_KEYS.map((k) => ({ + id: `e-gate-${k.toLowerCase()}`, + source: NODE_IDS.GATE, + target: NODE_IDS[k], + })), + ...SCORE_KEYS.map((k) => ({ + id: `e-${k.toLowerCase()}-combine`, + source: NODE_IDS[k], + target: NODE_IDS.COMBINE, + })), + { id: 'e-combine-result', source: NODE_IDS.COMBINE, target: NODE_IDS.RESULT }, +]; + +export const SCORE_NODE_LABEL = { + [NODE_IDS.FOREIGN]: { icon: '🌏', title: '외국인 매수' }, + [NODE_IDS.VOLUME]: { icon: '📊', title: '거래량 급증' }, + [NODE_IDS.MOMENTUM]: { icon: '🚀', title: '모멘텀' }, + [NODE_IDS.HIGH52W]: { icon: '🔝', title: '52주 고가' }, + [NODE_IDS.RS]: { icon: '💪', title: 'RS Rating' }, + [NODE_IDS.MA]: { icon: '📈', title: '이평선 정렬' }, + [NODE_IDS.VCP]: { icon: '🌀', title: 'VCP-lite' }, +}; diff --git a/src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js b/src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js new file mode 100644 index 0000000..b3e6234 --- /dev/null +++ b/src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { + NODE_IDS, INITIAL_NODE_POSITIONS, EDGES, + NODE_KIND_MAP, SCORE_NODE_NAME_MAP, +} from './canvasLayout'; + +describe('canvasLayout', () => { + it('NODE_IDS — 11개 키, 모두 unique', () => { + const ids = Object.values(NODE_IDS); + expect(ids).toHaveLength(11); + expect(new Set(ids).size).toBe(11); + }); + + it('INITIAL_NODE_POSITIONS — 모든 NODE_IDS에 좌표 존재', () => { + for (const id of Object.values(NODE_IDS)) { + expect(INITIAL_NODE_POSITIONS[id]).toMatchObject({ + x: expect.any(Number), + y: expect.any(Number), + }); + } + }); + + it('EDGES — 16개, source/target이 모두 NODE_IDS 안에 존재', () => { + expect(EDGES).toHaveLength(16); + const validIds = new Set(Object.values(NODE_IDS)); + for (const e of EDGES) { + expect(validIds.has(e.source)).toBe(true); + expect(validIds.has(e.target)).toBe(true); + expect(e.id).toBeTruthy(); + } + }); + + it('EDGES — 7개 점수 노드는 모두 gate 입력 + combine 출력을 가짐', () => { + const SCORE_IDS = [ + NODE_IDS.FOREIGN, NODE_IDS.VOLUME, NODE_IDS.MOMENTUM, + NODE_IDS.HIGH52W, NODE_IDS.RS, NODE_IDS.MA, NODE_IDS.VCP, + ]; + for (const sid of SCORE_IDS) { + const hasGateInput = EDGES.some( + (e) => e.source === NODE_IDS.GATE && e.target === sid + ); + const hasCombineOutput = EDGES.some( + (e) => e.source === sid && e.target === NODE_IDS.COMBINE + ); + expect(hasGateInput).toBe(true); + expect(hasCombineOutput).toBe(true); + } + }); + + it('NODE_KIND_MAP — 각 노드의 kind ∈ {data,gate,score,combine,result}', () => { + const valid = new Set(['data','gate','score','combine','result']); + for (const id of Object.values(NODE_IDS)) { + expect(valid.has(NODE_KIND_MAP[id])).toBe(true); + } + }); + + it('SCORE_NODE_NAME_MAP — 7개 점수 노드 ID → backend node name', () => { + expect(Object.keys(SCORE_NODE_NAME_MAP)).toHaveLength(7); + expect(SCORE_NODE_NAME_MAP[NODE_IDS.FOREIGN]).toBe('foreign_buy'); + expect(SCORE_NODE_NAME_MAP[NODE_IDS.VOLUME]).toBe('volume_surge'); + }); +});