lotto lab 전 차수 그래프화 추가
This commit is contained in:
@@ -21,6 +21,10 @@ export function getLatest() {
|
||||
return apiGet("/api/lotto/latest");
|
||||
}
|
||||
|
||||
export function getStats() {
|
||||
return apiGet("/api/lotto/stats");
|
||||
}
|
||||
|
||||
export function recommend(params) {
|
||||
const qs = new URLSearchParams({
|
||||
recent_window: String(params.recent_window),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { deleteHistory, getHistory, getLatest, recommend } from '../../api';
|
||||
import { deleteHistory, getHistory, getLatest, getStats, recommend } from '../../api';
|
||||
|
||||
const fmtKST = (value) => value?.replace('T', ' ') ?? '';
|
||||
|
||||
@@ -22,6 +22,46 @@ const NumberRow = ({ nums }) => (
|
||||
);
|
||||
|
||||
const bucketOrder = ['1-10', '11-20', '21-30', '31-40', '41-45'];
|
||||
const STATS_CACHE_KEY = 'lotto_stats_v1';
|
||||
|
||||
const readStatsCache = () => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(STATS_CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || !Array.isArray(parsed.frequency)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeStatsCache = (data) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STATS_CACHE_KEY, JSON.stringify(data));
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
};
|
||||
|
||||
const buildFrequencySeries = (frequency) => {
|
||||
const map = new Map();
|
||||
(frequency ?? []).forEach((item) => {
|
||||
const number = Number(item?.number);
|
||||
const count = Number(item?.count) || 0;
|
||||
if (Number.isFinite(number) && number >= 1 && number <= 45) {
|
||||
map.set(number, count);
|
||||
}
|
||||
});
|
||||
const series = Array.from({ length: 45 }, (_, idx) => ({
|
||||
number: idx + 1,
|
||||
count: map.get(idx + 1) ?? 0,
|
||||
}));
|
||||
const max = Math.max(1, ...series.map((item) => item.count));
|
||||
return { series, max };
|
||||
};
|
||||
|
||||
const toBucketEntries = (metrics) => {
|
||||
if (!metrics?.buckets) return [];
|
||||
@@ -103,6 +143,49 @@ const MetricBlock = ({ title, metrics }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const FrequencyChart = ({ stats }) => {
|
||||
const { series, max } = useMemo(
|
||||
() => buildFrequencySeries(stats?.frequency),
|
||||
[stats]
|
||||
);
|
||||
const ticks = useMemo(() => {
|
||||
const mid = Math.round(max * 0.5);
|
||||
return [max, mid, 0];
|
||||
}, [max]);
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
return (
|
||||
<div className="lotto-chart">
|
||||
<div className="lotto-chart__y">
|
||||
<span>횟수</span>
|
||||
<div className="lotto-chart__ticks">
|
||||
{ticks.map((value) => (
|
||||
<span key={value}>{value}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lotto-chart__plot" role="list">
|
||||
{series.map((item) => (
|
||||
<div
|
||||
key={item.number}
|
||||
className="lotto-chart__col"
|
||||
role="listitem"
|
||||
>
|
||||
<span
|
||||
className="lotto-chart__bar"
|
||||
style={{ height: `${(item.count / max) * 100}%` }}
|
||||
title={`${item.number}번: ${item.count}회`}
|
||||
aria-label={`${item.number}번 ${item.count}회`}
|
||||
/>
|
||||
<span className="lotto-chart__x">{item.number}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Functions() {
|
||||
const [latest, setLatest] = useState(null);
|
||||
const [params, setParams] = useState({
|
||||
@@ -122,6 +205,9 @@ export default function Functions() {
|
||||
|
||||
const [result, setResult] = useState(null);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [stats, setStats] = useState(() => readStatsCache());
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [statsError, setStatsError] = useState('');
|
||||
const [loading, setLoading] = useState({
|
||||
latest: false,
|
||||
recommend: false,
|
||||
@@ -155,6 +241,28 @@ export default function Functions() {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshStats = async () => {
|
||||
setStatsLoading(true);
|
||||
setStatsError('');
|
||||
try {
|
||||
const cached = readStatsCache();
|
||||
if (cached && !stats) {
|
||||
setStats(cached);
|
||||
}
|
||||
const data = await getStats();
|
||||
const shouldUpdate =
|
||||
!cached || cached.total_draws !== data?.total_draws;
|
||||
if (shouldUpdate) {
|
||||
setStats(data);
|
||||
writeStatsCache(data);
|
||||
}
|
||||
} catch (e) {
|
||||
setStatsError(e?.message ?? String(e));
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onRecommend = async () => {
|
||||
setLoading((s) => ({ ...s, recommend: true }));
|
||||
setError('');
|
||||
@@ -195,6 +303,7 @@ export default function Functions() {
|
||||
useEffect(() => {
|
||||
refreshLatest();
|
||||
refreshHistory();
|
||||
refreshStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -432,6 +541,46 @@ export default function Functions() {
|
||||
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Distribution</p>
|
||||
<h3>전체 회차 번호 분포</h3>
|
||||
<p className="lotto-panel__sub">
|
||||
1~45번 번호가 등장한 횟수를 기준으로 분포를 표시합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{statsLoading ? (
|
||||
<span className="lotto-chip">로딩 중</span>
|
||||
) : null}
|
||||
{stats?.total_draws ? (
|
||||
<span className="lotto-chip">
|
||||
{stats.total_draws}회차
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={refreshStats}
|
||||
disabled={statsLoading}
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statsError ? (
|
||||
<p className="lotto-empty">{statsError}</p>
|
||||
) : null}
|
||||
{stats ? (
|
||||
<FrequencyChart stats={stats} />
|
||||
) : (
|
||||
<p className="lotto-empty">
|
||||
통계 데이터를 불러오지 못했습니다.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="lotto-panel">
|
||||
|
||||
@@ -373,6 +373,72 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lotto-chart {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.lotto-chart__y {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.lotto-chart__ticks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.lotto-chart__plot {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(45, minmax(0, 1fr));
|
||||
align-items: end;
|
||||
gap: 2px;
|
||||
height: 180px;
|
||||
padding: 0 4px 18px 6px;
|
||||
border-left: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.lotto-chart__col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.lotto-chart__bar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 6px 6px 2px 2px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(133, 165, 216, 0.8),
|
||||
rgba(133, 165, 216, 0.2)
|
||||
);
|
||||
min-height: 2px;
|
||||
transition: transform 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
|
||||
.lotto-chart__bar:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.lotto-chart__x {
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lotto-batch {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
||||
Reference in New Issue
Block a user