Compare commits
22 Commits
feature/st
...
fde63d757b
| Author | SHA1 | Date | |
|---|---|---|---|
| fde63d757b | |||
| 4b64761800 | |||
| 1449342f96 | |||
| 2effc47593 | |||
| f8574f1b45 | |||
| 2da7255c03 | |||
| b4ad0b1abf | |||
| 4e134eb59a | |||
| b1a1bb22f9 | |||
| f10fa062e9 | |||
| 40e3e2cf39 | |||
| 1505518ca6 | |||
| 2fd2ea33c7 | |||
| c60c32b7f2 | |||
| 5f95f55271 | |||
| d73ad9b851 | |||
| fdf5ef6ce8 | |||
| ca248891c2 | |||
| 55d2adeaf5 | |||
| 6fd70dd802 | |||
| 9f4363cdbb | |||
| 295972e0cb |
@@ -17,7 +17,7 @@
|
||||
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
||||
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||
|
||||
1826
docs/superpowers/plans/2026-05-13-screener-node-canvas.md
Normal file
1826
docs/superpowers/plans/2026-05-13-screener-node-canvas.md
Normal file
File diff suppressed because it is too large
Load Diff
505
docs/superpowers/specs/2026-05-13-screener-node-canvas-design.md
Normal file
505
docs/superpowers/specs/2026-05-13-screener-node-canvas-design.md
Normal file
@@ -0,0 +1,505 @@
|
||||
# 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의 수동 검증 체크리스트 통과
|
||||
2578
package-lock.json
generated
2578
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -10,9 +10,12 @@
|
||||
"deploy:nas": "node scripts/deploy-nas.cjs",
|
||||
"release:nas": "npm run build && npm run deploy:nas",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -24,6 +27,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
@@ -31,7 +37,9 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"rimraf": "^6.1.2",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,9 @@ if (!fs.existsSync(src)) {
|
||||
}
|
||||
|
||||
if (isWin) {
|
||||
// dstWin을 PowerShell 문자열로 안전하게 escape
|
||||
const dstPs = dstWin.replace(/\\/g, "\\\\");
|
||||
// PowerShell single-quote literal로 path 전달 — backslash over-escape 회피
|
||||
const cmd =
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"dist\\"; $dst=\\"${dstPs}\\"; if(!(Test-Path $src)){ throw \\"dist not found. Run build first.\\" }; if(!(Test-Path $dst)){ throw \\"NAS 경로를 찾을 수 없음: $dst — Z: 매핑 또는 NAS_FRONTEND_DEST_WIN env 확인\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"`;
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $src='dist'; $dst='${dstWin}'; if(!(Test-Path $src)){ throw 'dist not found. Run build first.' }; if(!(Test-Path $dst)){ throw ('NAS 경로를 찾을 수 없음: ' + $dst + ' — Z: 매핑 또는 NAS_FRONTEND_DEST_WIN env 확인') }; $log = Join-Path (Get-Location) 'robocopy.log'; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host ('robocopy failed with code ' + $rc + '. See ' + $log); exit $rc } else { exit 0 }"`;
|
||||
execSync(cmd, { stdio: "inherit" });
|
||||
} else if (isMac) {
|
||||
const sshTarget = process.env.NAS_SSH_TARGET;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import './components/canvas/Canvas.css';
|
||||
.screener-page {
|
||||
padding: 24px;
|
||||
color: var(--text, #e5e7eb);
|
||||
@@ -80,3 +81,107 @@
|
||||
.screener-table th { text-align: left; padding: 8px; background: #0a0f1a; color: #9ca3af; font-weight: 500; border-bottom: 1px solid #1f2937; }
|
||||
.screener-table td { padding: 8px; border-bottom: 1px solid #1a2230; vertical-align: middle; }
|
||||
.screener-table tr:hover { background: #0a0f1a; }
|
||||
|
||||
/* === 결과 표 헤더 === */
|
||||
.screener-result-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.screener-warn {
|
||||
background: #7c2d12;
|
||||
color: #fde68a;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* === 모바일 카드 layout === */
|
||||
.screener-mobile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.screener-mcard {
|
||||
background: #0a0f1a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.screener-mcard-head {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.screener-mcard-rank {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fbbf24;
|
||||
text-align: center;
|
||||
}
|
||||
.screener-mcard-name-main {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.screener-mcard-name-sub {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.screener-mcard-score {
|
||||
text-align: right;
|
||||
}
|
||||
.screener-mcard-score-val {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.screener-mcard-score-lbl {
|
||||
font-size: 10px;
|
||||
color: #6b7280;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.screener-mcard-delta {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
background: #0f1623;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.screener-mcard-delta span { display: flex; gap: 4px; align-items: center; }
|
||||
.screener-mcard-chips { padding: 0; }
|
||||
.screener-mcard-prices {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 12px;
|
||||
font-size: 12px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid #1f2937;
|
||||
}
|
||||
.screener-mcard-prices > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
.screener-mcard-prices .lbl {
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
}
|
||||
.screener-out-divider {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
padding: 12px 0 4px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, lazy, Suspense } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import './Screener.css';
|
||||
|
||||
@@ -6,6 +6,8 @@ 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';
|
||||
@@ -13,13 +15,22 @@ 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, runPreview, runSave } = useScreenerRun();
|
||||
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) {
|
||||
@@ -36,36 +47,83 @@ export default function Screener() {
|
||||
· 분석 기준일: {activeResult?.asof ?? settings.asof ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<nav>
|
||||
<Link to="/stock">시장</Link>
|
||||
<Link to="/stock/trade">트레이드</Link>
|
||||
</nav>
|
||||
<div className="screener-header-right">
|
||||
{!isMobile && <ModeToggle value={mode} onChange={setMode} />}
|
||||
<nav>
|
||||
<Link to="/stock">시장</Link>
|
||||
<Link to="/stock/trade">트레이드</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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} />
|
||||
<TelegramPreview payload={activeResult?.telegram_payload} />
|
||||
</main>
|
||||
|
||||
<aside className="screener-right">
|
||||
<RunHistoryList runs={runs} loading={runs_loading} onSelect={selectRun}
|
||||
selectedId={selectedRun?.meta?.id} />
|
||||
</aside>
|
||||
</div>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
26
src/pages/stock/screener/components/ModeToggle.jsx
Normal file
26
src/pages/stock/screener/components/ModeToggle.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +1,231 @@
|
||||
import ScoreChips from './ScoreChips';
|
||||
import { useIsMobile } from '../../../../hooks/useIsMobile';
|
||||
|
||||
const COL_TIPS = {
|
||||
rank: '순위 — 종합 점수가 높은 순서',
|
||||
name: '종목명과 종목 코드',
|
||||
total: '종합 점수 (0~100) — 활성 점수 노드들의 가중평균. 가중치는 좌측 패널에서 조정',
|
||||
nodes: '노드별 점수 칩 — 70점 이상이면 노란색 강조. 각 칩에 마우스 올리면 해당 노드 설명이 나옵니다',
|
||||
entry: '예상 진입가 (원) — 현재 종가의 +0.5%, 다음날 시초가 슬리피지 가정',
|
||||
stop: '손절가 (원) — 현재가 - 2 × ATR(14, Wilder smoothing). 변동성 기반 손절',
|
||||
target: '익절가 (원) — 진입가 + (진입가 - 손절가) × R:R 비율 (기본 2.0). 위험 1 대비 보상 2',
|
||||
r_pct: '손실 위험 % — (진입가 - 손절가) / 진입가 × 100. 클수록 변동성 큰 종목',
|
||||
delta_rank: '비교 대상 대비 순위 변화 — ▲(상승)·▼(하락)·NEW(이번에 새로 진입)·OUT(비교 대상에만 있음)',
|
||||
delta_score: '비교 대상 대비 점수 변화 — 양수면 상승',
|
||||
};
|
||||
|
||||
function Th({ k, children }) {
|
||||
return (
|
||||
<th title={COL_TIPS[k]} style={{ cursor: 'help' }}>
|
||||
{children}
|
||||
<span style={{ marginLeft: 4, fontSize: 10, color: '#6b7280' }}>ⓘ</span>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function buildCompareIndex(compareWith) {
|
||||
if (!compareWith?.results) return null;
|
||||
const idx = new Map();
|
||||
for (const r of compareWith.results) idx.set(r.ticker, r);
|
||||
return idx;
|
||||
}
|
||||
|
||||
function DeltaRank({ current, prev }) {
|
||||
if (!prev) {
|
||||
return <span style={{ color: '#22c55e', fontSize: 11, fontWeight: 600 }}>NEW</span>;
|
||||
}
|
||||
const diff = prev.rank - current.rank;
|
||||
if (diff === 0) return <span style={{ color: '#9ca3af', fontSize: 11 }}>─</span>;
|
||||
const up = diff > 0;
|
||||
return (
|
||||
<span style={{ color: up ? '#22c55e' : '#ef4444', fontSize: 11 }}>
|
||||
{up ? '▲' : '▼'} {Math.abs(diff)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DeltaScore({ current, prev }) {
|
||||
if (!prev) return <span style={{ color: '#9ca3af', fontSize: 11 }}>-</span>;
|
||||
const d = (current.total_score ?? 0) - (prev.total_score ?? 0);
|
||||
if (Math.abs(d) < 0.1) return <span style={{ color: '#9ca3af', fontSize: 11 }}>─</span>;
|
||||
const up = d > 0;
|
||||
return (
|
||||
<span style={{ color: up ? '#22c55e' : '#ef4444', fontSize: 11 }}>
|
||||
{up ? '+' : ''}{d.toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCard({ r, prev, hasCompare }) {
|
||||
return (
|
||||
<div className="screener-mcard">
|
||||
<div className="screener-mcard-head">
|
||||
<div className="screener-mcard-rank">#{r.rank}</div>
|
||||
<div className="screener-mcard-name">
|
||||
<div className="screener-mcard-name-main">{r.name}</div>
|
||||
<div className="screener-mcard-name-sub">{r.ticker}</div>
|
||||
</div>
|
||||
<div className="screener-mcard-score">
|
||||
<div className="screener-mcard-score-val">{r.total_score?.toFixed(1)}</div>
|
||||
<div className="screener-mcard-score-lbl">총점</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasCompare && (
|
||||
<div className="screener-mcard-delta">
|
||||
<span>순위 <DeltaRank current={r} prev={prev} /></span>
|
||||
<span>점수 <DeltaScore current={r} prev={prev} /></span>
|
||||
</div>
|
||||
)}
|
||||
<div className="screener-mcard-chips">
|
||||
<ScoreChips scores={r.scores} />
|
||||
</div>
|
||||
<div className="screener-mcard-prices">
|
||||
<div><span className="lbl">진입</span><span>{r.entry_price?.toLocaleString?.()}원</span></div>
|
||||
<div><span className="lbl">손절</span><span>{r.stop_price?.toLocaleString?.()}원</span></div>
|
||||
<div><span className="lbl">익절</span><span>{r.target_price?.toLocaleString?.()}원</span></div>
|
||||
<div><span className="lbl">위험</span><span>{r.r_pct?.toFixed?.(1)}%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileOutCard({ r }) {
|
||||
return (
|
||||
<div className="screener-mcard" style={{ opacity: 0.55 }}>
|
||||
<div className="screener-mcard-head">
|
||||
<div className="screener-mcard-rank">
|
||||
<span style={{ color: '#ef4444', fontWeight: 600 }}>OUT</span>
|
||||
</div>
|
||||
<div className="screener-mcard-name">
|
||||
<div className="screener-mcard-name-main">{r.name}</div>
|
||||
<div className="screener-mcard-name-sub">{r.ticker}</div>
|
||||
</div>
|
||||
<div className="screener-mcard-score">
|
||||
<div className="screener-mcard-score-val">{r.total_score?.toFixed(1)}</div>
|
||||
<div className="screener-mcard-score-lbl">이전</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="screener-mcard-chips">
|
||||
<ScoreChips scores={r.scores} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResultTable({ result, compareWith, compareLabel }) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
export default function ResultTable({ result }) {
|
||||
if (!result) {
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<p style={{ color: '#9ca3af' }}>아직 결과 없음. "지금 실행"을 눌러보세요.</p>
|
||||
<p style={{ color: '#6b7280', fontSize: 12, marginTop: 8 }}>
|
||||
💡 컬럼/칩에 마우스를 올리면 의미가 표시됩니다 (PC).
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const cmpIdx = buildCompareIndex(compareWith);
|
||||
const hasCompare = !!cmpIdx;
|
||||
const currentTickers = new Set((result.results || []).map((r) => r.ticker));
|
||||
const onlyInCompare = hasCompare
|
||||
? (compareWith.results || []).filter((r) => !currentTickers.has(r.ticker))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className="screener-result-head">
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Top {result.top_n} · 통과 {result.survivors_count} · {result.asof}
|
||||
{hasCompare && (
|
||||
<span style={{ marginLeft: 8, fontSize: 12, color: '#fbbf24' }}>
|
||||
vs {compareLabel ?? '비교 대상'} (통과 {compareWith.survivors_count})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{result.warnings?.length > 0 && (
|
||||
<div style={{
|
||||
background: '#7c2d12', color: '#fde68a', padding: '4px 10px',
|
||||
borderRadius: 4, fontSize: 12,
|
||||
}}>
|
||||
<div className="screener-warn">
|
||||
⚠ {result.warnings.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto', marginTop: 12 }}>
|
||||
<table className="screener-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>종목</th><th>총점</th><th>노드</th>
|
||||
<th>진입</th><th>손절</th><th>익절</th><th>R%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(result.results || []).map((r) => (
|
||||
<tr key={r.ticker}>
|
||||
<td>{r.rank}</td>
|
||||
<td>{r.name}<br /><span style={{ fontSize: 11, color: '#9ca3af' }}>{r.ticker}</span></td>
|
||||
<td style={{ fontWeight: 600 }}>{r.total_score?.toFixed(1)}</td>
|
||||
<td><ScoreChips scores={r.scores} /></td>
|
||||
<td>{r.entry_price?.toLocaleString?.()}</td>
|
||||
<td>{r.stop_price?.toLocaleString?.()}</td>
|
||||
<td>{r.target_price?.toLocaleString?.()}</td>
|
||||
<td>{r.r_pct?.toFixed?.(1)}</td>
|
||||
<p style={{ color: '#6b7280', fontSize: 11, marginTop: 8, marginBottom: 0 }}>
|
||||
{isMobile
|
||||
? `💡 종목 카드를 위아래로 스크롤하며 확인${hasCompare ? ' · 비교 모드 ON' : ''}`
|
||||
: `💡 컬럼/칩에 마우스를 올리면 의미가 표시됩니다${hasCompare ? ' · 비교 모드 ON — ▲▼NEW/OUT 변화 표시' : ''}`}
|
||||
</p>
|
||||
|
||||
{isMobile ? (
|
||||
<div className="screener-mobile-list">
|
||||
{(result.results || []).map((r) => (
|
||||
<MobileCard key={r.ticker} r={r} prev={cmpIdx?.get(r.ticker)} hasCompare={hasCompare} />
|
||||
))}
|
||||
{hasCompare && onlyInCompare.length > 0 && (
|
||||
<>
|
||||
<div className="screener-out-divider">── 이번엔 빠진 종목 ──</div>
|
||||
{onlyInCompare.map((r) => <MobileOutCard key={`out-${r.ticker}`} r={r} />)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto', marginTop: 12 }}>
|
||||
<table className="screener-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th k="rank">#</Th>
|
||||
<Th k="name">종목</Th>
|
||||
<Th k="total">총점</Th>
|
||||
{hasCompare && <Th k="delta_rank">순위Δ</Th>}
|
||||
{hasCompare && <Th k="delta_score">점수Δ</Th>}
|
||||
<Th k="nodes">노드</Th>
|
||||
<Th k="entry">진입(원)</Th>
|
||||
<Th k="stop">손절(원)</Th>
|
||||
<Th k="target">익절(원)</Th>
|
||||
<Th k="r_pct">R%</Th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(result.results || []).map((r) => {
|
||||
const prev = cmpIdx?.get(r.ticker);
|
||||
return (
|
||||
<tr key={r.ticker}>
|
||||
<td>{r.rank}</td>
|
||||
<td>{r.name}<br /><span style={{ fontSize: 11, color: '#9ca3af' }}>{r.ticker}</span></td>
|
||||
<td style={{ fontWeight: 600 }}>{r.total_score?.toFixed(1)}</td>
|
||||
{hasCompare && <td><DeltaRank current={r} prev={prev} /></td>}
|
||||
{hasCompare && <td><DeltaScore current={r} prev={prev} /></td>}
|
||||
<td><ScoreChips scores={r.scores} /></td>
|
||||
<td>{r.entry_price?.toLocaleString?.()}</td>
|
||||
<td>{r.stop_price?.toLocaleString?.()}</td>
|
||||
<td>{r.target_price?.toLocaleString?.()}</td>
|
||||
<td>{r.r_pct?.toFixed?.(1)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{hasCompare && onlyInCompare.length > 0 && (
|
||||
<>
|
||||
<tr><td colSpan={10} style={{ fontSize: 11, color: '#6b7280', padding: '12px 8px 4px' }}>
|
||||
── 이번엔 빠진 종목 (비교 대상에만 존재) ──
|
||||
</td></tr>
|
||||
{onlyInCompare.map((r) => (
|
||||
<tr key={`out-${r.ticker}`} style={{ opacity: 0.55 }}>
|
||||
<td>—</td>
|
||||
<td>{r.name}<br /><span style={{ fontSize: 11, color: '#9ca3af' }}>{r.ticker}</span></td>
|
||||
<td style={{ fontWeight: 500 }}>{r.total_score?.toFixed(1)}</td>
|
||||
<td><span style={{ color: '#ef4444', fontSize: 11, fontWeight: 600 }}>OUT</span></td>
|
||||
<td>—</td>
|
||||
<td><ScoreChips scores={r.scores} /></td>
|
||||
<td colSpan={4}>—</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,92 @@
|
||||
export default function RunHistoryList({ runs, loading, onSelect, selectedId }) {
|
||||
if (loading) return <section className="screener-card"><p>로딩…</p></section>;
|
||||
function formatTime(iso) {
|
||||
if (!iso) return '-';
|
||||
const d = new Date(iso);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function RunHistoryList({
|
||||
runs, loading, onSelect, selectedId,
|
||||
previewHistory = [], onSelectPreview, selectedPreviewId,
|
||||
onSetCompare, compareId,
|
||||
}) {
|
||||
const hasPreview = previewHistory.length > 0;
|
||||
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<h3>최근 실행</h3>
|
||||
<ul style={{listStyle:'none', padding:0, margin:0, fontSize:13}}>
|
||||
{(runs || []).map((r) => (
|
||||
<li key={r.id} style={{padding:'6px 0', borderBottom:'1px solid #1f2937', cursor:'pointer',
|
||||
color: selectedId === r.id ? '#fbbf24' : '#e5e7eb'}}
|
||||
onClick={() => onSelect(r.id)}>
|
||||
{r.asof} · {r.mode}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p style={{ fontSize: 11, color: '#6b7280', marginTop: 0 }}>
|
||||
💡 클릭하면 결과 표에 로드. 우측 "비교"를 누르면 다른 실행과 함께 표시
|
||||
</p>
|
||||
|
||||
{hasPreview && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginBottom: 4 }}>
|
||||
이번 세션 미리보기 (새로고침 시 사라짐)
|
||||
</div>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: 12 }}>
|
||||
{previewHistory.map((p) => {
|
||||
const isSelected = selectedPreviewId === p.id;
|
||||
const isCompare = compareId === p.id;
|
||||
return (
|
||||
<li key={p.id} style={{
|
||||
padding: '6px 4px',
|
||||
borderBottom: '1px solid #1f2937',
|
||||
background: isSelected ? '#1f2937' : 'transparent',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}>
|
||||
<span
|
||||
onClick={() => onSelectPreview?.(p.id)}
|
||||
style={{ cursor: 'pointer', flex: 1, color: isSelected ? '#fbbf24' : '#e5e7eb' }}
|
||||
>
|
||||
{formatTime(p.timestamp)} · {p.mode}
|
||||
<br />
|
||||
<span style={{ fontSize: 10, color: '#9ca3af' }}>
|
||||
통과 {p.survivors_count ?? '-'} · Top1 {p.top_name ?? '-'}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onSetCompare?.(isCompare ? null : p.id)}
|
||||
style={{
|
||||
padding: '2px 6px', fontSize: 10,
|
||||
background: isCompare ? '#fbbf24' : '#374151',
|
||||
color: isCompare ? '#0b0f17' : '#e5e7eb',
|
||||
border: 'none', borderRadius: 4, cursor: 'pointer',
|
||||
}}
|
||||
title="이 결과를 비교 대상으로 설정"
|
||||
>
|
||||
{isCompare ? '✓ 비교중' : '비교'}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginBottom: 4 }}>
|
||||
저장된 실행 (자동 잡 + 스냅샷 저장)
|
||||
</div>
|
||||
{loading ? <p style={{ fontSize: 12 }}>로딩…</p> : (
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: 13 }}>
|
||||
{(runs || []).length === 0 && (
|
||||
<li style={{ fontSize: 11, color: '#6b7280' }}>저장된 실행 없음</li>
|
||||
)}
|
||||
{(runs || []).map((r) => (
|
||||
<li key={r.id} style={{
|
||||
padding: '6px 0', borderBottom: '1px solid #1f2937', cursor: 'pointer',
|
||||
color: selectedId === r.id ? '#fbbf24' : '#e5e7eb',
|
||||
}}
|
||||
onClick={() => onSelect?.(r.id)}>
|
||||
{r.asof} · {r.mode}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,55 @@
|
||||
const NODE_ICONS = {
|
||||
foreign_buy: { icon: '👤', label: '외국인' },
|
||||
volume_surge: { icon: '⚡', label: '거래량' },
|
||||
momentum: { icon: '🚀', label: '모멘텀' },
|
||||
high52w: { icon: '🆙', label: '52w고' },
|
||||
rs_rating: { icon: '💪', label: 'RS' },
|
||||
ma_alignment: { icon: '📈', label: '정배열' },
|
||||
vcp_lite: { icon: '🌀', label: 'VCP' },
|
||||
const NODE_META = {
|
||||
foreign_buy: {
|
||||
label: '외국인',
|
||||
description: '외국인 누적 순매수 강도 — 최근 N일(기본 5일) 외국인 순매수 합계를 시가총액으로 나눈 비율의 백분위',
|
||||
},
|
||||
volume_surge: {
|
||||
label: '거래량 급증',
|
||||
description: '최근 3일 평균 거래량 vs 직전 20일 평균의 log(비율) 백분위 — 매집/관심 급증 신호',
|
||||
},
|
||||
momentum: {
|
||||
label: '20일 모멘텀',
|
||||
description: '20일 누적 수익률 백분위 — 단기 상승 추세 강도',
|
||||
},
|
||||
high52w: {
|
||||
label: '52주 신고가 근접도',
|
||||
description: '현재가 / 52주 최고가 (룰 기반: 70% 미만 0점, 100% 도달 100점, 선형) — 미너비니 SEPA 핵심',
|
||||
},
|
||||
rs_rating: {
|
||||
label: 'RS Rating',
|
||||
description: '시장(KOSPI) 대비 3·6·9·12개월 초과수익 가중합 (IBD 표준 2:1:1:1) 백분위 — 상대강도',
|
||||
},
|
||||
ma_alignment: {
|
||||
label: '이평선 정배열',
|
||||
description: '현재가>MA50, MA50>MA150, MA150>MA200, 현재가>MA200, 52주 저점+25% 이상 — 5조건 만족도 × 20점',
|
||||
},
|
||||
vcp_lite: {
|
||||
label: 'VCP-lite (변동성 수축)',
|
||||
description: '단기(40일) vs 장기(252일) 일중 변동성 비율 백분위 — 변동성 수축 = 돌파 직전 패턴',
|
||||
},
|
||||
};
|
||||
|
||||
export default function ScoreChips({ scores }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{Object.entries(scores || {}).map(([name, s]) => {
|
||||
const meta = NODE_ICONS[name];
|
||||
const meta = NODE_META[name];
|
||||
if (!meta) return null;
|
||||
const active = s >= 70;
|
||||
const score = Math.round(s);
|
||||
return (
|
||||
<span key={name}
|
||||
title={`${meta.label}: ${s.toFixed?.(0) ?? s}`}
|
||||
style={{
|
||||
padding: '2px 6px', borderRadius: 4, fontSize: 11,
|
||||
background: active ? '#fbbf24' : '#1f2937',
|
||||
color: active ? '#0b0f17' : '#9ca3af',
|
||||
}}>
|
||||
{meta.icon}{Math.round(s)}
|
||||
<span
|
||||
key={name}
|
||||
title={`${meta.label} ${score}점\n\n${meta.description}\n\n(70점 이상이면 강조 표시)`}
|
||||
style={{
|
||||
padding: '3px 8px', borderRadius: 4, fontSize: 11,
|
||||
background: active ? '#fbbf24' : '#1f2937',
|
||||
color: active ? '#0b0f17' : '#9ca3af',
|
||||
cursor: 'help',
|
||||
fontWeight: active ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{meta.label} {score}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
196
src/pages/stock/screener/components/canvas/Canvas.css
Normal file
196
src/pages/stock/screener/components/canvas/Canvas.css
Normal file
@@ -0,0 +1,196 @@
|
||||
/* ─────────── 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; }
|
||||
}
|
||||
56
src/pages/stock/screener/components/canvas/CanvasLayout.jsx
Normal file
56
src/pages/stock/screener/components/canvas/CanvasLayout.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
61
src/pages/stock/screener/components/canvas/CanvasToolbar.jsx
Normal file
61
src/pages/stock/screener/components/canvas/CanvasToolbar.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
196
src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx
Normal file
196
src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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' },
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
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);
|
||||
@@ -0,0 +1,72 @@
|
||||
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);
|
||||
@@ -0,0 +1,96 @@
|
||||
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);
|
||||
@@ -0,0 +1,71 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
45
src/pages/stock/screener/hooks/useCanvasLayout.js
Normal file
45
src/pages/stock/screener/hooks/useCanvasLayout.js
Normal file
@@ -0,0 +1,45 @@
|
||||
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 { /* ignore quota/security errors */ }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPositions(initialPositions);
|
||||
try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore security errors */ }
|
||||
}, [initialPositions]);
|
||||
|
||||
return { positions, updateNodePosition, reset };
|
||||
}
|
||||
49
src/pages/stock/screener/hooks/useCanvasLayout.test.js
Normal file
49
src/pages/stock/screener/hooks/useCanvasLayout.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
25
src/pages/stock/screener/hooks/useScreenerMode.js
Normal file
25
src/pages/stock/screener/hooks/useScreenerMode.js
Normal file
@@ -0,0 +1,25 @@
|
||||
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 { /* ignore quota/security errors */ }
|
||||
};
|
||||
|
||||
return { mode, setMode };
|
||||
}
|
||||
29
src/pages/stock/screener/hooks/useScreenerMode.test.js
Normal file
29
src/pages/stock/screener/hooks/useScreenerMode.test.js
Normal file
@@ -0,0 +1,29 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { runScreener } from '../../../../api';
|
||||
|
||||
const MAX_PREVIEW_HISTORY = 10;
|
||||
|
||||
export function useScreenerRun() {
|
||||
const [result, setResult] = useState(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
// 미리보기 결과를 세션 메모리에 누적 (새로고침 시 사라짐 — DB 부하 없음)
|
||||
const [previewHistory, setPreviewHistory] = useState([]);
|
||||
|
||||
async function call(mode, settings) {
|
||||
setRunning(true);
|
||||
@@ -17,15 +21,34 @@ export function useScreenerRun() {
|
||||
};
|
||||
const r = await runScreener(body);
|
||||
setResult(r);
|
||||
const stamp = new Date().toISOString();
|
||||
const item = {
|
||||
id: `${mode}-${stamp}`,
|
||||
mode,
|
||||
timestamp: stamp,
|
||||
asof: r?.asof,
|
||||
survivors_count: r?.survivors_count,
|
||||
top_ticker: r?.results?.[0]?.ticker,
|
||||
top_name: r?.results?.[0]?.name,
|
||||
top_score: r?.results?.[0]?.total_score,
|
||||
result: r,
|
||||
};
|
||||
setPreviewHistory((prev) => [item, ...prev].slice(0, MAX_PREVIEW_HISTORY));
|
||||
return r;
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectPreview(id) {
|
||||
const item = previewHistory.find((p) => p.id === id);
|
||||
if (item) setResult(item.result);
|
||||
}
|
||||
|
||||
return {
|
||||
result, running,
|
||||
result, running, previewHistory,
|
||||
runPreview: (s) => call('preview', s),
|
||||
runSave: (s) => call('manual_save', s),
|
||||
selectPreview,
|
||||
};
|
||||
}
|
||||
|
||||
35
src/test-setup.js
Normal file
35
src/test-setup.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { beforeEach } from 'vitest';
|
||||
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();
|
||||
});
|
||||
@@ -4,6 +4,12 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test-setup.js'],
|
||||
include: ['src/**/*.test.{js,jsx}'],
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 3007,
|
||||
|
||||
Reference in New Issue
Block a user