diff --git a/docs/superpowers/plans/2026-05-13-screener-node-canvas.md b/docs/superpowers/plans/2026-05-13-screener-node-canvas.md new file mode 100644 index 0000000..b603d53 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-screener-node-canvas.md @@ -0,0 +1,1826 @@ +# 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 뉴스 호재/악재 노드