import React, { useEffect, useMemo, useRef, useState } from 'react'; import { deleteHistory, getHistory, getLatest, getStats, recommend } from '../../api'; const fmtKST = (value) => value?.replace('T', ' ') ?? ''; const ballClass = (n) => { if (n <= 10) return 'lotto-ball range-a'; if (n <= 20) return 'lotto-ball range-b'; if (n <= 30) return 'lotto-ball range-c'; if (n <= 40) return 'lotto-ball range-d'; return 'lotto-ball range-e'; }; const Ball = ({ n }) => {n}; const NumberRow = ({ nums }) => (
{nums.map((n) => ( ))}
); 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 buildMetricsFromCounts = (counts) => { if (!counts?.length) return null; const total = counts.reduce((acc, value) => acc + value, 0); if (!total) return null; const min = Math.min(...counts); const max = Math.max(...counts); const range = max - min; const odd = counts.reduce( (acc, value, idx) => (idx % 2 === 0 ? acc + value : acc), 0 ); const even = total - odd; const buckets = { '1-10': counts.slice(0, 10).reduce((a, b) => a + b, 0), '11-20': counts.slice(10, 20).reduce((a, b) => a + b, 0), '21-30': counts.slice(20, 30).reduce((a, b) => a + b, 0), '31-40': counts.slice(30, 40).reduce((a, b) => a + b, 0), '41-45': counts.slice(40, 45).reduce((a, b) => a + b, 0), }; return { sum: total, min, max, range, odd, even, buckets }; }; const buildMetricsFromFrequency = (frequency) => { if (!frequency?.length) return null; const counts = Array.from({ length: 45 }, () => 0); frequency.forEach((item) => { const number = Number(item?.number); const count = Number(item?.count) || 0; if (number >= 1 && number <= 45) { counts[number - 1] = count; } }); return buildMetricsFromCounts(counts); }; const buildMetricsFromHistory = (items) => { if (!items?.length) return null; const counts = Array.from({ length: 45 }, () => 0); items.forEach((item) => { (item?.numbers ?? []).forEach((value) => { const number = Number(value); if (number >= 1 && number <= 45) { counts[number - 1] += 1; } }); }); return buildMetricsFromCounts(counts); }; 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 (

{title}

총 출현 횟수 {metrics.sum ?? '-'}

최소 출현

{metrics.min ?? '-'}

최대 출현

{metrics.max ?? '-'}

출현 편차

{metrics.range ?? '-'}

홀 {odd} 짝 {even}
{buckets.length ? (
{buckets.map(([label, value]) => (
{label}
{value}
))}
) : null}
); }; 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) => { const showLabel = item.number === 1 || item.number % 5 === 0; return (
{showLabel ? item.number : ''}
); })}
); }; export default function Functions() { const [latest, setLatest] = useState(null); const [params, setParams] = useState({ recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5, }); const presets = useMemo( () => [ { name: '기본', recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5 }, { name: '최근 가중치↑', recent_window: 100, recent_weight: 3.0, avoid_recent_k: 10 }, { name: '안전(분산)', recent_window: 300, recent_weight: 1.6, avoid_recent_k: 8 }, { name: '공격(최근)', recent_window: 80, recent_weight: 3.5, avoid_recent_k: 12 }, ], [] ); const [result, setResult] = useState(null); const [history, setHistory] = useState([]); const [historyExpanded, setHistoryExpanded] = useState(false); const historyEndRef = useRef(null); const prevHistoryExpandedRef = useRef(false); const [stats, setStats] = useState(() => readStatsCache()); const [statsLoading, setStatsLoading] = useState(false); const [statsError, setStatsError] = useState(''); const [loading, setLoading] = useState({ latest: false, recommend: false, history: false, }); const [error, setError] = useState(''); const overallMetrics = useMemo( () => buildMetricsFromFrequency(stats?.frequency), [stats] ); const historyMetrics = useMemo( () => buildMetricsFromHistory(history), [history] ); const refreshLatest = async () => { setLoading((s) => ({ ...s, latest: true })); setError(''); try { const data = await getLatest(); setLatest(data); } catch (e) { setError(e?.message ?? String(e)); } finally { setLoading((s) => ({ ...s, latest: false })); } }; const refreshHistory = async () => { setLoading((s) => ({ ...s, history: true })); setError(''); try { const limit = 100; let offset = 0; const allItems = []; while (true) { const data = await getHistory(limit, offset); const items = data.items ?? []; allItems.push(...items); if (items.length < limit) break; offset += limit; } setHistory(allItems); } catch (e) { setError(e?.message ?? String(e)); } finally { setLoading((s) => ({ ...s, history: false })); } }; 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(''); try { const data = await recommend(params); setResult(data); await refreshHistory(); } catch (e) { setError(e?.message ?? String(e)); } finally { setLoading((s) => ({ ...s, recommend: false })); } }; const onDelete = async (id) => { const ok = confirm(`히스토리 #${id}를 삭제할까요?`); if (!ok) return; setError(''); try { await deleteHistory(id); setHistory((prev) => prev.filter((item) => item.id !== id)); } catch (e) { setError(e?.message ?? String(e)); } }; const copyNumbers = async (nums) => { const text = nums.join(', '); try { await navigator.clipboard.writeText(text); alert(`복사 완료: ${text}`); } catch { prompt('복사해서 사용하세요:', text); } }; useEffect(() => { refreshLatest(); refreshHistory(); refreshStats(); }, []); const visibleHistory = historyExpanded ? history : history.slice(0, 5); useEffect(() => { if (historyExpanded && !prevHistoryExpandedRef.current) { requestAnimationFrame(() => { historyEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end', }); }); } prevHistoryExpandedRef.current = historyExpanded; }, [historyExpanded, visibleHistory.length]); return (
{error ? (

오류

{error}

) : null}

Latest Draw

최신 회차

최신 회차와 번호를 빠르게 확인할 수 있습니다.

{loading.latest ? 로딩 중 : null}
{latest ? ( <>

{latest.drawNo}회

{latest.date}

보너스 {latest.bonus}

{overallMetrics ? ( ) : null} ) : (

최신 회차 데이터가 없습니다.

)}

Recommendation

추천 생성

파라미터를 조정해 다른 추천 전략을 만들 수 있습니다.

{loading.recommend ? 계산 중 : null}
{presets.map((preset) => ( ))}
{result ? (

추천 ID #{result.id}

기준 회차 {result.based_on_latest_draw ?? '-'}

{result.numbers ? : null} {historyMetrics ? (
) : null} {Array.isArray(result.items) && result.items.length ? (
추천 후보 보기
{result.items.map((item, idx) => (

후보 #{item.id ?? idx + 1}

기준 회차 {item.based_on_draw ?? '-'}

{item.metrics ? ( ) : null}
))}
) : null} {result.explain ? (
설명 보기
{JSON.stringify(result.explain, null, 2)}
) : null}
) : (

아직 추천 결과가 없습니다.

)}

Distribution

전체 회차 번호 분포

1~45번 번호가 등장한 횟수를 기준으로 분포를 표시합니다.

{statsLoading ? ( 로딩 중 ) : null} {stats?.total_draws ? ( {stats.total_draws}회차 ) : null}
{statsError ?

{statsError}

: null} {stats ? ( ) : (

통계 데이터를 불러오지 못했습니다.

)}

History

추천 히스토리

최근 추천 결과를 모아서 확인할 수 있습니다.

{history.length}건 {history.length > 5 ? ( ) : null}
{loading.history ?

불러오는 중...

: null} {history.length === 0 ? (

저장된 히스토리가 없습니다.

) : (
{visibleHistory.map((item) => (

#{item.id}

{fmtKST(item.created_at)}

기준 회차 {item.based_on_draw ?? '-'}

window={item.params?.recent_window}, weight= {item.params?.recent_weight}, avoid_k= {item.params?.avoid_recent_k}

))}
)}
); }