Files
web-page/docs/superpowers/plans/2026-05-13-screener-node-canvas.md
gahusb d73ad9b851 docs(screener): node canvas mode implementation plan (15 tasks)
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>
2026-05-13 21:29:22 +09:00

1827 lines
53 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 뉴스 호재/악재 노드