lotto 기능에 추천 결과 통계 시각화 (분포, 합계, 홀짝)를 추가

This commit is contained in:
2026-01-25 22:10:24 +09:00
parent 2495feef3e
commit dcd2910cea
2 changed files with 305 additions and 5 deletions

View File

@@ -21,6 +21,88 @@ const NumberRow = ({ nums }) => (
</div>
);
const bucketOrder = ['1-10', '11-20', '21-30', '31-40', '41-45'];
const toBucketEntries = (metrics) => {
if (!metrics?.buckets) return [];
const entries = Object.entries(metrics.buckets);
const ordered = bucketOrder
.filter((key) => Object.prototype.hasOwnProperty.call(metrics.buckets, key))
.map((key) => [key, metrics.buckets[key]]);
const rest = entries
.filter(([key]) => !bucketOrder.includes(key))
.sort((a, b) => {
const aStart = Number(a[0].split('-')[0]);
const bStart = Number(b[0].split('-')[0]);
if (Number.isNaN(aStart) || Number.isNaN(bStart)) return 0;
return aStart - bStart;
});
return [...ordered, ...rest];
};
const MetricBlock = ({ title, metrics }) => {
if (!metrics) return null;
const buckets = toBucketEntries(metrics);
const maxBucket = buckets.length
? Math.max(...buckets.map(([, value]) => Number(value) || 0), 1)
: 1;
const odd = Number(metrics.odd) || 0;
const even = Number(metrics.even) || 0;
const totalOE = odd + even || 1;
const oddPct = (odd / totalOE) * 100;
return (
<div className="lotto-metrics">
<div className="lotto-metrics__head">
<p className="lotto-metrics__title">{title}</p>
<span className="lotto-metrics__sum">합계 {metrics.sum ?? '-'}</span>
</div>
<div className="lotto-metric-cards">
<div className="lotto-metric-card">
<p className="lotto-metric-card__label">최솟값</p>
<p className="lotto-metric-card__value">{metrics.min ?? '-'}</p>
</div>
<div className="lotto-metric-card">
<p className="lotto-metric-card__label">최댓값</p>
<p className="lotto-metric-card__value">{metrics.max ?? '-'}</p>
</div>
<div className="lotto-metric-card">
<p className="lotto-metric-card__label">범위</p>
<p className="lotto-metric-card__value">{metrics.range ?? '-'}</p>
</div>
</div>
<div className="lotto-odd-even">
<div className="lotto-odd-even__labels">
<span> {odd}</span>
<span> {even}</span>
</div>
<div className="lotto-odd-even__bar" aria-hidden>
<span className="lotto-odd-even__odd" style={{ width: `${oddPct}%` }} />
<span
className="lotto-odd-even__even"
style={{ width: `${100 - oddPct}%` }}
/>
</div>
</div>
{buckets.length ? (
<div className="lotto-buckets">
{buckets.map(([label, value]) => (
<div key={label} className="lotto-bucket">
<span className="lotto-bucket__label">{label}</span>
<div className="lotto-bucket__bar" aria-hidden>
<span
style={{ width: `${((Number(value) || 0) / maxBucket) * 100}%` }}
/>
</div>
<span className="lotto-bucket__value">{value}</span>
</div>
))}
</div>
) : null}
</div>
);
};
export default function Functions() {
const [latest, setLatest] = useState(null);
const [params, setParams] = useState({
@@ -169,6 +251,9 @@ export default function Functions() {
<p className="lotto-bonus">
보너스 <strong>{latest.bonus}</strong>
</p>
{latest.metrics ? (
<MetricBlock title="당첨 통계" metrics={latest.metrics} />
) : null}
</>
) : (
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
@@ -285,11 +370,63 @@ export default function Functions() {
번호 복사
</button>
</div>
<NumberRow nums={result.numbers} />
{result.numbers ? <NumberRow nums={result.numbers} /> : null}
{result.metrics || latest?.metrics ? (
<div className="lotto-compare">
{result.metrics ? (
<MetricBlock title="추천 통계" metrics={result.metrics} />
) : null}
{latest?.metrics ? (
<MetricBlock
title="당첨 통계 (최신 회차)"
metrics={latest.metrics}
/>
) : null}
</div>
) : null}
{Array.isArray(result.items) && result.items.length ? (
<details className="lotto-details">
<summary>추천 후보 보기</summary>
<div className="lotto-batch">
{result.items.map((item, idx) => (
<div
key={item.id ?? `candidate-${idx}`}
className="lotto-batch__item"
>
<div className="lotto-batch__meta">
<div>
<p className="lotto-batch__title">
후보 #{item.id ?? idx + 1}
</p>
<p className="lotto-batch__sub">
기준 회차 {item.based_on_draw ?? '-'}
</p>
</div>
<button
className="button ghost small"
onClick={() => copyNumbers(item.numbers)}
>
복사
</button>
</div>
<NumberRow nums={item.numbers} />
{item.metrics ? (
<MetricBlock
title="후보 통계"
metrics={item.metrics}
/>
) : null}
</div>
))}
</div>
</details>
) : null}
{result.explain ? (
<details className="lotto-details">
<summary>설명 보기</summary>
<pre>{JSON.stringify(result.explain, null, 2)}</pre>
</details>
) : null}
</div>
) : (
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>

View File

@@ -244,6 +244,169 @@
border: 1px solid var(--line);
}
.lotto-compare {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.lotto-metrics {
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
background: rgba(255, 255, 255, 0.02);
display: grid;
gap: 12px;
}
.lotto-metrics__head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.lotto-metrics__title {
margin: 0;
font-weight: 600;
font-size: 14px;
}
.lotto-metrics__sum {
font-size: 12px;
color: var(--muted);
}
.lotto-metric-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.lotto-metric-card {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px;
background: rgba(0, 0, 0, 0.2);
display: grid;
gap: 6px;
}
.lotto-metric-card__label {
margin: 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
}
.lotto-metric-card__value {
margin: 0;
font-weight: 600;
font-size: 16px;
}
.lotto-odd-even {
display: grid;
gap: 8px;
}
.lotto-odd-even__labels {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--muted);
}
.lotto-odd-even__bar {
height: 10px;
border-radius: 999px;
overflow: hidden;
display: grid;
grid-template-columns: 1fr 1fr;
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.2);
}
.lotto-odd-even__odd {
background: rgba(247, 168, 165, 0.6);
}
.lotto-odd-even__even {
background: rgba(151, 201, 170, 0.6);
}
.lotto-buckets {
display: grid;
gap: 8px;
}
.lotto-bucket {
display: grid;
grid-template-columns: 54px minmax(0, 1fr) 28px;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
}
.lotto-bucket__label {
font-weight: 600;
}
.lotto-bucket__bar {
height: 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.25);
overflow: hidden;
border: 1px solid var(--line);
}
.lotto-bucket__bar span {
display: block;
height: 100%;
background: rgba(133, 165, 216, 0.7);
}
.lotto-bucket__value {
text-align: right;
font-weight: 600;
}
.lotto-batch {
display: grid;
gap: 12px;
margin-top: 12px;
}
.lotto-batch__item {
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
display: grid;
gap: 10px;
background: rgba(0, 0, 0, 0.2);
}
.lotto-batch__meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.lotto-batch__title {
margin: 0;
font-weight: 600;
}
.lotto-batch__sub {
margin: 4px 0 0;
color: var(--muted);
font-size: 12px;
}
.lotto-empty {
margin: 0;
color: var(--muted);