feat(stock): 스크리너 모바일 카드 레이아웃 + 비교 적용

데스크탑은 기존 테이블 유지, <768px에서는 종목별 카드로 전환:
- 카드 헤더: #순위 | 종목명+코드 | 총점
- 비교 모드 ON 시: 순위Δ/점수Δ 두 줄
- 노드 칩 (가로 wrap)
- 진입/손절/익절/위험 2×2 그리드 (라벨 + 원 단위)
- 빠진 종목(OUT)도 카드로 회색 표시

CSS: .screener-mobile-list / .screener-mcard / .screener-result-head /
.screener-warn 추가. useIsMobile 훅으로 분기.
This commit is contained in:
2026-05-13 12:26:16 +09:00
parent 55d2adeaf5
commit ca248891c2
2 changed files with 237 additions and 66 deletions

View File

@@ -1,4 +1,5 @@
import ScoreChips from './ScoreChips';
import { useIsMobile } from '../../../../hooks/useIsMobile';
const COL_TIPS = {
rank: '순위 — 종합 점수가 높은 순서',
@@ -25,9 +26,7 @@ function Th({ k, children }) {
function buildCompareIndex(compareWith) {
if (!compareWith?.results) return null;
const idx = new Map();
for (const r of compareWith.results) {
idx.set(r.ticker, r);
}
for (const r of compareWith.results) idx.set(r.ticker, r);
return idx;
}
@@ -35,7 +34,7 @@ function DeltaRank({ current, prev }) {
if (!prev) {
return <span style={{ color: '#22c55e', fontSize: 11, fontWeight: 600 }}>NEW</span>;
}
const diff = prev.rank - current.rank; // 양수: 순위 상승
const diff = prev.rank - current.rank;
if (diff === 0) return <span style={{ color: '#9ca3af', fontSize: 11 }}></span>;
const up = diff > 0;
return (
@@ -57,13 +56,71 @@ function DeltaScore({ current, prev }) {
);
}
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>
);
@@ -71,8 +128,6 @@ export default function ResultTable({ result, compareWith, compareLabel }) {
const cmpIdx = buildCompareIndex(compareWith);
const hasCompare = !!cmpIdx;
// 비교 모드에서, 비교 대상에만 있는 ticker도 추가 (OUT 표시)
const currentTickers = new Set((result.results || []).map((r) => r.ticker));
const onlyInCompare = hasCompare
? (compareWith.results || []).filter((r) => !currentTickers.has(r.ticker))
@@ -80,7 +135,7 @@ export default function ResultTable({ result, compareWith, compareLabel }) {
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 && (
@@ -90,75 +145,87 @@ export default function ResultTable({ result, compareWith, compareLabel }) {
)}
</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>
<p style={{ color: '#6b7280', fontSize: 11, marginTop: 8, marginBottom: 0 }}>
💡 컬럼 헤더와 노드 칩에 마우스를 올리면 의미가 표시됩니다.
{hasCompare && ' · 비교 모드 ON — ▲▼NEW/OUT으로 변화 표시'}
{isMobile
? `💡 종목 카드를 위아래로 스크롤하며 확인${hasCompare ? ' · 비교 모드 ON' : ''}`
: `💡 컬럼/칩에 마우스를 올리면 의미가 표시됩니다${hasCompare ? ' · 비교 모드 ON — ▲▼NEW/OUT 변화 표시' : ''}`}
</p>
<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>
{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: 500 }}>{r.total_score?.toFixed(1)}</td>
<td><span style={{ color: '#ef4444', fontSize: 11, fontWeight: 600 }}>OUT</span></td>
<td></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 colSpan={4}></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>
))}
</>
)}
</tbody>
</table>
</div>
);
})}
{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>
);
}