Files
web-page/docs/superpowers/specs/2026-05-13-screener-node-canvas-design.md
gahusb fdf5ef6ce8 docs(screener): node canvas mode design spec
n8n 스타일 노드 캔버스 모드 설계 문서. 폼 모드와 토글로 전환,
같은 settings state 공유. 11 노드 + 16 엣지 고정 토폴로지, react-flow
기반 시각화. 백엔드 변경 없음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:22:57 +09:00

506 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
# Stock Screener — Node Canvas Mode Design
**작성일**: 2026-05-13
**작성자**: gahusb
**상태**: Approved for implementation
**선행 spec**: `2026-05-12-stock-screener-board-design.md` (§14 — react-flow 노드 캔버스 후속 슬라이스)
---
## 1. 목표
`/stock/screener` 페이지에 **n8n 스타일 노드 캔버스 모드**를 추가한다. 폼 모드와 토글로 전환하며, 같은 settings state를 공유한다. 백엔드는 변경하지 않는다 — 캔버스는 시각화 + 편집 UI일 뿐, 결과적으로는 동일한 `weights / node_params / gate_params``/api/stock/screener/run` 에 전송한다.
**Why**: 사용자가 슬라이더만 들여다보는 폼 모드는 "어떤 노드가 어떤 단계에서 무엇을 하는지"의 파이프라인 감각이 약하다. n8n/Figma류 캔버스 시각화는 데이터 흐름을 한눈에 보여줘 강세주 분석 모델의 구조적 이해를 돕는다.
---
## 2. 범위
**포함 (이번 슬라이스)**:
- 헤더 토글 (`폼 ↔ 캔버스`) — 데스크탑 전용
- 11개 노드의 미니 파이프라인 시각화 (고정 토폴로지)
- 점수 노드 카드 위 가중치/활성/핵심 파라미터 인라인 편집 + 설명 표시
- floating 미니 툴바 (실행 / 저장 실행 / 설정 영구 저장 / 레이아웃 리셋)
- 노드 위치 localStorage 저장 + 초기화 버튼
- 모바일에서는 캔버스 토글 숨김, 폼 강제
**범위 외 (NOT)**:
- 노드 추가/삭제 UI (토폴로지 고정)
- 노드 간 연결선 사용자 편집
- 자유 그래프 모드 (별도 후속 슬라이스)
- 캔버스 안 결과 노드에 결과 표시 (외부 테이블에만 표시)
- 노드 캔버스 화면 자체에서의 대화형 백테스트
- dagre 등 자동 레이아웃 알고리즘
---
## 3. 아키텍처 개요
```
┌─────────────────────────────┐
│ Screener.jsx (entrypoint) │
│ - useScreenerMode (form|canvas) │
│ - useIsMobile() → 강제 form │
└────────────┬────────────────┘
┌────────────────┼────────────────┐
│ │ │
form mode canvas mode shared result area
(기존 그대로) (신규) (기존 그대로)
│ │ │
┌──────────┴──┐ ┌─────────┴──────┐ ┌────┴──────┐
│ GatePanel │ │ ScreenerCanvas │ │ ResultTable
│ NodePanel │ │ + CanvasToolbar│ │ TelegramPreview
│ GlobalControls│ │ + Node cards │ │ RunHistoryList
└──────────────┘ └─────────────────┘ └───────────┘
↑ ↑ ↑
└────────────────┴────────────────┘
공유 state: useScreenerSettings,
useScreenerRun, useScreenerHistory
```
**의존성 추가**: `@xyflow/react` (구 react-flow, MIT, ~50KB gzipped).
**백엔드 변경 없음**. 캔버스는 settings를 동일한 형태로 만들고, 동일한 `/run` 엔드포인트를 호출한다.
---
## 4. 화면 레이아웃
### 4.1 데스크탑 — 캔버스 모드
```
┌───────────────────────────────────────────────────────────┐
│ Header: 스크리너 [폼] [캔버스] │
│ 최근 자동잡: 2026-05-13 · 분석 기준일: 2026-05-13│
├───────────────────────────────────────────────────────────┤
│ ╔═════════════════════════════════════════════════════╗ │
│ ║ ┌─ floating toolbar ──────────────────────────┐ ║ │
│ ║ │ ▶ 실행 💾 저장 실행 📌 설정 저장 🔄 ⛶ │ ║ │
│ ║ └──────────────────────────────────────────────┘ ║ │
│ ║ ║ │
│ ║ ┌─────┐ ┌──────┐ ┌───────┐ ║ │
│ ║ │📥KRX│→ │🛡️위생│ ┬→│외국인 │ ┐ ║ │
│ ║ │data │ │gate │ ├→│거래량 │ │ ┌─────────────┐ ║ │
│ ║ └─────┘ └──────┘ ├→│모멘텀 │ ┼→ │⚙️가중합+TopN │→ │📊│║│
│ ║ ├→│52w고가│ │ │ +ATR 사이저 │ ║ │
│ ║ ├→│RS │ │ └─────────────┘ ║ │
│ ║ ├→│이평선│ ┤ ║ │
│ ║ └→│VCP │ ┘ ║ │
│ ║ ║ │
│ ║ (캔버스 영역: 화면 높이의 약 60-65%) ║ │
│ ╚═══════════════════════════════════════════════════════╝ │
├───────────────────────────────────────────────────────────┤
│ ResultTable (기존 그대로) — 비교 모드 그대로 │
│ TelegramPreview (기존 그대로) │
│ RunHistoryList (기존 그대로 — 우측 사이드) │
└───────────────────────────────────────────────────────────┘
```
**그리드 구성 (캔버스 모드)**:
- Row 1 — 헤더 (높이 자동)
- Row 2 — 캔버스 영역 (`min-height: 60vh`, `max-height: 70vh`)
- Row 3 — 2-column: 좌측 `ResultTable + TelegramPreview` (flex 1), 우측 `RunHistoryList` (width 300px)
폼 모드의 3-column 그리드(좌 사이드/센터/우 사이드)와 달리, 캔버스 모드는 캔버스가 가로 전체를 쓰고 결과 영역만 2-column으로 분리. `RunHistoryList` 의 위치는 두 모드 모두 "우측 결과 사이드"로 일관.
### 4.2 데스크탑 — 폼 모드
기존 layout 그대로. 헤더에 토글 [폼] [캔버스]만 추가.
### 4.3 모바일 (<768px)
기존 모바일 카드 layout 그대로. 헤더 토글 자체를 렌더하지 않음. localStorage에 `mode='canvas'`로 저장돼 있어도 무시.
---
## 5. 노드 종류
총 11개 노드, 4개 카테고리.
| 카테고리 | 노드 | 편집 | 색상 | 표시 정보 |
|----------|------|------|------|-----------|
| **데이터** | `📥 KRX 데이터` | 불가 | 회색 | "~2,800종목 · FDR" |
| **게이트** | `🛡️ 위생 게이트` | 가능 | 노랑 | 파라미터 (min_market_cap 등) + 활성/비활성 |
| **점수** | `📈 외국인` | 가능 | 컬러 | 가중치 + 핵심 파라미터 + 설명 |
| **점수** | `📊 거래량 급증` | 가능 | 컬러 | 동일 |
| **점수** | `🚀 모멘텀` | 가능 | 컬러 | 동일 |
| **점수** | `🔝 52w 고가` | 가능 | 컬러 | 동일 |
| **점수** | `💪 RS Rating` | 가능 | 컬러 | 동일 |
| **점수** | `📉 이평선 정렬` | 가능 | 컬러 | 동일 |
| **점수** | `🌀 VCP-lite` | 가능 | 컬러 | 동일 |
| **결합** | `⚙️ 가중합+TopN+ATR` | 불가 | 회색 | "TopN=10 · ATR×2" 등 현재 settings 요약 |
| **결과** | `📊 결과` | 불가 | 회색 | "마지막 실행: 2026-05-13 · 8종목 통과" |
점수 노드의 컬러는 기존 `NODE_META` 의 accent color 시스템과 동기화 — 폼 모드에서 쓰던 색상이 캔버스에서도 동일하게 적용.
---
## 6. 노드 카드 디자인
### 6.1 점수 노드 카드 (편집 가능)
```
┌──────────────────────────────────┐
│ 📈 거래량 급증 ⓘ │ ← 호버 시 풀 설명 툴팁
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
│ "20일 평균 대비 2배 이상" │ ← 항상 표시되는 한 줄 요약
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
│ 가중치 [█████░░░░░] 0.5 │ ← 슬라이더 (0~1, step 0.05)
│ ☑ 활성 │ ← 체크박스. uncheck = weight 0
│ │
│ ▾ 파라미터 (펼치면) │
│ lookback_days: [ 20 ] 일 │
│ multiplier: [2.0 ] │
└──────────────────────────────────┘
```
- 한 줄 요약: 기존 `NODE_META[name].summary` (없으면 `description` 첫 줄)
- 풀 설명 (호버 툴팁): 기존 `NODE_META[name].description`
- 파라미터 폼: `param_schema` 기반 자동 생성 (기존 `NodeCard.jsx` 와 동일 로직 재사용)
### 6.2 게이트 노드 카드 (편집 가능, 노랑)
```
┌──────────────────────────────────┐
│ 🛡️ 위생 게이트 ⓘ │
│ "통과해야 점수 단계 진입" │
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
│ ☑ 활성 │
│ ▾ 파라미터 │
│ min_market_cap: [50] 억원 │
│ exclude_spac: ☑ │
│ ... │
└──────────────────────────────────┘
```
### 6.3 고정 노드 카드 (정보 표시만, 회색)
```
┌────────────────────┐
│ 📥 KRX 데이터 │
│ ~2,800종목 · FDR │
└────────────────────┘
```
결합 노드는 동적으로 현재 settings를 요약 표시:
```
┌────────────────────────────┐
│ ⚙️ 가중합 + TopN + ATR │
│ Top 10 · RR 2.0 · ATR×2 │ ← settings에서 계산해서 표시
└────────────────────────────┘
```
결과 노드도 동적:
```
┌──────────────────────────┐
│ 📊 결과 │
│ 마지막 실행: 14:32 │
│ 8 / 12 종목 통과 │
└──────────────────────────┘
```
---
## 7. 캔버스 인터랙션
| 동작 | 결과 |
|------|------|
| 노드 드래그 | 위치 변경 → 드래그 종료 시 `screener-canvas-layout-v1` localStorage에 저장 |
| 슬라이더 변경 | `useScreenerSettings.setLocal({...settings, weights: {...}})``dirty=true` |
| 체크박스 (활성) | weight 토글: uncheck 시 weight=0 저장, check 시 이전 값 복원 (default = 0.5) |
| 파라미터 ▾ 펼치기 | 카드 높이 동적 확장 |
| 마우스 휠 | 줌 (React Flow 기본) |
| 드래그 (빈 공간) | 팬 (React Flow 기본) |
| ⛶ fitView 버튼 | 전체 노드 화면 맞춤 |
| 🔄 레이아웃 리셋 | `INITIAL_NODE_POSITIONS` 로 복귀, localStorage 키 삭제 |
| ▶ 실행 | 기존 `runPreview(settings)` → 결과는 하단 ResultTable |
| 💾 저장 실행 | 기존 `runSave(settings)` → DB 영구화 |
| 📌 설정 저장 | 기존 `save()` (settings 영구화) |
엣지 연결선은 사용자가 편집할 수 없음 (고정). React Flow 인스턴스 prop `nodesConnectable={false}`, `edgesUpdatable={false}`.
---
## 8. 컴포넌트 분해 (신규 파일)
```
src/pages/stock/screener/
Screener.jsx ← 모드 토글 추가, canvas 모드 분기 렌더
hooks/
useScreenerMode.js ← 신규: 'form' | 'canvas' state + localStorage
useCanvasLayout.js ← 신규: 노드 위치 read/write/reset
(기존 hooks 그대로)
components/
ModeToggle.jsx ← 신규: [폼][캔버스] 세그먼트 컨트롤 (헤더용)
canvas/
CanvasLayout.jsx ← 신규: 캔버스 + 결과 영역 그리드 (4.1 그리드 구성)
ScreenerCanvas.jsx ← React Flow 루트 컨테이너
CanvasToolbar.jsx ← floating Panel (실행/저장/리셋/fitView)
nodes/
ScoreNodeCard.jsx ← 점수 노드 카드 (편집)
GateNodeCard.jsx ← 게이트 노드 카드 (편집)
FixedNodeCard.jsx ← 데이터/결합/결과 카드 (정보만)
constants/
canvasLayout.js ← INITIAL_NODE_POSITIONS / EDGES / NODE_KIND_MAP
(기존 components 그대로 — 폼 모드에서 계속 사용)
```
기존 컴포넌트(`GatePanel`, `NodePanel`, `GlobalControls`, `ResultTable`, `TelegramPreview`, `RunHistoryList`)는 **변경 없음**. 결과 영역은 모드와 무관하게 동일.
### 8.1 `Screener.jsx` 변경점
```jsx
const { mode, setMode } = useScreenerMode();
const isMobile = useIsMobile();
const effectiveMode = isMobile ? 'form' : mode;
return (
<div className="screener-page">
<header className="screener-header">
<h1>스크리너</h1>
{!isMobile && (
<ModeToggle value={mode} onChange={setMode} />
)}
</header>
{effectiveMode === 'form' ? (
<FormLayout {...sharedProps} /> /* 기존 grid layout */
) : (
<CanvasLayout {...sharedProps} /> /* 신규 — 캔버스 + 동일 결과 영역 */
)}
</div>
);
```
---
## 9. 데이터 / state 설계
### 9.1 localStorage 키
| 키 | shape | 설명 |
|----|-------|------|
| `screener-mode-v1` | `'form' \| 'canvas'` | 마지막 사용 모드 |
| `screener-canvas-layout-v1` | `{ [nodeId: string]: { x: number, y: number } }` | 노드별 좌표 |
### 9.2 `useScreenerMode`
```js
export function useScreenerMode() {
const [mode, setModeState] = useState(() => {
try {
return localStorage.getItem('screener-mode-v1') || 'form';
} catch { return 'form'; }
});
const setMode = (m) => {
setModeState(m);
try { localStorage.setItem('screener-mode-v1', m); } catch {}
};
return { mode, setMode };
}
```
### 9.3 `useCanvasLayout`
```js
export function useCanvasLayout(initialPositions) {
const STORAGE_KEY = 'screener-canvas-layout-v1';
const [positions, setPositions] = useState(() => readOrInit(initialPositions));
const updateNodePosition = (nodeId, pos) => {
setPositions((prev) => {
const next = { ...prev, [nodeId]: pos };
writeSafe(next);
return next;
});
};
const reset = () => {
setPositions(initialPositions);
try { localStorage.removeItem(STORAGE_KEY); } catch {}
};
return { positions, updateNodePosition, reset };
}
```
`readOrInit` 은 JSON.parse 실패하거나 노드 ID가 누락된 경우 누락된 ID에 대해서만 `initialPositions` 값을 보충.
### 9.4 `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 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 },
};
export const EDGES = [
{ id: 'e-data-gate', source: NODE_IDS.DATA, target: NODE_IDS.GATE },
...['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP'].map((k) => ({
id: `e-gate-${k.toLowerCase()}`, source: NODE_IDS.GATE, target: NODE_IDS[k],
})),
...['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP'].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 },
];
```
총 엣지 수: 1(data→gate) + 7(gate→점수) + 7(점수→combine) + 1(combine→result) = **16개**.
---
## 10. 시각 디자인 디테일
| 요소 | 스타일 |
|------|--------|
| 캔버스 배경 | `bg-screener-canvas` (다크 그리드, 점선 `#1f2937`) |
| 고정 노드 카드 | 배경 `#1f2937`, 텍스트 `#9ca3af`, 200×64 |
| 게이트 카드 | accent `#facc15` (노랑) 좌측 4px stripe, 220×auto |
| 점수 카드 | accent = 기존 `NODE_META[name].color`, 240×auto |
| 비활성 점수 카드 | opacity 0.45 + grayscale 0.6 |
| 엣지 (active) | `#fbbf24` 1.5px, 약한 그라데이션 |
| 엣지 (해당 점수 노드 weight=0) | `#374151` 1px, 점선 |
| 미니맵 | **사용하지 않음** (캔버스 크기가 작아 불필요) |
| Controls (줌/리셋) | React Flow `<Controls />` 좌하단, 미니멀 |
| floating toolbar | 좌상단, `position: absolute`, `backdrop-filter: blur(8px)`, 반투명 |
---
## 11. 모바일/엣지 케이스
| 케이스 | 처리 |
|--------|------|
| 모바일 진입 (≤768px) | 토글 미렌더, `effectiveMode = 'form'` 강제 |
| 데스크탑 → 모바일 리사이즈 중 | `useIsMobile` 가 자동 감지 → 폼으로 폴백 |
| localStorage 파싱 실패 | catch + reset → 초기 위치/모드로 복귀 |
| 노드 ID 누락 (마이그레이션) | 누락 노드만 `INITIAL_NODE_POSITIONS` 값 사용, 나머지는 저장값 유지 |
| 노드 ID 신규 추가 (후속) | 같은 누락 처리 로직으로 자동 흡수 |
| React Flow 초기 렌더 깜빡임 | `fitView` 초기 옵션 + `defaultViewport` 명시로 흡수 |
---
## 12. 테스트 전략
캔버스는 시각화 위주라 E2E 테스트 비용이 크므로 **단위 테스트 중심**으로 간다.
### 12.1 단위 테스트 (web-ui)
| 파일 | 검증 |
|------|------|
| `useScreenerMode.test.js` | 초기값 'form', set 후 localStorage 반영, 손상 시 fallback |
| `useCanvasLayout.test.js` | 초기 positions 반환, updateNodePosition 후 localStorage 반영, reset 후 storage 삭제, 손상 시 initial 반환, 누락 ID 시 initial 보충 |
| `canvasLayout.test.js` | EDGES 정합성: 모든 점수 노드가 gate 입력과 combine 출력을 가짐, source/target ID가 NODE_IDS 안에 존재 |
| `ScoreNodeCard.test.jsx` | 슬라이더 onChange 호출, 비활성 체크박스 시 weight=0, 활성 복원 시 default 0.5 |
### 12.2 통합 (가볍게)
- `Screener.test.jsx` 회귀: 폼 모드 기본 렌더 후 토글로 캔버스 진입, 다시 폼으로 — settings state 유지 확인
### 12.3 수동 검증 체크리스트
배포 전 데스크탑 브라우저:
- [ ] 토글 폼↔캔버스 전환 시 가중치 동기화
- [ ] 캔버스에서 슬라이더 → `dirty` 표시 정상
- [ ] `▶ 실행` → 하단 ResultTable 갱신
- [ ] 노드 드래그 → 새로고침 후 위치 복원
- [ ] `🔄` 리셋 → 초기 위치로 복귀
- [ ] 모바일 (DevTools 360×640) → 토글 미표시, 폼 강제
---
## 13. 성능
| 항목 | 평가 |
|------|------|
| 번들 사이즈 | `@xyflow/react` ~50KB gzipped + 노드 카드 컴포넌트 ~5KB. 전체 web-ui 번들 영향 미미 |
| 렌더 비용 | 11개 노드, 16개 엣지 — React Flow 권장 한계 대비 매우 작음 |
| localStorage I/O | 노드 드래그 종료(`onNodeDragStop`) 시점에만 write, 드래그 중 빈번한 write 없음 |
| 모바일 폴백 | useIsMobile 분기로 캔버스 컴포넌트 자체를 mount하지 않음 → 모바일 번들 부담 없음 (lazy import 검토 가치 있음) |
`@xyflow/react` 는 데스크탑 진입 시에만 필요하므로 **`React.lazy` + `Suspense` 로 분리 import** 권장 (Plan에서 task로 명시).
---
## 14. 후속 슬라이스 후보 (이번 슬라이스 NOT)
이번 캔버스 슬라이스가 완료된 이후 자연스럽게 이어질 수 있는 작업들:
1. **노드 추가/삭제 UI** — 캔버스 우클릭 메뉴로 점수 노드 추가/제거 (백엔드 registry 동적 등록 필요)
2. **자유 그래프 모드** — 토폴로지 자체를 사용자가 구성 (엔진 재설계 동반)
3. **캔버스 안 결과 노드 펼치기** — 결과 노드 클릭 시 in-canvas 결과 표
4. **캔버스 백테스트 시각화** — 노드별 기여도 히트맵 (후속 백테스트 슬라이스와 연동)
5. **노드 그룹화** — 점수 노드 7개를 묶어 접기/펼치기
6. **키보드 단축키** — Space=실행, Cmd+S=저장, R=리셋
---
## 15. 리스크와 완화
| 리스크 | 완화 |
|--------|------|
| `@xyflow/react` API 변경 (v11 → v12 transition 중) | spec 작성 시점 안정 버전(`12.x`) 고정, package.json에 명시 |
| 캔버스 모드에서 폼 모드 settings와 동기화 깨짐 | 같은 hook 인스턴스 공유 + Screener.jsx 한 컴포넌트가 두 layout 분기 렌더 → 동일 state 자동 공유 |
| 노드 카드가 너무 커서 캔버스 빽빽 | spec 6장의 카드 폭(220~240px), 점수 노드 세로 90px 간격으로 사전 검증된 좌표 사용 |
| localStorage 무한 누적 | 키는 정해진 1개씩만 사용, 마이그레이션 시 키 명에 -v1 suffix |
| 모바일 사용자 혼란 | 토글 자체를 렌더하지 않음 → 캔버스 모드 존재 자체를 알지 못함 → 학습 부담 0 |
---
## 16. API/백엔드 영향
**없음**. 본 슬라이스는 프론트엔드 전용. 기존 API:
- `GET /api/stock/screener/nodes`
- `GET/PUT /api/stock/screener/settings`
- `POST /api/stock/screener/run`
를 그대로 사용한다. settings의 shape도 변경 없음.
---
## 17. 배포
- 프론트만 변경 → `npm run release:nas` 또는 `scripts\deploy.bat --frontend`
- 백엔드 배포 불필요
- 마이그레이션 불필요 (DB 변경 없음, localStorage는 점진적 적용)
---
## 18. 완료 조건 (Definition of Done)
- [ ] 데스크탑에서 헤더 [폼][캔버스] 토글이 보이고 정상 전환
- [ ] 캔버스 모드에 11개 노드, 16개 엣지가 사전 정의된 위치로 표시
- [ ] 점수 노드 카드에서 가중치 슬라이더/활성 체크박스/핵심 파라미터 편집 동작
- [ ] 카드 ⓘ 호버 시 설명 툴팁 표시, 한 줄 요약 항상 표시
- [ ] floating 툴바 4개 버튼 (실행/저장 실행/설정 저장/레이아웃 리셋) 모두 동작
- [ ] 노드 드래그 → localStorage 저장 → 새로고침 후 복원
- [ ] 🔄 리셋 → 초기 좌표 복귀 + localStorage 삭제
- [ ] 모바일 (≤768px)에서 토글 미렌더, 폼 강제
- [ ] 폼/캔버스 모드 전환해도 settings, 미리보기 히스토리, 결과 유지
- [ ] 12.1의 단위 테스트 모두 통과
- [ ] 12.3의 수동 검증 체크리스트 통과