15-task TDD plan. 의존성 + 테스트 환경 셋업 → 상수/hooks/카드/캔버스 → Screener.jsx 통합 → 수동 검증 + 배포. 단위 테스트 20개 (4 파일), react-flow lazy import로 모바일 번들 보호. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1827 lines
53 KiB
Markdown
1827 lines
53 KiB
Markdown
# 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 (
|
||
<div className="screener-mode-toggle" role="tablist" aria-label="화면 모드">
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={value === 'form'}
|
||
className={value === 'form' ? 'active' : ''}
|
||
onClick={() => onChange('form')}
|
||
>
|
||
폼
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={value === 'canvas'}
|
||
className={value === 'canvas' ? 'active' : ''}
|
||
onClick={() => onChange('canvas')}
|
||
>
|
||
캔버스
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<div className={`canvas-node canvas-node--fixed canvas-node--${kind}`}>
|
||
{hasInput && <Handle type="target" position={Position.Left} isConnectable={false} />}
|
||
<div className="canvas-node-title">
|
||
<span className="canvas-node-icon">{icon}</span>
|
||
<span>{title}</span>
|
||
</div>
|
||
{subtitle && <div className="canvas-node-subtitle">{subtitle}</div>}
|
||
{hasOutput && <Handle type="source" position={Position.Right} isConnectable={false} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<label className="canvas-param-field">
|
||
<input
|
||
type="checkbox"
|
||
checked={!!value}
|
||
onChange={(e) => onChange(name, e.target.checked)}
|
||
/>
|
||
<span>{schema.label || name}</span>
|
||
</label>
|
||
);
|
||
}
|
||
return (
|
||
<label className="canvas-param-field">
|
||
<span>{schema?.label || name}</span>
|
||
<input
|
||
type="number"
|
||
value={value ?? schema?.default ?? 0}
|
||
step={schema?.step ?? 1}
|
||
onChange={(e) => onChange(name, Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function GateNodeCard({ data }) {
|
||
const { meta, params, onChange, description } = data;
|
||
const [expanded, setExpanded] = useState(false);
|
||
|
||
const update = (key, v) => onChange({ ...params, [key]: v });
|
||
|
||
return (
|
||
<div className="canvas-node canvas-node--gate">
|
||
<Handle type="target" position={Position.Left} isConnectable={false} />
|
||
<div className="canvas-node-title">
|
||
<span className="canvas-node-icon">🛡️</span>
|
||
<span>{meta?.label || '위생 게이트'}</span>
|
||
{description && (
|
||
<span className="canvas-node-info" title={description}>ⓘ</span>
|
||
)}
|
||
</div>
|
||
<div className="canvas-node-summary">통과해야 점수 단계 진입</div>
|
||
<button
|
||
type="button"
|
||
className="canvas-node-expand"
|
||
onClick={() => setExpanded((v) => !v)}
|
||
>
|
||
{expanded ? '▴ 파라미터' : '▾ 파라미터'}
|
||
</button>
|
||
{expanded && (
|
||
<div className="canvas-node-params">
|
||
{Object.entries(meta?.param_schema || {}).map(([key, schema]) => (
|
||
<ParamField
|
||
key={key}
|
||
name={key}
|
||
schema={schema}
|
||
value={params?.[key]}
|
||
onChange={update}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
<Handle type="source" position={Position.Right} isConnectable={false} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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(
|
||
<ReactFlowProvider>
|
||
<ScoreNodeCard data={data} />
|
||
</ReactFlowProvider>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<label className="canvas-param-field">
|
||
<span>{schema?.label || name}</span>
|
||
<input
|
||
type="number"
|
||
value={value ?? schema?.default ?? 0}
|
||
step={schema?.step ?? 1}
|
||
onChange={(e) => onChange(name, Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
className={`canvas-node canvas-node--score ${active ? '' : 'is-inactive'}`}
|
||
style={{ '--canvas-accent': accent || '#3b82f6' }}
|
||
>
|
||
<Handle type="target" position={Position.Left} isConnectable={false} />
|
||
<div className="canvas-node-title">
|
||
<span className="canvas-node-icon">{icon}</span>
|
||
<span>{meta?.label || meta?.name}</span>
|
||
{description && (
|
||
<span className="canvas-node-info" title={description}>ⓘ</span>
|
||
)}
|
||
</div>
|
||
{summary && <div className="canvas-node-summary">{summary}</div>}
|
||
<div className="canvas-node-weight">
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={1}
|
||
step={0.05}
|
||
value={weight}
|
||
onChange={(e) => onWeightChange(Number(e.target.value))}
|
||
aria-label="가중치"
|
||
/>
|
||
<span className="canvas-node-weight-value">{weight.toFixed(2)}</span>
|
||
</div>
|
||
<label className="canvas-node-active">
|
||
<input
|
||
type="checkbox"
|
||
checked={active}
|
||
onChange={toggleActive}
|
||
/>
|
||
<span>활성</span>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
className="canvas-node-expand"
|
||
onClick={() => setExpanded((v) => !v)}
|
||
>
|
||
{expanded ? '▴ 파라미터' : '▾ 파라미터'}
|
||
</button>
|
||
{expanded && (
|
||
<div className="canvas-node-params">
|
||
{Object.entries(meta?.param_schema || {}).map(([key, schema]) => (
|
||
<ParamField
|
||
key={key}
|
||
name={key}
|
||
schema={schema}
|
||
value={params?.[key]}
|
||
onChange={updateParam}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
<Handle type="source" position={Position.Right} isConnectable={false} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Panel position="top-left" className="canvas-toolbar">
|
||
<button
|
||
type="button"
|
||
className="canvas-toolbar-btn canvas-toolbar-btn--primary"
|
||
disabled={running}
|
||
onClick={onRunPreview}
|
||
title="현재 가중치로 미리보기 실행"
|
||
>
|
||
{running ? '실행 중…' : '▶ 실행'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="canvas-toolbar-btn"
|
||
disabled={running}
|
||
onClick={onRunSave}
|
||
title="실행 결과를 DB에 저장"
|
||
>
|
||
💾 저장 실행
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="canvas-toolbar-btn"
|
||
disabled={!dirty}
|
||
onClick={onPersistSettings}
|
||
title="현재 설정을 영구 저장"
|
||
>
|
||
📌 설정 저장{dirty ? ' *' : ''}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="canvas-toolbar-btn"
|
||
onClick={onResetLayout}
|
||
title="노드 위치를 초기 좌표로 복귀"
|
||
>
|
||
🔄
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="canvas-toolbar-btn"
|
||
onClick={() => fitView({ padding: 0.2, duration: 300 })}
|
||
title="화면에 맞춤"
|
||
>
|
||
⛶
|
||
</button>
|
||
</Panel>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<div className="screener-canvas-wrap">
|
||
<ReactFlow
|
||
nodes={nodes}
|
||
edges={edges}
|
||
nodeTypes={nodeTypes}
|
||
nodesConnectable={false}
|
||
edgesUpdatable={false}
|
||
edgesFocusable={false}
|
||
onNodeDragStop={handleNodeDragStop}
|
||
defaultViewport={{ x: 0, y: 0, zoom: 0.85 }}
|
||
fitView
|
||
fitViewOptions={{ padding: 0.2 }}
|
||
proOptions={{ hideAttribution: true }}
|
||
>
|
||
<Background gap={20} size={1} color="#1f2937" />
|
||
<Controls showInteractive={false} />
|
||
<CanvasToolbar
|
||
onRunPreview={onRunPreview}
|
||
onRunSave={onRunSave}
|
||
onPersistSettings={onPersistSettings}
|
||
onResetLayout={reset}
|
||
dirty={dirty}
|
||
running={running}
|
||
/>
|
||
</ReactFlow>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ScreenerCanvas(props) {
|
||
return (
|
||
<ReactFlowProvider>
|
||
<ScreenerCanvasInner {...props} />
|
||
</ReactFlowProvider>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<div className="screener-canvas-layout">
|
||
<section className="screener-canvas-area">
|
||
<ScreenerCanvas
|
||
meta={meta}
|
||
settings={settings}
|
||
setLocal={setLocal}
|
||
result={activeResult}
|
||
running={running}
|
||
dirty={dirty}
|
||
onRunPreview={() => runPreview(settings)}
|
||
onRunSave={() => runSave(settings)}
|
||
onPersistSettings={save}
|
||
/>
|
||
</section>
|
||
<section className="screener-canvas-results">
|
||
<div className="screener-canvas-results-main">
|
||
<ResultTable
|
||
result={activeResult}
|
||
compareWith={compareResult}
|
||
compareLabel={compareItem ? new Date(compareItem.timestamp).toLocaleTimeString() : null}
|
||
/>
|
||
<TelegramPreview payload={activeResult?.telegram_payload} />
|
||
</div>
|
||
<aside className="screener-canvas-results-side">
|
||
<RunHistoryList
|
||
runs={runs}
|
||
loading={runs_loading}
|
||
onSelect={selectRun}
|
||
selectedId={selectedRun?.meta?.id}
|
||
previewHistory={previewHistory}
|
||
onSelectPreview={selectPreview}
|
||
onSetCompare={setCompareId}
|
||
compareId={compareId}
|
||
/>
|
||
</aside>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 <div className="screener-loading">로딩 중…</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="screener-page">
|
||
<header className="screener-header">
|
||
<div>
|
||
<h1>스크리너</h1>
|
||
<p className="meta">
|
||
최근 자동 잡: {runs?.find(r => r.mode === 'auto')?.asof ?? '-'}
|
||
· 분석 기준일: {activeResult?.asof ?? settings.asof ?? '-'}
|
||
</p>
|
||
</div>
|
||
<div className="screener-header-right">
|
||
{!isMobile && <ModeToggle value={mode} onChange={setMode} />}
|
||
<nav>
|
||
<Link to="/stock">시장</Link>
|
||
<Link to="/stock/trade">트레이드</Link>
|
||
</nav>
|
||
</div>
|
||
</header>
|
||
|
||
{effectiveMode === 'form' ? (
|
||
<div className="screener-grid">
|
||
<aside className="screener-left">
|
||
<GatePanel
|
||
meta={meta.gate_nodes[0]}
|
||
value={settings.gate_params}
|
||
onChange={(p) => setLocal({ ...settings, gate_params: p })}
|
||
/>
|
||
<NodePanel
|
||
meta={meta.score_nodes}
|
||
weights={settings.weights}
|
||
params={settings.node_params}
|
||
onWeights={(w) => setLocal({ ...settings, weights: w })}
|
||
onParams={(p) => setLocal({ ...settings, node_params: p })}
|
||
/>
|
||
<GlobalControls
|
||
settings={settings} setSettings={setLocal}
|
||
onRun={() => runPreview(settings)}
|
||
onSave={() => runSave(settings)}
|
||
onPersist={save}
|
||
dirty={dirty}
|
||
running={running}
|
||
/>
|
||
</aside>
|
||
<main className="screener-center">
|
||
<ResultTable
|
||
result={activeResult}
|
||
compareWith={compareResult}
|
||
compareLabel={compareItem ? new Date(compareItem.timestamp).toLocaleTimeString() : null}
|
||
/>
|
||
<TelegramPreview payload={activeResult?.telegram_payload} />
|
||
</main>
|
||
<aside className="screener-right">
|
||
<RunHistoryList
|
||
runs={runs}
|
||
loading={runs_loading}
|
||
onSelect={selectRun}
|
||
selectedId={selectedRun?.meta?.id}
|
||
previewHistory={previewHistory}
|
||
onSelectPreview={selectPreview}
|
||
onSetCompare={setCompareId}
|
||
compareId={compareId}
|
||
/>
|
||
</aside>
|
||
</div>
|
||
) : (
|
||
<Suspense fallback={<div className="screener-loading">캔버스 로딩 중…</div>}>
|
||
<CanvasLayout
|
||
meta={meta}
|
||
settings={settings}
|
||
setLocal={setLocal}
|
||
save={save}
|
||
dirty={dirty}
|
||
result={result}
|
||
running={running}
|
||
previewHistory={previewHistory}
|
||
runPreview={runPreview}
|
||
runSave={runSave}
|
||
selectPreview={selectPreview}
|
||
runs={runs}
|
||
runs_loading={runs_loading}
|
||
selectRun={selectRun}
|
||
selectedRun={selectedRun}
|
||
compareId={compareId}
|
||
setCompareId={setCompareId}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 뉴스 호재/악재 노드
|