From 80a61e74eefe8d5ab026789e85e00315444382a4 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 25 Jan 2026 23:51:50 +0900 Subject: [PATCH] =?UTF-8?q?lotto=20lab=20=EC=A0=84=20=EC=B0=A8=EC=88=98=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api.js | 4 + src/pages/lotto/Functions.jsx | 151 +++++++++++++++++++++++++++++++++- src/pages/lotto/Lotto.css | 66 +++++++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) 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;