데스크탑은 기존 테이블 유지, <768px에서는 종목별 카드로 전환: - 카드 헤더: #순위 | 종목명+코드 | 총점 - 비교 모드 ON 시: 순위Δ/점수Δ 두 줄 - 노드 칩 (가로 wrap) - 진입/손절/익절/위험 2×2 그리드 (라벨 + 원 단위) - 빠진 종목(OUT)도 카드로 회색 표시 CSS: .screener-mobile-list / .screener-mcard / .screener-result-head / .screener-warn 추가. useIsMobile 훅으로 분기.
232 lines
9.5 KiB
JavaScript
232 lines
9.5 KiB
JavaScript
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>
|
||
);
|
||
}
|