lotto lab 전 차수 그래프화 추가
This commit is contained in:
@@ -21,6 +21,10 @@ export function getLatest() {
|
|||||||
return apiGet("/api/lotto/latest");
|
return apiGet("/api/lotto/latest");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStats() {
|
||||||
|
return apiGet("/api/lotto/stats");
|
||||||
|
}
|
||||||
|
|
||||||
export function recommend(params) {
|
export function recommend(params) {
|
||||||
const qs = new URLSearchParams({
|
const qs = new URLSearchParams({
|
||||||
recent_window: String(params.recent_window),
|
recent_window: String(params.recent_window),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
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', ' ') ?? '';
|
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 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) => {
|
const toBucketEntries = (metrics) => {
|
||||||
if (!metrics?.buckets) return [];
|
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() {
|
export default function Functions() {
|
||||||
const [latest, setLatest] = useState(null);
|
const [latest, setLatest] = useState(null);
|
||||||
const [params, setParams] = useState({
|
const [params, setParams] = useState({
|
||||||
@@ -122,6 +205,9 @@ export default function Functions() {
|
|||||||
|
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [history, setHistory] = useState([]);
|
const [history, setHistory] = useState([]);
|
||||||
|
const [stats, setStats] = useState(() => readStatsCache());
|
||||||
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
const [statsError, setStatsError] = useState('');
|
||||||
const [loading, setLoading] = useState({
|
const [loading, setLoading] = useState({
|
||||||
latest: false,
|
latest: false,
|
||||||
recommend: 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 () => {
|
const onRecommend = async () => {
|
||||||
setLoading((s) => ({ ...s, recommend: true }));
|
setLoading((s) => ({ ...s, recommend: true }));
|
||||||
setError('');
|
setError('');
|
||||||
@@ -195,6 +303,7 @@ export default function Functions() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshLatest();
|
refreshLatest();
|
||||||
refreshHistory();
|
refreshHistory();
|
||||||
|
refreshStats();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -432,6 +541,46 @@ export default function Functions() {
|
|||||||
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|
||||||
<section className="lotto-panel">
|
<section className="lotto-panel">
|
||||||
|
|||||||
@@ -373,6 +373,72 @@
|
|||||||
font-weight: 600;
|
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 {
|
.lotto-batch {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user