# Screener Node Canvas Mode Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** `/stock/screener` 에 n8n 스타일 노드 캔버스 모드를 추가한다. 폼 모드와 토글로 전환하며 같은 settings state를 공유하고, 11 노드 / 16 엣지 고정 토폴로지로 파이프라인을 시각화한다. **Architecture:** 백엔드 변경 없이 프론트 전용. `@xyflow/react` 도입, `useScreenerMode` / `useCanvasLayout` 두 hook 추가, `canvas/` 하위에 노드 카드 + 캔버스 컨테이너 + floating 툴바 컴포넌트 신설. 모드 분기는 `Screener.jsx` 한 곳에서 처리하고 결과 영역(`ResultTable`, `TelegramPreview`, `RunHistoryList`)은 두 모드가 공유. 모바일은 `useIsMobile` 분기로 폼 강제. **Tech Stack:** React 18.2, Vite 7, `@xyflow/react` ^12, vitest + jsdom + @testing-library/react, localStorage. **선행 spec**: `docs/superpowers/specs/2026-05-13-screener-node-canvas-design.md` --- ## 파일 구조 신규 파일: ``` src/pages/stock/screener/ hooks/ useScreenerMode.js useCanvasLayout.js components/ ModeToggle.jsx canvas/ CanvasLayout.jsx ScreenerCanvas.jsx CanvasToolbar.jsx nodes/ ScoreNodeCard.jsx GateNodeCard.jsx FixedNodeCard.jsx constants/ canvasLayout.js Canvas.css src/test-setup.js ``` 수정 파일: ``` package.json (의존성 추가, test script) vite.config.js (test 섹션) src/pages/stock/screener/Screener.jsx src/pages/stock/screener/Screener.css (ModeToggle 스타일 추가) ``` 테스트 파일: ``` src/pages/stock/screener/hooks/useScreenerMode.test.js src/pages/stock/screener/hooks/useCanvasLayout.test.js src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx ``` --- ### Task 1: 의존성 + 테스트 환경 셋업 **Files:** - Modify: `package.json` - Modify: `vite.config.js` - Create: `src/test-setup.js` - [ ] **Step 1: `@xyflow/react` 와 테스트 의존성 설치** ```bash cd C:\Users\jaeoh\Desktop\workspace\web-ui npm install @xyflow/react@^12 npm install -D vitest@^2 jsdom@^25 @testing-library/react@^16 @testing-library/jest-dom@^6 @testing-library/user-event@^14 ``` Expected: 새 의존성 6개가 `package.json` 에 추가됨. `npm install` 성공. - [ ] **Step 2: `package.json` 에 test 스크립트 추가** `scripts` 객체에 다음 줄을 추가: ```json "test": "vitest", "test:run": "vitest run" ``` - [ ] **Step 3: `vite.config.js` 에 test 섹션 추가** 기존 `defineConfig({ ... })` 블록 안 `plugins` 옆에 추가: ```js test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test-setup.js'], include: ['src/**/*.test.{js,jsx}'], }, ``` - [ ] **Step 4: `src/test-setup.js` 작성** ```js import '@testing-library/jest-dom/vitest'; // jsdom polyfills for react-flow if (typeof window !== 'undefined') { if (!window.matchMedia) { window.matchMedia = (query) => ({ matches: false, media: query, onchange: null, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, }); } if (!window.ResizeObserver) { window.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }; } if (!window.DOMMatrixReadOnly) { window.DOMMatrixReadOnly = class { constructor() {} m22 = 1; }; } } beforeEach(() => { localStorage.clear(); }); ``` - [ ] **Step 5: 셋업 동작 확인** ```bash npx vitest --run --reporter=verbose ``` Expected: "No test files found" 메시지 (테스트 파일이 아직 없어 정상). 에러 없이 종료. - [ ] **Step 6: Commit** ```bash git add package.json package-lock.json vite.config.js src/test-setup.js git commit -m "chore(screener): add @xyflow/react + vitest test environment" ``` --- ### Task 2: `canvasLayout.js` 상수 + 정합성 테스트 **Files:** - Create: `src/pages/stock/screener/components/canvas/constants/canvasLayout.js` - Test: `src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js` - [ ] **Step 1: 실패하는 정합성 테스트 작성** ```js 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'); }); }); ``` - [ ] **Step 2: 테스트 실패 확인** ```bash npx vitest run src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js ``` Expected: FAIL — "Cannot find module './canvasLayout'". - [ ] **Step 3: `canvasLayout.js` 구현** ```js 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' }, }; ``` - [ ] **Step 4: 테스트 통과 확인** ```bash npx vitest run src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js ``` Expected: PASS — 6 tests passed. - [ ] **Step 5: Commit** ```bash git add src/pages/stock/screener/components/canvas/constants/ git commit -m "feat(screener): add canvas layout constants (11 nodes, 16 edges)" ``` --- ### Task 3: `useScreenerMode` hook + 테스트 **Files:** - Create: `src/pages/stock/screener/hooks/useScreenerMode.js` - Test: `src/pages/stock/screener/hooks/useScreenerMode.test.js` - [ ] **Step 1: 실패하는 테스트 작성** ```js import { describe, it, expect } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useScreenerMode } from './useScreenerMode'; describe('useScreenerMode', () => { it('초기값은 "form"', () => { const { result } = renderHook(() => useScreenerMode()); expect(result.current.mode).toBe('form'); }); it('localStorage 에 저장된 값 복원', () => { localStorage.setItem('screener-mode-v1', 'canvas'); const { result } = renderHook(() => useScreenerMode()); expect(result.current.mode).toBe('canvas'); }); it('손상된 localStorage 는 "form" 으로 fallback', () => { localStorage.setItem('screener-mode-v1', 'INVALID_MODE'); const { result } = renderHook(() => useScreenerMode()); expect(result.current.mode).toBe('form'); }); it('setMode 호출 시 state 와 localStorage 모두 갱신', () => { const { result } = renderHook(() => useScreenerMode()); act(() => result.current.setMode('canvas')); expect(result.current.mode).toBe('canvas'); expect(localStorage.getItem('screener-mode-v1')).toBe('canvas'); }); }); ``` - [ ] **Step 2: 테스트 실패 확인** ```bash npx vitest run src/pages/stock/screener/hooks/useScreenerMode.test.js ``` Expected: FAIL — "Cannot find module './useScreenerMode'". - [ ] **Step 3: `useScreenerMode.js` 구현** ```js import { useState } from 'react'; const STORAGE_KEY = 'screener-mode-v1'; const VALID_MODES = new Set(['form', 'canvas']); function readMode() { try { const v = localStorage.getItem(STORAGE_KEY); return VALID_MODES.has(v) ? v : 'form'; } catch { return 'form'; } } export function useScreenerMode() { const [mode, setModeState] = useState(readMode); const setMode = (m) => { if (!VALID_MODES.has(m)) return; setModeState(m); try { localStorage.setItem(STORAGE_KEY, m); } catch {} }; return { mode, setMode }; } ``` - [ ] **Step 4: 테스트 통과 확인** ```bash npx vitest run src/pages/stock/screener/hooks/useScreenerMode.test.js ``` Expected: PASS — 4 tests passed. - [ ] **Step 5: Commit** ```bash git add src/pages/stock/screener/hooks/useScreenerMode.js src/pages/stock/screener/hooks/useScreenerMode.test.js git commit -m "feat(screener): useScreenerMode hook (form|canvas + localStorage)" ``` --- ### Task 4: `useCanvasLayout` hook + 테스트 **Files:** - Create: `src/pages/stock/screener/hooks/useCanvasLayout.js` - Test: `src/pages/stock/screener/hooks/useCanvasLayout.test.js` - [ ] **Step 1: 실패하는 테스트 작성** ```js import { describe, it, expect } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useCanvasLayout } from './useCanvasLayout'; const INITIAL = { a: { x: 0, y: 0 }, b: { x: 100, y: 100 }, c: { x: 200, y: 200 }, }; describe('useCanvasLayout', () => { it('초기 호출 시 INITIAL 반환', () => { const { result } = renderHook(() => useCanvasLayout(INITIAL)); expect(result.current.positions).toEqual(INITIAL); }); it('updateNodePosition 호출 시 state + localStorage 모두 갱신', () => { const { result } = renderHook(() => useCanvasLayout(INITIAL)); act(() => result.current.updateNodePosition('a', { x: 50, y: 50 })); expect(result.current.positions.a).toEqual({ x: 50, y: 50 }); const stored = JSON.parse(localStorage.getItem('screener-canvas-layout-v1')); expect(stored.a).toEqual({ x: 50, y: 50 }); }); it('reset 호출 시 INITIAL 복원 + localStorage 삭제', () => { const { result } = renderHook(() => useCanvasLayout(INITIAL)); act(() => result.current.updateNodePosition('a', { x: 50, y: 50 })); act(() => result.current.reset()); expect(result.current.positions).toEqual(INITIAL); expect(localStorage.getItem('screener-canvas-layout-v1')).toBeNull(); }); it('손상된 localStorage 는 INITIAL 로 fallback', () => { localStorage.setItem('screener-canvas-layout-v1', 'NOT_JSON'); const { result } = renderHook(() => useCanvasLayout(INITIAL)); expect(result.current.positions).toEqual(INITIAL); }); it('localStorage 에 일부 ID 만 있으면 누락 ID 는 INITIAL 보충', () => { localStorage.setItem( 'screener-canvas-layout-v1', JSON.stringify({ a: { x: 999, y: 999 } }) ); const { result } = renderHook(() => useCanvasLayout(INITIAL)); expect(result.current.positions.a).toEqual({ x: 999, y: 999 }); expect(result.current.positions.b).toEqual({ x: 100, y: 100 }); expect(result.current.positions.c).toEqual({ x: 200, y: 200 }); }); }); ``` - [ ] **Step 2: 테스트 실패 확인** ```bash npx vitest run src/pages/stock/screener/hooks/useCanvasLayout.test.js ``` Expected: FAIL — "Cannot find module './useCanvasLayout'". - [ ] **Step 3: `useCanvasLayout.js` 구현** ```js import { useState, useCallback } from 'react'; const STORAGE_KEY = 'screener-canvas-layout-v1'; function readPositions(initial) { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return initial; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object') return initial; // 누락 ID 보충 return { ...initial, ...filterValidEntries(parsed) }; } catch { return initial; } } function filterValidEntries(obj) { const out = {}; for (const [k, v] of Object.entries(obj)) { if (v && typeof v.x === 'number' && typeof v.y === 'number') { out[k] = { x: v.x, y: v.y }; } } return out; } export function useCanvasLayout(initialPositions) { const [positions, setPositions] = useState(() => readPositions(initialPositions)); const updateNodePosition = useCallback((nodeId, pos) => { setPositions((prev) => { const next = { ...prev, [nodeId]: { x: pos.x, y: pos.y } }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {} return next; }); }, []); const reset = useCallback(() => { setPositions(initialPositions); try { localStorage.removeItem(STORAGE_KEY); } catch {} }, [initialPositions]); return { positions, updateNodePosition, reset }; } ``` - [ ] **Step 4: 테스트 통과 확인** ```bash npx vitest run src/pages/stock/screener/hooks/useCanvasLayout.test.js ``` Expected: PASS — 5 tests passed. - [ ] **Step 5: Commit** ```bash git add src/pages/stock/screener/hooks/useCanvasLayout.js src/pages/stock/screener/hooks/useCanvasLayout.test.js git commit -m "feat(screener): useCanvasLayout hook (node positions + reset)" ``` --- ### Task 5: `ModeToggle` 컴포넌트 **Files:** - Create: `src/pages/stock/screener/components/ModeToggle.jsx` - [ ] **Step 1: `ModeToggle.jsx` 구현** ```jsx import React from 'react'; export default function ModeToggle({ value, onChange }) { return (
); } ``` - [ ] **Step 2: 빠른 sanity import 검증** `vite` dev 서버는 아직 띄우지 않음. lint 만: ```bash npx eslint src/pages/stock/screener/components/ModeToggle.jsx ``` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash git add src/pages/stock/screener/components/ModeToggle.jsx git commit -m "feat(screener): ModeToggle segment control component" ``` --- ### Task 6: `FixedNodeCard` (데이터/결합/결과 노드) **Files:** - Create: `src/pages/stock/screener/components/canvas/nodes/FixedNodeCard.jsx` - [ ] **Step 1: `FixedNodeCard.jsx` 구현** ```jsx import React, { memo } from 'react'; import { Handle, Position } from '@xyflow/react'; function FixedNodeCard({ data }) { const { icon, title, subtitle, kind } = data; const hasInput = kind !== 'data'; const hasOutput = kind !== 'result'; return (
{hasInput && }
{icon} {title}
{subtitle &&
{subtitle}
} {hasOutput && }
); } export default memo(FixedNodeCard); ``` - [ ] **Step 2: Commit** ```bash git add src/pages/stock/screener/components/canvas/nodes/FixedNodeCard.jsx git commit -m "feat(screener): FixedNodeCard for data/combine/result nodes" ``` --- ### Task 7: `GateNodeCard` (위생 게이트) **Files:** - Create: `src/pages/stock/screener/components/canvas/nodes/GateNodeCard.jsx` - [ ] **Step 1: `GateNodeCard.jsx` 구현** ```jsx import React, { memo, useState } from 'react'; import { Handle, Position } from '@xyflow/react'; function ParamField({ name, schema, value, onChange }) { if (schema?.type === 'boolean') { return ( ); } return ( ); } function GateNodeCard({ data }) { const { meta, params, onChange, description } = data; const [expanded, setExpanded] = useState(false); const update = (key, v) => onChange({ ...params, [key]: v }); return (
🛡️ {meta?.label || '위생 게이트'} {description && ( )}
통과해야 점수 단계 진입
{expanded && (
{Object.entries(meta?.param_schema || {}).map(([key, schema]) => ( ))}
)}
); } export default memo(GateNodeCard); ``` - [ ] **Step 2: Commit** ```bash git add src/pages/stock/screener/components/canvas/nodes/GateNodeCard.jsx git commit -m "feat(screener): GateNodeCard for hygiene gate" ``` --- ### Task 8: `ScoreNodeCard` 컴포넌트 + 테스트 **Files:** - Create: `src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.jsx` - Test: `src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx` - [ ] **Step 1: 실패하는 테스트 작성** ```jsx import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { ReactFlowProvider } from '@xyflow/react'; import ScoreNodeCard from './ScoreNodeCard'; const baseData = { meta: { name: 'volume_surge', label: '거래량 급증', param_schema: { lookback_days: { type: 'integer', default: 20, label: 'lookback' }, multiplier: { type: 'number', default: 2.0, step: 0.1, label: 'mult' }, }, }, weight: 0.5, params: { lookback_days: 20, multiplier: 2.0 }, summary: '20일 평균 대비 2배 이상', description: '거래량이 평균 대비 급증한 종목을 가산', accent: '#3b82f6', onWeightChange: vi.fn(), onParamsChange: vi.fn(), }; function renderInFlow(data) { return render( ); } describe('ScoreNodeCard', () => { it('타이틀과 한 줄 요약을 표시한다', () => { renderInFlow(baseData); expect(screen.getByText('거래량 급증')).toBeInTheDocument(); expect(screen.getByText('20일 평균 대비 2배 이상')).toBeInTheDocument(); }); it('가중치 슬라이더 변경 시 onWeightChange 호출', () => { const onWeightChange = vi.fn(); renderInFlow({ ...baseData, onWeightChange }); const slider = screen.getByRole('slider'); fireEvent.change(slider, { target: { value: '0.8' } }); expect(onWeightChange).toHaveBeenCalledWith(0.8); }); it('활성 체크박스 uncheck 시 onWeightChange(0)', () => { const onWeightChange = vi.fn(); renderInFlow({ ...baseData, weight: 0.5, onWeightChange }); const checkbox = screen.getByRole('checkbox', { name: /활성/ }); expect(checkbox).toBeChecked(); fireEvent.click(checkbox); expect(onWeightChange).toHaveBeenCalledWith(0); }); it('weight=0 상태에서 활성 체크 시 기본값 0.5로 복원', () => { const onWeightChange = vi.fn(); renderInFlow({ ...baseData, weight: 0, onWeightChange }); const checkbox = screen.getByRole('checkbox', { name: /활성/ }); expect(checkbox).not.toBeChecked(); fireEvent.click(checkbox); expect(onWeightChange).toHaveBeenCalledWith(0.5); }); it('파라미터 펼치기 토글', () => { renderInFlow(baseData); expect(screen.queryByLabelText('lookback')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /파라미터/ })); expect(screen.getByLabelText('lookback')).toBeInTheDocument(); }); }); ``` - [ ] **Step 2: 테스트 실패 확인** ```bash npx vitest run src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx ``` Expected: FAIL — "Cannot find module './ScoreNodeCard'". - [ ] **Step 3: `ScoreNodeCard.jsx` 구현** ```jsx import React, { memo, useState } from 'react'; import { Handle, Position } from '@xyflow/react'; const DEFAULT_WEIGHT = 0.5; function ParamField({ name, schema, value, onChange }) { return ( ); } function ScoreNodeCard({ data }) { const { meta, weight, params, summary, description, accent, icon, onWeightChange, onParamsChange, } = data; const [expanded, setExpanded] = useState(false); const active = weight > 0; const toggleActive = () => { if (active) onWeightChange(0); else onWeightChange(DEFAULT_WEIGHT); }; const updateParam = (key, v) => onParamsChange({ ...params, [key]: v }); return (
{icon} {meta?.label || meta?.name} {description && ( )}
{summary &&
{summary}
}
onWeightChange(Number(e.target.value))} aria-label="가중치" /> {weight.toFixed(2)}
{expanded && (
{Object.entries(meta?.param_schema || {}).map(([key, schema]) => ( ))}
)}
); } export default memo(ScoreNodeCard); ``` - [ ] **Step 4: 테스트 통과 확인** ```bash npx vitest run src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx ``` Expected: PASS — 5 tests passed. - [ ] **Step 5: Commit** ```bash git add src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.jsx src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx git commit -m "feat(screener): ScoreNodeCard with weight slider + active toggle + params" ``` --- ### Task 9: `CanvasToolbar` (floating Panel) **Files:** - Create: `src/pages/stock/screener/components/canvas/CanvasToolbar.jsx` - [ ] **Step 1: `CanvasToolbar.jsx` 구현** ```jsx import React from 'react'; import { Panel, useReactFlow } from '@xyflow/react'; export default function CanvasToolbar({ onRunPreview, onRunSave, onPersistSettings, onResetLayout, dirty, running, }) { const { fitView } = useReactFlow(); return ( ); } ``` - [ ] **Step 2: Commit** ```bash git add src/pages/stock/screener/components/canvas/CanvasToolbar.jsx git commit -m "feat(screener): CanvasToolbar floating panel" ``` --- ### Task 10: `ScreenerCanvas` (React Flow root) **Files:** - Create: `src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx` - [ ] **Step 1: `ScreenerCanvas.jsx` 구현** ```jsx 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 activeAccent(weight, baseAccent) { if (weight > 0) return baseAccent; return '#374151'; } 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 (
); } export default function ScreenerCanvas(props) { return ( ); } ``` - [ ] **Step 2: lint 통과 확인** ```bash npx eslint src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx ``` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash git add src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx git commit -m "feat(screener): ScreenerCanvas root component (react-flow + 11 nodes + 16 edges)" ``` --- ### Task 11: `CanvasLayout` (캔버스 + 결과 영역 그리드) **Files:** - Create: `src/pages/stock/screener/components/canvas/CanvasLayout.jsx` - [ ] **Step 1: `CanvasLayout.jsx` 구현** ```jsx import React from 'react'; import ScreenerCanvas from './ScreenerCanvas'; import ResultTable from '../ResultTable'; import TelegramPreview from '../TelegramPreview'; import RunHistoryList from '../RunHistoryList'; export default function CanvasLayout({ meta, settings, setLocal, save, dirty, result, running, previewHistory, runPreview, runSave, selectPreview, runs, runs_loading, selectRun, selectedRun, compareId, setCompareId, }) { const compareItem = previewHistory.find((p) => p.id === compareId); const compareResult = compareItem?.result ?? null; const activeResult = selectedRun || result; return (
runPreview(settings)} onRunSave={() => runSave(settings)} onPersistSettings={save} />
); } ``` - [ ] **Step 2: Commit** ```bash git add src/pages/stock/screener/components/canvas/CanvasLayout.jsx git commit -m "feat(screener): CanvasLayout (canvas + result grid)" ``` --- ### Task 12: `Screener.jsx` 모드 분기 통합 **Files:** - Modify: `src/pages/stock/screener/Screener.jsx` - [ ] **Step 1: 기존 코드 전체 교체** ```jsx import React, { useState, lazy, Suspense } from 'react'; import { Link } from 'react-router-dom'; import './Screener.css'; import { useScreenerMeta } from './hooks/useScreenerMeta'; import { useScreenerSettings } from './hooks/useScreenerSettings'; import { useScreenerRun } from './hooks/useScreenerRun'; import { useScreenerHistory } from './hooks/useScreenerHistory'; import { useScreenerMode } from './hooks/useScreenerMode'; import { useIsMobile } from '../../../hooks/useIsMobile'; import GatePanel from './components/GatePanel'; import NodePanel from './components/NodePanel'; import GlobalControls from './components/GlobalControls'; import ResultTable from './components/ResultTable'; import TelegramPreview from './components/TelegramPreview'; import RunHistoryList from './components/RunHistoryList'; import ModeToggle from './components/ModeToggle'; const CanvasLayout = lazy(() => import('./components/canvas/CanvasLayout')); export default function Screener() { const { meta, loading: metaLoading } = useScreenerMeta(); const { settings, dirty, setLocal, save } = useScreenerSettings(); const { result, running, previewHistory, runPreview, runSave, selectPreview } = useScreenerRun(); const { runs, runs_loading, selectRun, selectedRun } = useScreenerHistory(); const { mode, setMode } = useScreenerMode(); const isMobile = useIsMobile(); const effectiveMode = isMobile ? 'form' : mode; const [compareId, setCompareId] = useState(null); const compareItem = previewHistory.find((p) => p.id === compareId); const compareResult = compareItem?.result ?? null; const activeResult = selectedRun || result; if (metaLoading || !meta || !settings) { return
로딩 중…
; } return (

스크리너

최근 자동 잡: {runs?.find(r => r.mode === 'auto')?.asof ?? '-'} · 분석 기준일: {activeResult?.asof ?? settings.asof ?? '-'}

{!isMobile && }
{effectiveMode === 'form' ? (
) : ( 캔버스 로딩 중…
}> )} ); } ``` - [ ] **Step 2: lint 통과 확인** ```bash npx eslint src/pages/stock/screener/Screener.jsx ``` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash git add src/pages/stock/screener/Screener.jsx git commit -m "feat(screener): integrate mode toggle (form|canvas) with lazy canvas" ``` --- ### Task 13: 캔버스 CSS **Files:** - Create: `src/pages/stock/screener/components/canvas/Canvas.css` - Modify: `src/pages/stock/screener/Screener.css` - [ ] **Step 1: `Canvas.css` 작성** ```css /* ─────────── ModeToggle 헤더 컨트롤 ─────────── */ .screener-mode-toggle { display: inline-flex; background: #111827; border: 1px solid #1f2937; border-radius: 8px; overflow: hidden; } .screener-mode-toggle button { padding: 6px 14px; background: transparent; color: #9ca3af; border: 0; cursor: pointer; font-size: 0.9rem; } .screener-mode-toggle button.active { background: #fbbf24; color: #111827; font-weight: 600; } .screener-header-right { display: flex; align-items: center; gap: 16px; } /* ─────────── CanvasLayout 그리드 ─────────── */ .screener-canvas-layout { display: flex; flex-direction: column; gap: 16px; } .screener-canvas-area { height: 65vh; min-height: 480px; border: 1px solid #1f2937; border-radius: 12px; overflow: hidden; background: #0b1220; } .screener-canvas-results { display: grid; grid-template-columns: 1fr 300px; gap: 16px; } .screener-canvas-results-main { display: flex; flex-direction: column; gap: 12px; } .screener-canvas-results-side { min-width: 0; } /* ─────────── React Flow 내부 ─────────── */ .screener-canvas-wrap { width: 100%; height: 100%; } /* ─────────── 노드 카드 공통 ─────────── */ .canvas-node { background: #111827; border: 1px solid #1f2937; border-radius: 10px; color: #e5e7eb; font-size: 12px; padding: 10px 12px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4); } .canvas-node-title { display: flex; align-items: center; gap: 6px; font-weight: 600; font-size: 13px; } .canvas-node-icon { font-size: 14px; } .canvas-node-info { margin-left: auto; color: #9ca3af; cursor: help; } .canvas-node-subtitle, .canvas-node-summary { color: #9ca3af; font-size: 11px; margin-top: 4px; } /* ─────────── 고정 노드 (회색) ─────────── */ .canvas-node--fixed { width: 200px; } .canvas-node--data { border-left: 3px solid #4b5563; } .canvas-node--combine { border-left: 3px solid #6b7280; } .canvas-node--result { border-left: 3px solid #6b7280; } /* ─────────── 게이트 노드 (노랑) ─────────── */ .canvas-node--gate { width: 220px; border-left: 4px solid #facc15; } /* ─────────── 점수 노드 (accent) ─────────── */ .canvas-node--score { width: 240px; border-left: 4px solid var(--canvas-accent, #3b82f6); } .canvas-node--score.is-inactive { opacity: 0.45; filter: grayscale(0.6); } .canvas-node-weight { display: flex; align-items: center; gap: 8px; margin-top: 8px; } .canvas-node-weight input[type=range] { flex: 1; } .canvas-node-weight-value { min-width: 32px; text-align: right; color: var(--canvas-accent, #3b82f6); font-variant-numeric: tabular-nums; } .canvas-node-active { display: flex; align-items: center; gap: 6px; margin-top: 6px; color: #d1d5db; } .canvas-node-expand { width: 100%; margin-top: 8px; padding: 4px 0; background: transparent; color: #9ca3af; border: 1px dashed #374151; border-radius: 6px; cursor: pointer; font-size: 11px; } .canvas-node-params { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; } .canvas-param-field { display: flex; justify-content: space-between; align-items: center; gap: 8px; color: #d1d5db; font-size: 11px; } .canvas-param-field input[type=number] { width: 70px; background: #0b1220; color: #e5e7eb; border: 1px solid #1f2937; border-radius: 4px; padding: 2px 6px; } /* ─────────── floating toolbar ─────────── */ .canvas-toolbar { display: flex; gap: 6px; padding: 6px; background: rgba(17, 24, 39, 0.75); backdrop-filter: blur(8px); border: 1px solid #1f2937; border-radius: 10px; } .canvas-toolbar-btn { padding: 6px 12px; background: #1f2937; color: #e5e7eb; border: 1px solid #374151; border-radius: 6px; cursor: pointer; font-size: 12px; } .canvas-toolbar-btn:hover:not(:disabled) { background: #374151; } .canvas-toolbar-btn:disabled { opacity: 0.45; cursor: not-allowed; } .canvas-toolbar-btn--primary { background: #fbbf24; color: #111827; border-color: #fbbf24; font-weight: 600; } .canvas-toolbar-btn--primary:hover:not(:disabled) { background: #f59e0b; } /* ─────────── 모바일 (캔버스는 숨겨지므로 ModeToggle만 영향) ─────────── */ @media (max-width: 768px) { .screener-canvas-results { grid-template-columns: 1fr; } } ``` - [ ] **Step 2: `Screener.css` 에 Canvas.css import 추가** `Screener.css` 첫 줄에 다음 추가: ```css @import './components/canvas/Canvas.css'; ``` - [ ] **Step 3: Commit** ```bash git add src/pages/stock/screener/components/canvas/Canvas.css src/pages/stock/screener/Screener.css git commit -m "style(screener): canvas mode styles (toggle, nodes, toolbar, layout)" ``` --- ### Task 14: 전체 테스트 스위트 통과 확인 **Files:** - (테스트만 실행) - [ ] **Step 1: 전체 테스트 실행** ```bash cd C:\Users\jaeoh\Desktop\workspace\web-ui npx vitest run ``` Expected: 4개 테스트 파일, 총 20개 테스트 PASS. - `canvasLayout.test.js` — 6 tests - `useScreenerMode.test.js` — 4 tests - `useCanvasLayout.test.js` — 5 tests - `ScoreNodeCard.test.jsx` — 5 tests - [ ] **Step 2: 빌드 통과 확인** ```bash npm run build ``` Expected: `dist/` 디렉토리 생성, 빌드 에러 없음. `@xyflow/react` 가 별도 chunk로 분리되었는지 확인 (lazy import 때문). - [ ] **Step 3: lint 통과 확인** ```bash npm run lint ``` Expected: 0 errors. warning은 무시 가능. --- ### Task 15: 수동 검증 + 배포 **Files:** - (실행만, 수동 점검) - [ ] **Step 1: dev 서버 실행** ```bash cd C:\Users\jaeoh\Desktop\workspace\web-ui npm run dev ``` 브라우저에서 `http://localhost:3007/stock/screener` 열기. - [ ] **Step 2: 폼 모드 회귀 검증** 폼 모드(기본)에서: - [ ] 좌측 GatePanel, NodePanel, GlobalControls 정상 렌더 - [ ] 가중치 슬라이더 변경 → dirty 표시 - [ ] "지금 실행" → 결과 테이블 갱신 - [ ] 모든 기존 기능 정상 - [ ] **Step 3: 캔버스 모드 검증** 헤더 우상단 [폼][캔버스] 토글에서 **캔버스** 클릭: - [ ] 캔버스에 11개 노드, 16개 엣지 사전 배치 표시 - [ ] 점수 노드 카드: 가중치 슬라이더, 활성 체크, ▾ 파라미터 펼치기 동작 - [ ] 점수 카드 변경 → 토글로 폼 모드 복귀 시 값 유지 - [ ] 게이트 카드: ▾ 파라미터 펼치기, 값 변경 후 폼 모드 복귀 시 유지 - [ ] 결합 카드 부제목에 `Top N · RR · ATR×M` 표시 - [ ] 결과 카드 부제목에 마지막 실행 정보 표시 - [ ] floating 툴바 5개 버튼 모두 클릭 동작 - [ ] ▶ 실행 → 하단 ResultTable 갱신 - [ ] 노드 드래그 후 새로고침 → 위치 복원 - [ ] 🔄 → 초기 좌표 복귀 - [ ] 점수 노드 weight=0 으로 만들면 카드 흐릿 + 해당 엣지 점선·회색 - [ ] ⛶ → 화면 맞춤 - [ ] **Step 4: 모바일 검증** DevTools → 모바일 뷰 (360×640): - [ ] 헤더에 토글 미표시 - [ ] 폼 모드 모바일 카드 layout 정상 - [ ] localStorage에 `canvas` 가 저장돼 있어도 강제로 폼 모드 - [ ] **Step 5: localStorage 손상 복구 검증** DevTools Application 탭에서: - [ ] `screener-mode-v1` 을 `"INVALID"` 로 수정 → 새로고침 → 폼 모드로 fallback - [ ] `screener-canvas-layout-v1` 을 `"NOT_JSON"` 으로 수정 → 새로고침 → 캔버스 초기 좌표로 복귀 - [ ] **Step 6: 배포** ```bash cd C:\Users\jaeoh\Desktop\workspace\web-ui npm run release:nas ``` Expected: 빌드 + robocopy 성공. NAS에서 https://gahusb.synology.me/stock/screener 접속해 동작 확인. - [ ] **Step 7: CLAUDE.md 업데이트** `web-ui/CLAUDE.md` 의 페이지 구조 표에서 `/stock/screener` 행 설명을 다음으로 갱신: ``` | `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) | ``` - [ ] **Step 8: 최종 commit + push** ```bash git add src/pages/stock/screener/CLAUDE.md # if needed git add CLAUDE.md git commit -m "docs(screener): note canvas mode in page structure" git push origin main ``` --- ## 완료 후 후속 메모 업데이트 안내 본 plan 완료 시 다음 메모리 파일을 업데이트 권장: - `project_stock_screener.md` — 캔버스 모드 완료 표시 + 후속 슬라이스에서 "노드 캔버스 UI" 항목 제거 - 신규 reference: `reference_react_flow.md` — `@xyflow/react` 사용 패턴 (lazy import + jsdom polyfills + Panel 슬롯) 다음 자연스러운 슬라이스 (spec §14): 1. 주간 자가학습 (가중치 자동 조정 제안) 2. 백테스트 화면 3. AI 뉴스 호재/악재 노드