diff --git a/src/api.js b/src/api.js
index 0dbcf16..d0859e7 100644
--- a/src/api.js
+++ b/src/api.js
@@ -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),
diff --git a/src/pages/lotto/Functions.jsx b/src/pages/lotto/Functions.jsx
index acaa5aa..db22443 100644
--- a/src/pages/lotto/Functions.jsx
+++ b/src/pages/lotto/Functions.jsx
@@ -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 (
+
+
+
횟수
+
+ {ticks.map((value) => (
+ {value}
+ ))}
+
+
+
+ {series.map((item) => (
+
+
+ {item.number}
+
+ ))}
+
+
+ );
+};
+
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() {
아직 추천 결과가 없습니다.
)}
+
+
+
+
+
Distribution
+
전체 회차 번호 분포
+
+ 1~45번 번호가 등장한 횟수를 기준으로 분포를 표시합니다.
+
+
+
+ {statsLoading ? (
+ 로딩 중
+ ) : null}
+ {stats?.total_draws ? (
+
+ {stats.total_draws}회차
+
+ ) : null}
+
+
+
+
+ {statsError ? (
+ {statsError}
+ ) : null}
+ {stats ? (
+
+ ) : (
+
+ 통계 데이터를 불러오지 못했습니다.
+
+ )}
+
diff --git a/src/pages/lotto/Lotto.css b/src/pages/lotto/Lotto.css
index ec43130..b728aa0 100644
--- a/src/pages/lotto/Lotto.css
+++ b/src/pages/lotto/Lotto.css
@@ -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;