Files
web-page/src/pages/stock/screener/components/ResultTable.jsx
gahusb ca248891c2 feat(stock): 스크리너 모바일 카드 레이아웃 + 비교 적용
데스크탑은 기존 테이블 유지, <768px에서는 종목별 카드로 전환:
- 카드 헤더: #순위 | 종목명+코드 | 총점
- 비교 모드 ON 시: 순위Δ/점수Δ 두 줄
- 노드 칩 (가로 wrap)
- 진입/손절/익절/위험 2×2 그리드 (라벨 + 원 단위)
- 빠진 종목(OUT)도 카드로 회색 표시

CSS: .screener-mobile-list / .screener-mcard / .screener-result-head /
.screener-warn 추가. useIsMobile 훅으로 분기.
2026-05-13 12:26:16 +09:00

232 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
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 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 className="screener-warn">
{result.warnings.join(' · ')}
</div>
)}
</div>
<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>
</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>
);
}