lotto 기능에 추천 결과 통계 시각화 (분포, 합계, 홀짝)를 추가
This commit is contained in:
@@ -21,6 +21,88 @@ const NumberRow = ({ nums }) => (
|
|||||||
</div>
|
</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() {
|
export default function Functions() {
|
||||||
const [latest, setLatest] = useState(null);
|
const [latest, setLatest] = useState(null);
|
||||||
const [params, setParams] = useState({
|
const [params, setParams] = useState({
|
||||||
@@ -169,6 +251,9 @@ export default function Functions() {
|
|||||||
<p className="lotto-bonus">
|
<p className="lotto-bonus">
|
||||||
보너스 <strong>{latest.bonus}</strong>
|
보너스 <strong>{latest.bonus}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
{latest.metrics ? (
|
||||||
|
<MetricBlock title="당첨 통계" metrics={latest.metrics} />
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
|
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
|
||||||
@@ -285,11 +370,63 @@ export default function Functions() {
|
|||||||
번호 복사
|
번호 복사
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<NumberRow nums={result.numbers} />
|
{result.numbers ? <NumberRow nums={result.numbers} /> : null}
|
||||||
<details className="lotto-details">
|
{result.metrics || latest?.metrics ? (
|
||||||
<summary>설명 보기</summary>
|
<div className="lotto-compare">
|
||||||
<pre>{JSON.stringify(result.explain, null, 2)}</pre>
|
{result.metrics ? (
|
||||||
</details>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
||||||
|
|||||||
@@ -244,6 +244,169 @@
|
|||||||
border: 1px solid var(--line);
|
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 {
|
.lotto-empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user