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

53 KiB
Raw Blame History

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 와 테스트 의존성 설치

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 객체에 다음 줄을 추가:

"test": "vitest",
"test:run": "vitest run"
  • Step 3: vite.config.js 에 test 섹션 추가

기존 defineConfig({ ... }) 블록 안 plugins 옆에 추가:

  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.js'],
    include: ['src/**/*.test.{js,jsx}'],
  },
  • Step 4: src/test-setup.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: 셋업 동작 확인
npx vitest --run --reporter=verbose

Expected: "No test files found" 메시지 (테스트 파일이 아직 없어 정상). 에러 없이 종료.

  • Step 6: Commit
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: 실패하는 정합성 테스트 작성

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: 테스트 실패 확인
npx vitest run src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js

Expected: FAIL — "Cannot find module './canvasLayout'".

  • Step 3: 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 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: 테스트 통과 확인
npx vitest run src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js

Expected: PASS — 6 tests passed.

  • Step 5: Commit
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: 실패하는 테스트 작성

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: 테스트 실패 확인
npx vitest run src/pages/stock/screener/hooks/useScreenerMode.test.js

Expected: FAIL — "Cannot find module './useScreenerMode'".

  • Step 3: useScreenerMode.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: 테스트 통과 확인
npx vitest run src/pages/stock/screener/hooks/useScreenerMode.test.js

Expected: PASS — 4 tests passed.

  • Step 5: Commit
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: 실패하는 테스트 작성

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: 테스트 실패 확인
npx vitest run src/pages/stock/screener/hooks/useCanvasLayout.test.js

Expected: FAIL — "Cannot find module './useCanvasLayout'".

  • Step 3: useCanvasLayout.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: 테스트 통과 확인
npx vitest run src/pages/stock/screener/hooks/useCanvasLayout.test.js

Expected: PASS — 5 tests passed.

  • Step 5: Commit
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 구현

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 만:

npx eslint src/pages/stock/screener/components/ModeToggle.jsx

Expected: 0 errors.

  • Step 3: Commit
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 구현

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
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 구현

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
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: 실패하는 테스트 작성

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: 테스트 실패 확인
npx vitest run src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx

Expected: FAIL — "Cannot find module './ScoreNodeCard'".

  • Step 3: ScoreNodeCard.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: 테스트 통과 확인
npx vitest run src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx

Expected: PASS — 5 tests passed.

  • Step 5: Commit
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 구현

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
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 구현

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 통과 확인
npx eslint src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx

Expected: 0 errors.

  • Step 3: Commit
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 구현

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
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: 기존 코드 전체 교체

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 통과 확인
npx eslint src/pages/stock/screener/Screener.jsx

Expected: 0 errors.

  • Step 3: Commit
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 작성

/* ─────────── 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 첫 줄에 다음 추가:

@import './components/canvas/Canvas.css';
  • Step 3: Commit
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: 전체 테스트 실행

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: 빌드 통과 확인

npm run build

Expected: dist/ 디렉토리 생성, 빌드 에러 없음. @xyflow/react 가 별도 chunk로 분리되었는지 확인 (lazy import 때문).

  • Step 3: lint 통과 확인
npm run lint

Expected: 0 errors. warning은 무시 가능.


Task 15: 수동 검증 + 배포

Files:

  • (실행만, 수동 점검)

  • Step 1: dev 서버 실행

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: 배포

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