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

24 KiB
Raw Permalink Blame History

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 변경점

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

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

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 상수

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의 수동 검증 체크리스트 통과