# 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 (
{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 뉴스 호재/악재 노드