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

@@ -80,3 +80,107 @@
.screener-table th { text-align: left; padding: 8px; background: #0a0f1a; color: #9ca3af; font-weight: 500; border-bottom: 1px solid #1f2937; } .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 td { padding: 8px; border-bottom: 1px solid #1a2230; vertical-align: middle; }
.screener-table tr:hover { background: #0a0f1a; } .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;
}

View File

@@ -1,4 +1,5 @@
import ScoreChips from './ScoreChips'; import ScoreChips from './ScoreChips';
import { useIsMobile } from '../../../../hooks/useIsMobile';
const COL_TIPS = { const COL_TIPS = {
rank: '순위 — 종합 점수가 높은 순서', rank: '순위 — 종합 점수가 높은 순서',
@@ -25,9 +26,7 @@ function Th({ k, children }) {
function buildCompareIndex(compareWith) { function buildCompareIndex(compareWith) {
if (!compareWith?.results) return null; if (!compareWith?.results) return null;
const idx = new Map(); const idx = new Map();
for (const r of compareWith.results) { for (const r of compareWith.results) idx.set(r.ticker, r);
idx.set(r.ticker, r);
}
return idx; return idx;
} }
@@ -35,7 +34,7 @@ function DeltaRank({ current, prev }) {
if (!prev) { if (!prev) {
return <span style={{ color: '#22c55e', fontSize: 11, fontWeight: 600 }}>NEW</span>; 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>; if (diff === 0) return <span style={{ color: '#9ca3af', fontSize: 11 }}></span>;
const up = diff > 0; const up = diff > 0;
return ( 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 }) { export default function ResultTable({ result, compareWith, compareLabel }) {
const isMobile = useIsMobile();
if (!result) { if (!result) {
return ( return (
<section className="screener-card"> <section className="screener-card">
<p style={{ color: '#9ca3af' }}>아직 결과 없음. "지금 실행" 눌러보세요.</p> <p style={{ color: '#9ca3af' }}>아직 결과 없음. "지금 실행" 눌러보세요.</p>
<p style={{ color: '#6b7280', fontSize: 12, marginTop: 8 }}> <p style={{ color: '#6b7280', fontSize: 12, marginTop: 8 }}>
💡 점수·가격 컬럼 헤더와 노드 칩에 마우스를 올리면 의미가 표시됩니다. 💡 컬럼/칩에 마우스를 올리면 의미가 표시됩니다 (PC).
</p> </p>
</section> </section>
); );
@@ -71,8 +128,6 @@ export default function ResultTable({ result, compareWith, compareLabel }) {
const cmpIdx = buildCompareIndex(compareWith); const cmpIdx = buildCompareIndex(compareWith);
const hasCompare = !!cmpIdx; const hasCompare = !!cmpIdx;
// 비교 모드에서, 비교 대상에만 있는 ticker도 추가 (OUT 표시)
const currentTickers = new Set((result.results || []).map((r) => r.ticker)); const currentTickers = new Set((result.results || []).map((r) => r.ticker));
const onlyInCompare = hasCompare const onlyInCompare = hasCompare
? (compareWith.results || []).filter((r) => !currentTickers.has(r.ticker)) ? (compareWith.results || []).filter((r) => !currentTickers.has(r.ticker))
@@ -80,7 +135,7 @@ export default function ResultTable({ result, compareWith, compareLabel }) {
return ( return (
<section className="screener-card"> <section className="screener-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div className="screener-result-head">
<h3 style={{ margin: 0 }}> <h3 style={{ margin: 0 }}>
Top {result.top_n} · 통과 {result.survivors_count} · {result.asof} Top {result.top_n} · 통과 {result.survivors_count} · {result.asof}
{hasCompare && ( {hasCompare && (
@@ -90,20 +145,31 @@ export default function ResultTable({ result, compareWith, compareLabel }) {
)} )}
</h3> </h3>
{result.warnings?.length > 0 && ( {result.warnings?.length > 0 && (
<div style={{ <div className="screener-warn">
background: '#7c2d12', color: '#fde68a', padding: '4px 10px',
borderRadius: 4, fontSize: 12,
}}>
{result.warnings.join(' · ')} {result.warnings.join(' · ')}
</div> </div>
)} )}
</div> </div>
<p style={{ color: '#6b7280', fontSize: 11, marginTop: 8, marginBottom: 0 }}> <p style={{ color: '#6b7280', fontSize: 11, marginTop: 8, marginBottom: 0 }}>
💡 컬럼 헤더와 노드 칩에 마우스를 올리면 의미가 표시됩니다. {isMobile
{hasCompare && ' · 비교 모드 ON — ▲▼NEW/OUT으로 변화 표시'} ? `💡 종목 카드를 위아래로 스크롤하며 확인${hasCompare ? ' · 비교 모드 ON' : ''}`
: `💡 컬럼/칩에 마우스를 올리면 의미가 표시됩니다${hasCompare ? ' · 비교 모드 ON — ▲▼NEW/OUT 변화 표시' : ''}`}
</p> </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 }}> <div style={{ overflowX: 'auto', marginTop: 12 }}>
<table className="screener-table"> <table className="screener-table">
<thead> <thead>
@@ -159,6 +225,7 @@ export default function ResultTable({ result, compareWith, compareLabel }) {
</tbody> </tbody>
</table> </table>
</div> </div>
)}
</section> </section>
); );
} }