diff --git a/src/pages/lotto/Functions.jsx b/src/pages/lotto/Functions.jsx
index 6d5bebe..9a0946a 100644
--- a/src/pages/lotto/Functions.jsx
+++ b/src/pages/lotto/Functions.jsx
@@ -1,1161 +1,38 @@
-import React, { useEffect, useMemo, useRef, useState } from 'react';
+import React, { useMemo } from 'react';
import {
- deleteHistory, getHistory, getLatest, getStats, recommend,
- getBestPicks, getAnalysis, triggerSimulate,
- getPerformanceStats, getLatestReport, getReportHistory,
- getPersonalAnalysis, getPurchases, getPurchaseStats,
- addPurchase, updatePurchase, deletePurchase,
- getCombinedRecommend, getCombinedHistory,
-} from '../../api';
+ fmtKST, Ball, NumberRow, copyNumbers,
+ buildMetricsFromFrequency, BEST_PICKS_DEFAULT_SHOW,
+} from './lottoUtils';
-/* ─────────────────────────────────────────────
- 공통 유틸
-───────────────────────────────────────────── */
-const fmtKST = (value) => value?.replace('T', ' ') ?? '';
+/* ── hooks ──────────────────────────────────────────────────────── */
+import useLottoData from './hooks/useLottoData';
+import usePurchases from './hooks/usePurchases';
+import useManualRecommend from './hooks/useManualRecommend';
-const fmtWon = (n) => {
- if (n == null || isNaN(Number(n))) return '-';
- return new Intl.NumberFormat('ko-KR').format(Math.round(Number(n))) + '원';
-};
+/* ── components ─────────────────────────────────────────────────── */
+import MetricBlock from './components/MetricBlock';
+import FrequencyChart from './components/FrequencyChart';
+import PerformanceBanner from './components/PerformanceBanner';
+import CombinedRecommendPanel from './components/CombinedRecommendPanel';
+import ReportPanel from './components/ReportPanel';
+import PersonalAnalysisPanel from './components/PersonalAnalysisPanel';
+import PurchasePanel from './components/PurchasePanel';
-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 BEST_PICKS_DEFAULT_SHOW = 5;
-
-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 {}
-};
-
-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, v) => acc + v, 0);
- if (!total) return null;
- const min = Math.min(...counts), max = Math.max(...counts);
- const odd = counts.reduce((acc, v, idx) => (idx % 2 === 0 ? acc + v : 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: max - min, 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), 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 ordered = bucketOrder
- .filter((key) => Object.prototype.hasOwnProperty.call(metrics.buckets, key))
- .map((key) => [key, metrics.buckets[key]]);
- const rest = Object.entries(metrics.buckets)
- .filter(([key]) => !bucketOrder.includes(key))
- .sort((a, b) => Number(a[0].split('-')[0]) - Number(b[0].split('-')[0]));
- return [...ordered, ...rest];
-};
-
-/* ─────────────────────────────────────────────
- SubComponents — 기존
-───────────────────────────────────────────── */
-const MetricBlock = ({ title, metrics }) => {
- if (!metrics) return null;
- const buckets = toBucketEntries(metrics);
- const maxBucket = buckets.length ? Math.max(...buckets.map(([, v]) => Number(v) || 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(() => [max, Math.round(max * 0.5), 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 : ''}
-
-
- );
- })}
-
-
- );
-};
-
-/* ─────────────────────────────────────────────
- SubComponents — 신규
-───────────────────────────────────────────── */
-
-/* 신뢰도 배너 */
-const PerformanceBanner = ({ perf }) => {
- if (!perf || perf.total_checked === 0) return null;
- const imp = perf.vs_random?.improvement_pct ?? 0;
- const prizeHits = (perf.by_rank?.rank_3 ?? 0) + (perf.by_rank?.rank_4 ?? 0) + (perf.by_rank?.rank_5 ?? 0);
- return (
-
-
신뢰도 지표
-
-
- {perf.total_checked}
- 검증 회차
-
-
-
- {(perf.avg_correct ?? 0).toFixed(1)}
- 평균 일치수
-
-
-
- 0 ? 'is-pos' : ''}`}>
- {imp > 0 ? '+' : ''}{imp.toFixed(1)}%
-
- 무작위 대비
-
-
-
-
- {((perf.rate_3plus ?? 0) * 100).toFixed(1)}%
-
- 3개↑ 일치율
-
- {prizeHits > 0 && (
- <>
-
-
- {prizeHits}건
- 3~5등 당첨
-
- >
- )}
-
-
- );
-};
-
-/* 신뢰도 링 SVG */
-const ConfidenceRing = ({ score }) => {
- const r = 28, c = 2 * Math.PI * r;
- const fill = (score / 100) * c;
- const color = score >= 80 ? '#97c9aa' : score >= 60 ? '#fdd4b1' : '#f7a8a5';
- return (
-
- );
-};
-
-/* ─────────────────────────────────────────────
- 종합 추론 추천 패널
-───────────────────────────────────────────── */
-const METHOD_META = {
- frequency: { label: '빈도 Z-score', desc: '역대 출현 빈도가 기댓값보다 높은 번호', color: '#818cf8', icon: '📊' },
- fingerprint: { label: '조합 지문', desc: '역대 당첨 조합의 합계·홀짝·구간 분포에 맞는 번호', color: '#fbbf24', icon: '🔏' },
- gap: { label: '갭 분석', desc: '가장 오래 등장하지 않은 오버듀 번호', color: '#34d399', icon: '⏳' },
- cooccur: { label: '공동 출현', desc: '역대에 함께 출현한 빈도가 높은 번호', color: '#f472b6', icon: '🔗' },
- diversity: { label: '다양성', desc: '구간 커버리지와 번호 범위를 극대화한 번호', color: '#fb923c', icon: '🌈' },
-};
-const METHOD_ORDER = ['fingerprint', 'frequency', 'gap', 'cooccur', 'diversity'];
-const SCORE_META = [
- { key: 'score_fingerprint', label: '조합 지문', color: '#fbbf24', weight: 30 },
- { key: 'score_frequency', label: '빈도 Z', color: '#818cf8', weight: 25 },
- { key: 'score_gap', label: '갭 분석', color: '#34d399', weight: 20 },
- { key: 'score_cooccur', label: '공동 출현', color: '#f472b6', weight: 15 },
- { key: 'score_diversity', label: '다양성', color: '#fb923c', weight: 10 },
-];
-
-const CombinedRecommendPanel = ({ combined, history, loading, histLoading, onRun, onCopy }) => {
- const [histExpand, setHistExpand] = useState(false);
-
- return (
-
-
-
-
AI · 종합 추론
-
종합 추론 번호 추천
-
- 5가지 통계 기법(빈도·지문·갭·공동출현·다양성)을 가중 투표로 합산해
- 최적 6개 번호를 도출합니다.
-
-
-
- {loading && 분석 중…}
-
- {history.length > 0 && (
-
- )}
-
-
-
- {!combined && !loading && (
- 버튼을 눌러 종합 추론을 실행하세요.
- )}
-
- {combined && (
- <>
- {/* 기법별 추천 번호 */}
-
- {METHOD_ORDER.map((key) => {
- const meta = METHOD_META[key];
- const m = combined.methods?.[key];
- if (!m) return null;
- return (
-
-
-
{meta.icon}
-
-
- {meta.label}
- ({m.weight_pct}%)
-
-
{meta.desc}
-
-
-
- {m.numbers.map((n) => {
- const inFinal = combined.final_numbers.includes(n);
- return (
-
- {n}
-
- );
- })}
-
-
- );
- })}
-
-
- {/* 최종 추론 결과 */}
-
-
- 종합 추론 결과
- {combined.deduped && (
- 중복 (이미 저장됨)
- )}
-
-
-
- {combined.final_numbers.map((n) => {
- const votes = combined.vote_counts?.[String(n)] ?? 0;
- return (
-
- {n}
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
-
- );
- })}
-
-
- ● 점은 해당 번호가 채택된 기법 수 (최대 5개)
-
-
-
- {/* 점수 바 */}
-
-
조합 품질 점수
- {SCORE_META.map(({ key, label, color, weight }) => {
- const val = combined.scores?.[key] ?? 0;
- const pct = Math.round(val * 100);
- return (
-
-
{label}
-
{weight}%
-
-
{pct}
-
- );
- })}
-
- 종합 점수 {Math.round((combined.scores?.score_total ?? 0) * 100)} / 100
-
-
-
-
- ※ 이 추천은 역대 통계 패턴 기반 참고 자료이며, 당첨을 보장하지 않습니다.
-
- >
- )}
-
- {/* 추천 이력 */}
- {histExpand && (
-
-
종합 추론 이력
- {histLoading &&
로딩 중…
}
- {history.map((item) => (
-
-
- #{item.id}
- {fmtKST(item.created_at)}
- 기준 {item.based_on_draw ?? '-'}회
-
-
-
-
- ))}
-
- )}
-
- );
-};
-
-
-/* 공략 리포트 패널 */
-const ReportPanel = ({ report, history, loading, onRefresh, onSelectDrw }) => {
- const [histExpand, setHistExpand] = useState(false);
-
- return (
-
-
-
-
Weekly Report
-
이번 주 공략 리포트
- {report && (
-
- {report.target_drw_no}회 대상 · {report.based_on_draw}회 기준
-
- )}
-
-
- {loading && 로딩 중}
-
- {history?.length > 0 && (
-
- )}
-
-
-
- {/* 지난 리포트 목록 */}
- {histExpand && history?.length > 0 && (
-
- {history.map((h) => (
-
- ))}
-
- )}
-
- {!report && !loading && (
- 리포트 데이터가 없습니다.
- )}
- {loading && !report && (
- 불러오는 중...
- )}
-
- {report && (
- <>
- {/* 신뢰도 + 패턴 요약 */}
-
-
-
-
-
신뢰도 점수
-
- {Object.entries(report.confidence_factors ?? {}).map(([k, v]) => (
-
-
- {k === 'data_volume' ? '데이터 충분도'
- : k === 'pattern_consistency' ? '패턴 안정성'
- : k === 'recent_trend' ? '최근 트렌드' : k}
-
-
-
-
-
{v}
-
- ))}
-
-
-
-
-
-
최근 패턴
-
-
- 합계 평균
- {report.recent_pattern?.recent_sum_avg?.toFixed(1) ?? '-'}
-
-
- 홀수 평균
- {report.recent_pattern?.recent_odd_avg?.toFixed(1) ?? '-'}
-
- {(report.recent_pattern?.triple_appear ?? []).length > 0 && (
-
- 3회 연속 출현
-
-
- )}
-
-
-
-
- {/* 핫 / 콜드 / 오버듀 */}
-
-
-
- 🔥 핫 번호 최근 10회 과출현
-
-
-
-
-
- 🧊 콜드 번호 역대 저빈도 10개
-
-
-
-
-
- ⏰ 오버듀 가장 오래 미출현
-
-
-
-
-
- {/* 전략 추천 세트 */}
- {(report.recommended_sets ?? []).length > 0 && (
-
- {report.recommended_sets.map((set, i) => (
-
-
{set.strategy}
-
-
{set.description}
-
- ))}
-
- )}
- >
- )}
-
- );
-};
-
-/* 개인 패턴 분석 */
-const PersonalAnalysisPanel = ({ data, loading }) => {
- const zones = Object.entries(data?.pattern?.zone_avg ?? {});
- const maxZone = zones.length ? Math.max(...zones.map(([, v]) => Number(v) || 0), 1) : 1;
-
- return (
-
-
-
-
My Pattern
-
내 번호 패턴
- {data && data.total_analyzed > 0 && (
-
총 {data.total_analyzed}회 추천 기반 분석
- )}
-
-
-
- {(loading || !data || data.total_analyzed === 0) ? (
-
- {loading ? '불러오는 중...' : '추천 이력이 없습니다.'}
-
- ) : (
-
-
-
-
- 내가 자주 선택한 번호 TOP 10
-
-
-
-
-
-
선택 성향
-
- {data.vs_draw_avg?.odd_tendency && (
-
- {data.vs_draw_avg.odd_tendency}
-
- )}
- {data.vs_draw_avg?.sum_tendency && (
-
- {data.vs_draw_avg.sum_tendency}
-
- )}
-
-
- 홀수 평균 {data.pattern?.avg_odd_count?.toFixed(1)}
- 합계 평균 {data.pattern?.avg_sum?.toFixed(1)}
-
- 연속번호 포함률{' '}
-
- {((data.pattern?.consecutive_rate ?? 0) * 100).toFixed(0)}%
-
-
-
-
-
- {zones.length > 0 && (
-
-
구간별 선택 비율
-
- {zones.map(([zone, avg]) => (
-
-
{zone}
-
-
-
-
{Number(avg).toFixed(1)}
-
- ))}
-
-
- )}
-
-
- )}
-
- );
-};
-
-/* 구매 기록 패널 */
-const emptyPurchaseForm = () => ({ draw_no: '', amount: 5000, sets: 5, prize: 0, note: '' });
-
-const PurchasePanel = ({
- records, stats, loading,
- formOpen, form, formSaving, formError, editId,
- onFormOpen, onFormClose, onFormChange, onFormSubmit,
- onEditStart, onDelete,
-}) => {
- const winRate = stats?.total_records > 0
- ? ((stats.prize_count / stats.total_records) * 100).toFixed(1)
- : '0.0';
- const netColor = (stats?.net ?? 0) >= 0 ? 'is-pos' : 'is-neg';
-
- return (
-
-
-
-
Purchase Tracker
-
구매 기록
-
구매 내역 기록 및 수익률 추적
-
-
- {loading && 로딩 중}
-
-
-
-
- {/* 통계 바 */}
- {stats && stats.total_records > 0 && (
-
-
- {fmtWon(stats.total_invested)}
- 총 투자
-
-
- {fmtWon(stats.total_prize)}
- 총 당첨금
-
-
-
- {(stats.net ?? 0) >= 0 ? '+' : ''}{fmtWon(stats.net)}
-
- 순손익
-
-
- {stats.return_rate?.toFixed(1)}%
- 회수율
-
-
- {winRate}%
- 당첨률
-
- {stats.max_prize > 0 && (
-
- {fmtWon(stats.max_prize)}
- 최대 당첨금
-
- )}
-
- )}
-
- {/* 입력 폼 */}
- {formOpen && (
-
- )}
-
- {/* 기록 목록 */}
- {records.length === 0 ? (
- 구매 기록이 없습니다.
- ) : (
-
-
- 회차
- 투자금
- 당첨금
- 손익
- 메모
-
-
- {records.map((rec) => {
- const net = (rec.prize ?? 0) - (rec.amount ?? 0);
- return (
-
-
{rec.draw_no}회
-
{fmtWon(rec.amount)}
-
0 ? 'is-prize' : ''}>
- {fmtWon(rec.prize)}
-
-
= 0 ? 'is-pos' : 'is-neg'}>
- {net >= 0 ? '+' : ''}{fmtWon(net)}
-
-
{rec.note || '-'}
-
-
-
-
-
- );
- })}
-
- )}
-
- );
-};
-
-/* ─────────────────────────────────────────────
- Main Functions Component
-───────────────────────────────────────────── */
+/* ── component ──────────────────────────────────────────────────── */
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, bestPicks: false, analysis: false,
- });
- const [error, setError] = useState('');
- const [bestPicks, setBestPicks] = useState([]);
- const [bestPicksExpanded, setBestPicksExpanded] = useState(false);
- const [analysis, setAnalysis] = useState(null);
- const [simulating, setSimulating] = useState(false);
- const [simResult, setSimResult] = useState(null);
+ const ld = useLottoData();
+ const pur = usePurchases();
+ const mr = useManualRecommend();
- // ── 신규 상태 ──────────────────────────────────────────────────────────────
- const [combined, setCombined] = useState(null);
- const [combinedLoading, setCombinedLoading] = useState(false);
- const [combinedHistory, setCombinedHistory] = useState([]);
- const [combinedHistLoading, setCombinedHistLoading] = useState(false);
+ /* ── derived ────────────────────────────────────────────────── */
+ const overallMetrics = useMemo(() => buildMetricsFromFrequency(ld.stats?.frequency), [ld.stats]);
+ const visibleBestPicks = ld.bestPicksExpanded ? ld.bestPicks : ld.bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW);
- const [perfStats, setPerfStats] = useState(null);
- const [report, setReport] = useState(null);
- const [reportHistory, setReportHistory] = useState([]);
- const [reportLoading, setReportLoading] = useState(false);
- const [personalAnalysis, setPersonalAnalysis] = useState(null);
- const [personalLoading, setPersonalLoading] = useState(false);
- const [purchases, setPurchases] = useState([]);
- const [purchaseStats, setPurchaseStats] = useState(null);
- const [purchaseLoading, setPurchaseLoading] = useState(false);
+ /* ── merged error ───────────────────────────────────────────── */
+ const error = ld.error || mr.error;
+ const clearError = () => { ld.setError(''); mr.setError(''); };
- // 구매 폼 상태
- const [purchaseFormOpen, setPurchaseFormOpen] = useState(false);
- const [purchaseForm, setPurchaseForm] = useState(emptyPurchaseForm);
- const [purchaseFormSaving, setPurchaseFormSaving] = useState(false);
- const [purchaseFormError, setPurchaseFormError] = useState('');
- const [purchaseEditId, setPurchaseEditId] = useState(null);
-
- // ── 파생 값 ────────────────────────────────────────────────────────────────
- const overallMetrics = useMemo(() => buildMetricsFromFrequency(stats?.frequency), [stats]);
- const historyMetrics = useMemo(() => buildMetricsFromHistory(history), [history]);
- const visibleHistory = historyExpanded ? history : history.slice(0, 5);
- const visibleBestPicks = bestPicksExpanded ? bestPicks : bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW);
-
- // ── 기존 로드 함수 ─────────────────────────────────────────────────────────
- const refreshLatest = async () => {
- setLoading((s) => ({ ...s, latest: true }));
- setError('');
- try { setLatest(await getLatest()); }
- 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();
- if (!cached || cached.total_draws !== data?.total_draws) {
- setStats(data); writeStatsCache(data);
- }
- } catch (e) { setStatsError(e?.message ?? String(e)); }
- finally { setStatsLoading(false); }
- };
-
- const refreshBestPicks = async () => {
- setLoading((s) => ({ ...s, bestPicks: true }));
- try { setBestPicks((await getBestPicks(20)).items ?? []); }
- catch {}
- finally { setLoading((s) => ({ ...s, bestPicks: false })); }
- };
-
- const refreshAnalysis = async () => {
- setLoading((s) => ({ ...s, analysis: true }));
- try { setAnalysis(await getAnalysis()); }
- catch {}
- finally { setLoading((s) => ({ ...s, analysis: false })); }
- };
-
- // ── 신규 로드 함수 ─────────────────────────────────────────────────────────
- const refreshPerfStats = async () => {
- try { setPerfStats(await getPerformanceStats()); } catch {}
- };
-
- const refreshReport = async () => {
- setReportLoading(true);
- try {
- const [rep, hist] = await Promise.all([
- getLatestReport(),
- getReportHistory(10),
- ]);
- setReport(rep);
- setReportHistory(hist?.reports ?? []);
- } catch {}
- finally { setReportLoading(false); }
- };
-
- const loadSpecificReport = async (drwNo) => {
- setReportLoading(true);
- try { setReport(await getReport(drwNo)); }
- catch {}
- finally { setReportLoading(false); }
- };
-
- const runCombinedRecommend = async () => {
- setCombinedLoading(true);
- try {
- const data = await getCombinedRecommend();
- setCombined(data);
- // 이력도 새로고침
- const hist = await getCombinedHistory(30);
- setCombinedHistory(hist?.items ?? []);
- } catch (e) { setError(e?.message ?? String(e)); }
- finally { setCombinedLoading(false); }
- };
-
- const loadCombinedHistory = async () => {
- setCombinedHistLoading(true);
- try {
- const hist = await getCombinedHistory(30);
- setCombinedHistory(hist?.items ?? []);
- } catch {}
- finally { setCombinedHistLoading(false); }
- };
-
- const refreshPersonalAnalysis = async () => {
- setPersonalLoading(true);
- try { setPersonalAnalysis(await getPersonalAnalysis()); }
- catch {}
- finally { setPersonalLoading(false); }
- };
-
- const refreshPurchases = async () => {
- setPurchaseLoading(true);
- try {
- const [recs, st] = await Promise.all([getPurchases(), getPurchaseStats()]);
- setPurchases(recs?.records ?? []);
- setPurchaseStats(st);
- } catch {}
- finally { setPurchaseLoading(false); }
- };
-
- // ── 시뮬레이션 ─────────────────────────────────────────────────────────────
- const onSimulate = async () => {
- const ok = confirm('시뮬레이션을 즉시 실행할까요?\n20,000개 후보를 분석합니다. (약 1~3분 소요)');
- if (!ok) return;
- setSimulating(true); setSimResult(null); setError('');
- try {
- const data = await triggerSimulate();
- setSimResult(data);
- await refreshBestPicks();
- } catch (e) { setError(e?.message ?? String(e)); }
- finally { setSimulating(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) => {
- if (!confirm(`히스토리 #${id}를 삭제할까요?`)) 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); }
- };
-
- // ── 구매 기록 CRUD ─────────────────────────────────────────────────────────
- const handlePurchaseFormOpen = () => {
- setPurchaseEditId(null);
- setPurchaseForm(emptyPurchaseForm());
- setPurchaseFormError('');
- setPurchaseFormOpen(true);
- };
-
- const handlePurchaseFormClose = () => {
- setPurchaseFormOpen(false);
- setPurchaseEditId(null);
- setPurchaseFormError('');
- };
-
- const handlePurchaseFormChange = (field, value) => {
- setPurchaseForm((prev) => ({ ...prev, [field]: value }));
- };
-
- const handlePurchaseEditStart = (rec) => {
- setPurchaseEditId(rec.id);
- setPurchaseForm({
- draw_no: String(rec.draw_no ?? ''),
- amount: rec.amount ?? 5000,
- sets: rec.sets ?? 5,
- prize: rec.prize ?? 0,
- note: rec.note ?? '',
- });
- setPurchaseFormError('');
- setPurchaseFormOpen(true);
- };
-
- const handlePurchaseFormSubmit = async (e) => {
- e.preventDefault();
- setPurchaseFormSaving(true); setPurchaseFormError('');
- const payload = {
- draw_no: Number(purchaseForm.draw_no),
- amount: Number(purchaseForm.amount),
- sets: Number(purchaseForm.sets),
- prize: Number(purchaseForm.prize),
- note: purchaseForm.note.trim(),
- };
- try {
- if (purchaseEditId != null) {
- const updated = await updatePurchase(purchaseEditId, payload);
- setPurchases((prev) =>
- prev.map((r) => r.id === purchaseEditId ? (updated ?? { ...payload, id: purchaseEditId }) : r)
- );
- } else {
- const saved = await addPurchase(payload);
- setPurchases((prev) => [saved ?? { ...payload, id: Date.now() }, ...prev]);
- }
- // 통계 재로드
- try { setPurchaseStats(await getPurchaseStats()); } catch {}
- handlePurchaseFormClose();
- } catch (err) {
- setPurchaseFormError(err?.message ?? String(err));
- } finally {
- setPurchaseFormSaving(false);
- }
- };
-
- const handlePurchaseDelete = async (id) => {
- if (!confirm('이 구매 기록을 삭제할까요?')) return;
- setPurchases((prev) => prev.filter((r) => r.id !== id));
- try {
- await deletePurchase(id);
- try { setPurchaseStats(await getPurchaseStats()); } catch {}
- } catch { refreshPurchases(); }
- };
-
- // ── 초기 로드 ──────────────────────────────────────────────────────────────
- useEffect(() => {
- refreshLatest();
- refreshHistory();
- refreshStats();
- refreshBestPicks();
- refreshAnalysis();
- refreshPerfStats();
- refreshReport();
- refreshPersonalAnalysis();
- refreshPurchases();
- loadCombinedHistory();
- }, []);
-
- useEffect(() => {
- if (historyExpanded && !prevHistoryExpandedRef.current) {
- requestAnimationFrame(() => {
- historyEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
- });
- }
- prevHistoryExpandedRef.current = historyExpanded;
- }, [historyExpanded, visibleHistory.length]);
-
- // ── 렌더 ───────────────────────────────────────────────────────────────────
+ /* ── render ──────────────────────────────────────────────────── */
return (
{error ? (
@@ -1164,20 +41,20 @@ export default function Functions() {
오류
{error}
-
+
) : null}
{/* ── 신뢰도 배너 ── */}
-
+
{/* ── 종합 추론 번호 추천 ── */}
@@ -1192,25 +69,25 @@ export default function Functions() {
최신 회차와 번호를 빠르게 확인할 수 있습니다.
- {loading.latest ? 로딩 중 : null}
-
- {latest ? (
+ {ld.latest ? (
<>
-
{latest.drawNo}회
-
{latest.date}
+
{ld.latest.drawNo}회
+
{ld.latest.date}
-
copyNumbers(latest.numbers)}>
+ copyNumbers(ld.latest.numbers)}>
번호 복사
-
- 보너스 {latest.bonus}
+
+ 보너스 {ld.latest.bonus}
{overallMetrics && (
)}
@@ -1231,29 +108,29 @@ export default function Functions() {
- {loading.bestPicks ? 로딩 중 : null}
- {simulating ? 분석 중 : null}
-
+ {ld.loading.bestPicks ? 로딩 중 : null}
+ {ld.simulating ? 분석 중 : null}
+
새로고침
-
- {simulating ? '실행 중...' : '지금 실행'}
+
+ {ld.simulating ? '실행 중...' : '지금 실행'}
- {simResult && (
+ {ld.simResult && (
-
완료: {simResult.total_generated?.toLocaleString()}개 후보 → 상위 {simResult.best_n_saved}개 저장
-
최고 점수 {((simResult.best_score ?? 0) * 100).toFixed(1)}% / 평균 {((simResult.avg_score ?? 0) * 100).toFixed(1)}%
+
완료: {ld.simResult.total_generated?.toLocaleString()}개 후보 → 상위 {ld.simResult.best_n_saved}개 저장
+
최고 점수 {((ld.simResult.best_score ?? 0) * 100).toFixed(1)}% / 평균 {((ld.simResult.avg_score ?? 0) * 100).toFixed(1)}%
)}
- {bestPicks.length === 0 ? (
+ {ld.bestPicks.length === 0 ? (
- {loading.bestPicks ? '불러오는 중...' : "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."}
+ {ld.loading.bestPicks ? '불러오는 중...' : "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."}
) : (
<>
@@ -1278,19 +155,19 @@ export default function Functions() {
))}
- {bestPicks.length > BEST_PICKS_DEFAULT_SHOW && (
+ {ld.bestPicks.length > BEST_PICKS_DEFAULT_SHOW && (
setBestPicksExpanded((p) => !p)}
- aria-expanded={bestPicksExpanded}
+ onClick={() => ld.setBestPicksExpanded((p) => !p)}
+ aria-expanded={ld.bestPicksExpanded}
>
- {bestPicksExpanded ? '접기' : `모두 보기 (${bestPicks.length}개)`}
- ▼
+ {ld.bestPicksExpanded ? '접기' : `모두 보기 (${ld.bestPicks.length}개)`}
+ ▼
)}
- 갱신: {fmtKST(bestPicks[0]?.created_at) || '-'}
- {bestPicks[0]?.based_on_draw ? ` · ${bestPicks[0].based_on_draw}회 기준` : ''}
+ 갱신: {fmtKST(ld.bestPicks[0]?.created_at) || '-'}
+ {ld.bestPicks[0]?.based_on_draw ? ` · ${ld.bestPicks[0].based_on_draw}회 기준` : ''}
>
)}
@@ -1299,11 +176,11 @@ export default function Functions() {
{/* ── 이번 주 공략 리포트 ── */}
{/* ── 통계 분석 ── */}
@@ -1315,32 +192,32 @@ export default function Functions() {
빈도, Z-score, 갭 분석으로 번호를 분류합니다.
- {loading.analysis ? 로딩 중 : null}
-
+ {ld.loading.analysis ? 로딩 중 : null}
+
새로고침
- {analysis ? (
+ {ld.analysis ? (
🔥 핫 번호 출현 빈도 상위 10
- {(analysis.hot_numbers ?? []).map((n) => )}
+ {(ld.analysis.hot_numbers ?? []).map((n) => )}
🧊 콜드 번호 출현 빈도 하위 10
- {(analysis.cold_numbers ?? []).map((n) => )}
+ {(ld.analysis.cold_numbers ?? []).map((n) => )}
⏰ 오버듀 번호 오래 안 나온 번호 (회차 수)
- {(analysis.overdue_numbers ?? []).map((n) => {
- const stat = (analysis.number_stats ?? []).find((s) => s.number === n);
+ {(ld.analysis.overdue_numbers ?? []).map((n) => {
+ const stat = (ld.analysis.number_stats ?? []).find((s) => s.number === n);
return (
@@ -1352,20 +229,20 @@ export default function Functions() {
- 역대 합계 평균 {analysis.mean_sum}
- 표준편차 ±{analysis.std_sum}
- 분석 회차 {analysis.total_draws?.toLocaleString()}
+ 역대 합계 평균 {ld.analysis.mean_sum}
+ 표준편차 ±{ld.analysis.std_sum}
+ 분석 회차 {ld.analysis.total_draws?.toLocaleString()}
홀수 3:짝수 3 확률{' '}
- {analysis.odd_distribution?.['3'] ? `${analysis.odd_distribution['3']}%` : '-'}
+ {ld.analysis.odd_distribution?.['3'] ? `${ld.analysis.odd_distribution['3']}%` : '-'}
) : (
- {loading.analysis ? '불러오는 중...' : '통계 분석 데이터가 없습니다.'}
+ {ld.loading.analysis ? '불러오는 중...' : '통계 분석 데이터가 없습니다.'}
)}
@@ -1379,42 +256,42 @@ export default function Functions() {
1~45번 번호가 등장한 횟수를 기준으로 분포를 표시합니다.
- {statsLoading ? 로딩 중 : null}
- {stats?.total_draws ? (
- {stats.total_draws}회차
+ {ld.statsLoading ? 로딩 중 : null}
+ {ld.stats?.total_draws ? (
+ {ld.stats.total_draws}회차
) : null}
-
+
새로고침
- {statsError ? {statsError}
: null}
- {stats ? (
-
+ {ld.statsError ? {ld.statsError}
: null}
+ {ld.stats ? (
+
) : (
통계 데이터를 불러오지 못했습니다.
)}
{/* ── 내 번호 패턴 ── */}
-
+
{/* ── 구매 기록 ── */}
{/* ── 수동 추천 ── */}
@@ -1426,14 +303,14 @@ export default function Functions() {
파라미터를 직접 조정해 번호를 추천받을 수 있습니다.
- {loading.recommend ? 계산 중 : null}
+ {mr.loading.recommend ? 계산 중 : null}
- {presets.map((preset) => (
+ {mr.presets.map((preset) => (
setParams({
+ onClick={() => mr.setParams({
recent_window: preset.recent_window,
recent_weight: preset.recent_weight,
avoid_recent_k: preset.avoid_recent_k,
@@ -1446,47 +323,47 @@ export default function Functions() {
-
+
추천 받기
- {result ? (
+ {mr.result ? (
-
추천 ID #{result.id}
-
기준 회차 {result.based_on_latest_draw ?? '-'}
+
추천 ID #{mr.result.id}
+
기준 회차 {mr.result.based_on_latest_draw ?? '-'}
-
copyNumbers(result.numbers)}>
+ copyNumbers(mr.result.numbers)}>
번호 복사
- {result.numbers &&
}
- {historyMetrics && (
+ {mr.result.numbers &&
}
+ {mr.historyMetrics && (
-
+
)}
- {Array.isArray(result.items) && result.items.length ? (
+ {Array.isArray(mr.result.items) && mr.result.items.length ? (
추천 후보 보기
- {result.items.map((item, idx) => (
+ {mr.result.items.map((item, idx) => (
@@ -1504,10 +381,10 @@ export default function Functions() {
) : null}
- {result.explain && (
+ {mr.result.explain && (
설명 보기
- {JSON.stringify(result.explain, null, 2)}
+ {JSON.stringify(mr.result.explain, null, 2)}
)}
@@ -1525,27 +402,27 @@ export default function Functions() {
수동 추천 결과를 모아서 확인할 수 있습니다.
- {history.length}건
- {history.length > 5 && (
+ {mr.history.length}건
+ {mr.history.length > 5 && (
setHistoryExpanded((p) => !p)}
- aria-expanded={historyExpanded}>
- {historyExpanded ? '접기' : '더보기'}
- ▼
+ onClick={() => mr.setHistoryExpanded((p) => !p)}
+ aria-expanded={mr.historyExpanded}>
+ {mr.historyExpanded ? '접기' : '더보기'}
+ ▼
)}
-
+
새로고침
- {loading.history ? 불러오는 중...
: null}
- {history.length === 0 ? (
+ {mr.loading.history ? 불러오는 중...
: null}
+ {mr.history.length === 0 ? (
저장된 히스토리가 없습니다.
) : (
- {visibleHistory.map((item) => (
+ {mr.visibleHistory.map((item) => (
#{item.id}
@@ -1563,13 +440,13 @@ export default function Functions() {
copyNumbers(item.numbers)}>
복사
-
onDelete(item.id)}>
+ mr.onDelete(item.id)}>
삭제
))}
-
+
)}
diff --git a/src/pages/lotto/components/CombinedRecommendPanel.jsx b/src/pages/lotto/components/CombinedRecommendPanel.jsx
new file mode 100644
index 0000000..7749a2c
--- /dev/null
+++ b/src/pages/lotto/components/CombinedRecommendPanel.jsx
@@ -0,0 +1,157 @@
+import React, { useState } from 'react';
+import { ballClass, NumberRow, METHOD_META, METHOD_ORDER, SCORE_META, fmtKST } from '../lottoUtils';
+
+const CombinedRecommendPanel = ({ combined, history, loading, histLoading, onRun, onCopy }) => {
+ const [histExpand, setHistExpand] = useState(false);
+
+ return (
+
+
+
+
AI · 종합 추론
+
종합 추론 번호 추천
+
+ 5가지 통계 기법(빈도·지문·갭·공동출현·다양성)을 가중 투표로 합산해
+ 최적 6개 번호를 도출합니다.
+
+
+
+ {loading && 분석 중…}
+
+ {loading ? '추론 중…' : '🔮 종합 추론 실행'}
+
+ {history.length > 0 && (
+ setHistExpand(p => !p)}>
+ 이력 {history.length}건 {histExpand ? '▲' : '▼'}
+
+ )}
+
+
+
+ {!combined && !loading && (
+ 버튼을 눌러 종합 추론을 실행하세요.
+ )}
+
+ {combined && (
+ <>
+ {/* 기법별 추천 번호 */}
+
+ {METHOD_ORDER.map((key) => {
+ const meta = METHOD_META[key];
+ const m = combined.methods?.[key];
+ if (!m) return null;
+ return (
+
+
+
{meta.icon}
+
+
+ {meta.label}
+ ({m.weight_pct}%)
+
+
{meta.desc}
+
+
+
+ {m.numbers.map((n) => {
+ const inFinal = combined.final_numbers.includes(n);
+ return (
+
+ {n}
+
+ );
+ })}
+
+
+ );
+ })}
+
+
+ {/* 최종 추론 결과 */}
+
+
+ 종합 추론 결과
+ {combined.deduped && (
+ 중복 (이미 저장됨)
+ )}
+ onCopy(combined.final_numbers)}>
+ 복사
+
+
+
+ {combined.final_numbers.map((n) => {
+ const votes = combined.vote_counts?.[String(n)] ?? 0;
+ return (
+
+ {n}
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+ );
+ })}
+
+
+ ● 점은 해당 번호가 채택된 기법 수 (최대 5개)
+
+
+
+ {/* 점수 바 */}
+
+
조합 품질 점수
+ {SCORE_META.map(({ key, label, color, weight }) => {
+ const val = combined.scores?.[key] ?? 0;
+ const pct = Math.round(val * 100);
+ return (
+
+
{label}
+
{weight}%
+
+
{pct}
+
+ );
+ })}
+
+ 종합 점수 {Math.round((combined.scores?.score_total ?? 0) * 100)} / 100
+
+
+
+
+ ※ 이 추천은 역대 통계 패턴 기반 참고 자료이며, 당첨을 보장하지 않습니다.
+
+ >
+ )}
+
+ {/* 추천 이력 */}
+ {histExpand && (
+
+
종합 추론 이력
+ {histLoading &&
로딩 중…
}
+ {history.map((item) => (
+
+
+ #{item.id}
+ {fmtKST(item.created_at)}
+ 기준 {item.based_on_draw ?? '-'}회
+
+
+
onCopy(item.numbers)}>복사
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default CombinedRecommendPanel;
diff --git a/src/pages/lotto/components/ConfidenceRing.jsx b/src/pages/lotto/components/ConfidenceRing.jsx
new file mode 100644
index 0000000..79e9fc4
--- /dev/null
+++ b/src/pages/lotto/components/ConfidenceRing.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+
+const ConfidenceRing = ({ score }) => {
+ const r = 28, c = 2 * Math.PI * r;
+ const fill = (score / 100) * c;
+ const color = score >= 80 ? '#97c9aa' : score >= 60 ? '#fdd4b1' : '#f7a8a5';
+ return (
+
+ );
+};
+
+export default ConfidenceRing;
diff --git a/src/pages/lotto/components/FrequencyChart.jsx b/src/pages/lotto/components/FrequencyChart.jsx
new file mode 100644
index 0000000..f356e80
--- /dev/null
+++ b/src/pages/lotto/components/FrequencyChart.jsx
@@ -0,0 +1,39 @@
+import React, { useMemo } from 'react';
+import { buildFrequencySeries } from '../lottoUtils';
+
+const FrequencyChart = ({ stats }) => {
+ const { series, max } = useMemo(() => buildFrequencySeries(stats?.frequency), [stats]);
+ const ticks = useMemo(() => [max, Math.round(max * 0.5), 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 FrequencyChart;
diff --git a/src/pages/lotto/components/MetricBlock.jsx b/src/pages/lotto/components/MetricBlock.jsx
new file mode 100644
index 0000000..6c18eaa
--- /dev/null
+++ b/src/pages/lotto/components/MetricBlock.jsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { toBucketEntries } from '../lottoUtils';
+
+const MetricBlock = ({ title, metrics }) => {
+ if (!metrics) return null;
+ const buckets = toBucketEntries(metrics);
+ const maxBucket = buckets.length ? Math.max(...buckets.map(([, v]) => Number(v) || 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}
+
+ );
+};
+
+export default MetricBlock;
diff --git a/src/pages/lotto/components/PerformanceBanner.jsx b/src/pages/lotto/components/PerformanceBanner.jsx
new file mode 100644
index 0000000..fab2096
--- /dev/null
+++ b/src/pages/lotto/components/PerformanceBanner.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+
+const PerformanceBanner = ({ perf }) => {
+ if (!perf || perf.total_checked === 0) return null;
+ const imp = perf.vs_random?.improvement_pct ?? 0;
+ const prizeHits = (perf.by_rank?.rank_3 ?? 0) + (perf.by_rank?.rank_4 ?? 0) + (perf.by_rank?.rank_5 ?? 0);
+ return (
+
+
신뢰도 지표
+
+
+ {perf.total_checked}
+ 검증 회차
+
+
+
+ {(perf.avg_correct ?? 0).toFixed(1)}
+ 평균 일치수
+
+
+
+ 0 ? 'is-pos' : ''}`}>
+ {imp > 0 ? '+' : ''}{imp.toFixed(1)}%
+
+ 무작위 대비
+
+
+
+
+ {((perf.rate_3plus ?? 0) * 100).toFixed(1)}%
+
+ 3개↑ 일치율
+
+ {prizeHits > 0 && (
+ <>
+
+
+ {prizeHits}건
+ 3~5등 당첨
+
+ >
+ )}
+
+
+ );
+};
+
+export default PerformanceBanner;
diff --git a/src/pages/lotto/components/PersonalAnalysisPanel.jsx b/src/pages/lotto/components/PersonalAnalysisPanel.jsx
new file mode 100644
index 0000000..907f9ec
--- /dev/null
+++ b/src/pages/lotto/components/PersonalAnalysisPanel.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { NumberRow } from '../lottoUtils';
+
+const PersonalAnalysisPanel = ({ data, loading }) => {
+ const zones = Object.entries(data?.pattern?.zone_avg ?? {});
+ const maxZone = zones.length ? Math.max(...zones.map(([, v]) => Number(v) || 0), 1) : 1;
+
+ return (
+
+
+
+
My Pattern
+
내 번호 패턴
+ {data && data.total_analyzed > 0 && (
+
총 {data.total_analyzed}회 추천 기반 분석
+ )}
+
+
+
+ {(loading || !data || data.total_analyzed === 0) ? (
+
+ {loading ? '불러오는 중...' : '추천 이력이 없습니다.'}
+
+ ) : (
+
+
+
+
+ 내가 자주 선택한 번호 TOP 10
+
+
+
+
+
+
선택 성향
+
+ {data.vs_draw_avg?.odd_tendency && (
+
+ {data.vs_draw_avg.odd_tendency}
+
+ )}
+ {data.vs_draw_avg?.sum_tendency && (
+
+ {data.vs_draw_avg.sum_tendency}
+
+ )}
+
+
+ 홀수 평균 {data.pattern?.avg_odd_count?.toFixed(1)}
+ 합계 평균 {data.pattern?.avg_sum?.toFixed(1)}
+
+ 연속번호 포함률{' '}
+
+ {((data.pattern?.consecutive_rate ?? 0) * 100).toFixed(0)}%
+
+
+
+
+
+ {zones.length > 0 && (
+
+
구간별 선택 비율
+
+ {zones.map(([zone, avg]) => (
+
+
{zone}
+
+
+
+
{Number(avg).toFixed(1)}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ );
+};
+
+export default PersonalAnalysisPanel;
diff --git a/src/pages/lotto/components/PurchasePanel.jsx b/src/pages/lotto/components/PurchasePanel.jsx
new file mode 100644
index 0000000..5c3ccd7
--- /dev/null
+++ b/src/pages/lotto/components/PurchasePanel.jsx
@@ -0,0 +1,173 @@
+import React from 'react';
+import { fmtWon } from '../lottoUtils';
+
+const PurchasePanel = ({
+ records, stats, loading,
+ formOpen, form, formSaving, formError, editId,
+ onFormOpen, onFormClose, onFormChange, onFormSubmit,
+ onEditStart, onDelete,
+}) => {
+ const winRate = stats?.total_records > 0
+ ? ((stats.prize_count / stats.total_records) * 100).toFixed(1)
+ : '0.0';
+ const netColor = (stats?.net ?? 0) >= 0 ? 'is-pos' : 'is-neg';
+
+ return (
+
+
+
+
Purchase Tracker
+
구매 기록
+
구매 내역 기록 및 수익률 추적
+
+
+ {loading && 로딩 중}
+
+ + 추가
+
+
+
+
+ {/* 통계 바 */}
+ {stats && stats.total_records > 0 && (
+
+
+ {fmtWon(stats.total_invested)}
+ 총 투자
+
+
+ {fmtWon(stats.total_prize)}
+ 총 당첨금
+
+
+
+ {(stats.net ?? 0) >= 0 ? '+' : ''}{fmtWon(stats.net)}
+
+ 순손익
+
+
+ {stats.return_rate?.toFixed(1)}%
+ 회수율
+
+
+ {winRate}%
+ 당첨률
+
+ {stats.max_prize > 0 && (
+
+ {fmtWon(stats.max_prize)}
+ 최대 당첨금
+
+ )}
+
+ )}
+
+ {/* 입력 폼 */}
+ {formOpen && (
+
+ )}
+
+ {/* 기록 목록 */}
+ {records.length === 0 ? (
+ 구매 기록이 없습니다.
+ ) : (
+
+
+ 회차
+ 투자금
+ 당첨금
+ 손익
+ 메모
+
+
+ {records.map((rec) => {
+ const net = (rec.prize ?? 0) - (rec.amount ?? 0);
+ return (
+
+
{rec.draw_no}회
+
{fmtWon(rec.amount)}
+
0 ? 'is-prize' : ''}>
+ {fmtWon(rec.prize)}
+
+
= 0 ? 'is-pos' : 'is-neg'}>
+ {net >= 0 ? '+' : ''}{fmtWon(net)}
+
+
{rec.note || '-'}
+
+ onEditStart(rec)}>
+ 수정
+
+ onDelete(rec.id)}>
+ 삭제
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
+export default PurchasePanel;
diff --git a/src/pages/lotto/components/ReportPanel.jsx b/src/pages/lotto/components/ReportPanel.jsx
new file mode 100644
index 0000000..0caa810
--- /dev/null
+++ b/src/pages/lotto/components/ReportPanel.jsx
@@ -0,0 +1,142 @@
+import React, { useState } from 'react';
+import { NumberRow } from '../lottoUtils';
+import ConfidenceRing from './ConfidenceRing';
+
+const ReportPanel = ({ report, history, loading, onRefresh, onSelectDrw }) => {
+ const [histExpand, setHistExpand] = useState(false);
+
+ return (
+
+
+
+
Weekly Report
+
이번 주 공략 리포트
+ {report && (
+
+ {report.target_drw_no}회 대상 · {report.based_on_draw}회 기준
+
+ )}
+
+
+ {loading && 로딩 중}
+
+ 새로고침
+
+ {history?.length > 0 && (
+ setHistExpand((p) => !p)}>
+ 지난 리포트 {histExpand ? '▲' : '▼'}
+
+ )}
+
+
+
+ {/* 지난 리포트 목록 */}
+ {histExpand && history?.length > 0 && (
+
+ {history.map((h) => (
+ { onSelectDrw(h.drw_no); setHistExpand(false); }}
+ >
+ {h.drw_no}회
+
+ ))}
+
+ )}
+
+ {!report && !loading && (
+ 리포트 데이터가 없습니다.
+ )}
+ {loading && !report && (
+ 불러오는 중...
+ )}
+
+ {report && (
+ <>
+ {/* 신뢰도 + 패턴 요약 */}
+
+
+
+
+
신뢰도 점수
+
+ {Object.entries(report.confidence_factors ?? {}).map(([k, v]) => (
+
+
+ {k === 'data_volume' ? '데이터 충분도'
+ : k === 'pattern_consistency' ? '패턴 안정성'
+ : k === 'recent_trend' ? '최근 트렌드' : k}
+
+
+
+
+
{v}
+
+ ))}
+
+
+
+
+
+
최근 패턴
+
+
+ 합계 평균
+ {report.recent_pattern?.recent_sum_avg?.toFixed(1) ?? '-'}
+
+
+ 홀수 평균
+ {report.recent_pattern?.recent_odd_avg?.toFixed(1) ?? '-'}
+
+ {(report.recent_pattern?.triple_appear ?? []).length > 0 && (
+
+ 3회 연속 출현
+
+
+ )}
+
+
+
+
+ {/* 핫 / 콜드 / 오버듀 */}
+
+
+
+ 🔥 핫 번호 최근 10회 과출현
+
+
+
+
+
+ 🧊 콜드 번호 역대 저빈도 10개
+
+
+
+
+
+ ⏰ 오버듀 가장 오래 미출현
+
+
+
+
+
+ {/* 전략 추천 세트 */}
+ {(report.recommended_sets ?? []).length > 0 && (
+
+ {report.recommended_sets.map((set, i) => (
+
+
{set.strategy}
+
+
{set.description}
+
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+};
+
+export default ReportPanel;
diff --git a/src/pages/lotto/hooks/useLottoData.js b/src/pages/lotto/hooks/useLottoData.js
new file mode 100644
index 0000000..7026803
--- /dev/null
+++ b/src/pages/lotto/hooks/useLottoData.js
@@ -0,0 +1,162 @@
+import { useCallback, useEffect, useState } from 'react';
+import {
+ getLatest, getStats, getBestPicks, getAnalysis,
+ getPerformanceStats, getLatestReport, getReportHistory, getReport,
+ getPersonalAnalysis, getCombinedRecommend, getCombinedHistory,
+} from '../../../api';
+import { readStatsCache, writeStatsCache } from '../lottoUtils';
+
+export default function useLottoData() {
+ const [latest, setLatest] = useState(null);
+ const [stats, setStats] = useState(() => readStatsCache());
+ const [statsLoading, setStatsLoading] = useState(false);
+ const [statsError, setStatsError] = useState('');
+ const [loading, setLoading] = useState({
+ latest: false, bestPicks: false, analysis: false,
+ });
+ const [error, setError] = useState('');
+ const [bestPicks, setBestPicks] = useState([]);
+ const [bestPicksExpanded, setBestPicksExpanded] = useState(false);
+ const [analysis, setAnalysis] = useState(null);
+ const [simulating, setSimulating] = useState(false);
+ const [simResult, setSimResult] = useState(null);
+
+ // 종합 추론
+ const [combined, setCombined] = useState(null);
+ const [combinedLoading, setCombinedLoading] = useState(false);
+ const [combinedHistory, setCombinedHistory] = useState([]);
+ const [combinedHistLoading, setCombinedHistLoading] = useState(false);
+
+ // 신뢰도·리포트·개인분석
+ const [perfStats, setPerfStats] = useState(null);
+ const [report, setReport] = useState(null);
+ const [reportHistory, setReportHistory] = useState([]);
+ const [reportLoading, setReportLoading] = useState(false);
+ const [personalAnalysis, setPersonalAnalysis] = useState(null);
+ const [personalLoading, setPersonalLoading] = useState(false);
+
+ const refreshLatest = useCallback(async () => {
+ setLoading((s) => ({ ...s, latest: true }));
+ setError('');
+ try { setLatest(await getLatest()); }
+ catch (e) { setError(e?.message ?? String(e)); }
+ finally { setLoading((s) => ({ ...s, latest: false })); }
+ }, []);
+
+ const refreshStats = useCallback(async () => {
+ setStatsLoading(true); setStatsError('');
+ try {
+ const cached = readStatsCache();
+ if (cached && !stats) setStats(cached);
+ const data = await getStats();
+ if (!cached || cached.total_draws !== data?.total_draws) {
+ setStats(data); writeStatsCache(data);
+ }
+ } catch (e) { setStatsError(e?.message ?? String(e)); }
+ finally { setStatsLoading(false); }
+ }, [stats]);
+
+ const refreshBestPicks = useCallback(async () => {
+ setLoading((s) => ({ ...s, bestPicks: true }));
+ try { setBestPicks((await getBestPicks(20)).items ?? []); }
+ catch {}
+ finally { setLoading((s) => ({ ...s, bestPicks: false })); }
+ }, []);
+
+ const refreshAnalysis = useCallback(async () => {
+ setLoading((s) => ({ ...s, analysis: true }));
+ try { setAnalysis(await getAnalysis()); }
+ catch {}
+ finally { setLoading((s) => ({ ...s, analysis: false })); }
+ }, []);
+
+ const refreshPerfStats = useCallback(async () => {
+ try { setPerfStats(await getPerformanceStats()); } catch {}
+ }, []);
+
+ const refreshReport = useCallback(async () => {
+ setReportLoading(true);
+ try {
+ const [rep, hist] = await Promise.all([
+ getLatestReport(),
+ getReportHistory(10),
+ ]);
+ setReport(rep);
+ setReportHistory(hist?.reports ?? []);
+ } catch {}
+ finally { setReportLoading(false); }
+ }, []);
+
+ const loadSpecificReport = useCallback(async (drwNo) => {
+ setReportLoading(true);
+ try { setReport(await getReport(drwNo)); }
+ catch {}
+ finally { setReportLoading(false); }
+ }, []);
+
+ const runCombinedRecommend = useCallback(async () => {
+ setCombinedLoading(true);
+ try {
+ const data = await getCombinedRecommend();
+ setCombined(data);
+ const hist = await getCombinedHistory(30);
+ setCombinedHistory(hist?.items ?? []);
+ } catch (e) { setError(e?.message ?? String(e)); }
+ finally { setCombinedLoading(false); }
+ }, []);
+
+ const loadCombinedHistory = useCallback(async () => {
+ setCombinedHistLoading(true);
+ try {
+ const hist = await getCombinedHistory(30);
+ setCombinedHistory(hist?.items ?? []);
+ } catch {}
+ finally { setCombinedHistLoading(false); }
+ }, []);
+
+ const refreshPersonalAnalysis = useCallback(async () => {
+ setPersonalLoading(true);
+ try { setPersonalAnalysis(await getPersonalAnalysis()); }
+ catch {}
+ finally { setPersonalLoading(false); }
+ }, []);
+
+ const onSimulate = useCallback(async () => {
+ const ok = confirm('시뮬레이션을 즉시 실행할까요?\n20,000개 후보를 분석합니다. (약 1~3분 소요)');
+ if (!ok) return;
+ setSimulating(true); setSimResult(null); setError('');
+ try {
+ const { triggerSimulate } = await import('../../../api');
+ const data = await triggerSimulate();
+ setSimResult(data);
+ await refreshBestPicks();
+ } catch (e) { setError(e?.message ?? String(e)); }
+ finally { setSimulating(false); }
+ }, [refreshBestPicks]);
+
+ // 초기 로드
+ useEffect(() => {
+ refreshLatest();
+ refreshStats();
+ refreshBestPicks();
+ refreshAnalysis();
+ refreshPerfStats();
+ refreshReport();
+ refreshPersonalAnalysis();
+ loadCombinedHistory();
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return {
+ latest, loading, error, setError,
+ stats, statsLoading, statsError, refreshStats,
+ refreshLatest,
+ bestPicks, bestPicksExpanded, setBestPicksExpanded, refreshBestPicks,
+ analysis, refreshAnalysis,
+ simulating, simResult, onSimulate,
+ combined, combinedLoading, combinedHistory, combinedHistLoading,
+ runCombinedRecommend,
+ perfStats,
+ report, reportHistory, reportLoading, refreshReport, loadSpecificReport,
+ personalAnalysis, personalLoading,
+ };
+}
diff --git a/src/pages/lotto/hooks/useManualRecommend.js b/src/pages/lotto/hooks/useManualRecommend.js
new file mode 100644
index 0000000..ef0986c
--- /dev/null
+++ b/src/pages/lotto/hooks/useManualRecommend.js
@@ -0,0 +1,75 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { deleteHistory, getHistory, recommend } from '../../../api';
+import { buildMetricsFromHistory } from '../lottoUtils';
+
+export default function useManualRecommend() {
+ 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 [loading, setLoading] = useState({ recommend: false, history: false });
+ const [error, setError] = useState('');
+
+ const historyMetrics = useMemo(() => buildMetricsFromHistory(history), [history]);
+ const visibleHistory = historyExpanded ? history : history.slice(0, 5);
+
+ const refreshHistory = useCallback(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 onRecommend = useCallback(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 })); }
+ }, [params, refreshHistory]);
+
+ const onDelete = useCallback(async (id) => {
+ if (!confirm(`히스토리 #${id}를 삭제할까요?`)) return;
+ setError('');
+ try { await deleteHistory(id); setHistory((prev) => prev.filter((item) => item.id !== id)); }
+ catch (e) { setError(e?.message ?? String(e)); }
+ }, []);
+
+ useEffect(() => {
+ if (historyExpanded && !prevHistoryExpandedRef.current) {
+ requestAnimationFrame(() => {
+ historyEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
+ });
+ }
+ prevHistoryExpandedRef.current = historyExpanded;
+ }, [historyExpanded, visibleHistory.length]);
+
+ useEffect(() => { refreshHistory(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return {
+ params, setParams, presets,
+ result, history, historyExpanded, setHistoryExpanded,
+ historyEndRef, loading, error, setError,
+ historyMetrics, visibleHistory,
+ refreshHistory, onRecommend, onDelete,
+ };
+}
diff --git a/src/pages/lotto/hooks/usePurchases.js b/src/pages/lotto/hooks/usePurchases.js
new file mode 100644
index 0000000..730be5e
--- /dev/null
+++ b/src/pages/lotto/hooks/usePurchases.js
@@ -0,0 +1,105 @@
+import { useCallback, useEffect, useState } from 'react';
+import {
+ getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase,
+} from '../../../api';
+import { emptyPurchaseForm } from '../lottoUtils';
+
+export default function usePurchases() {
+ const [purchases, setPurchases] = useState([]);
+ const [purchaseStats, setPurchaseStats] = useState(null);
+ const [purchaseLoading, setPurchaseLoading] = useState(false);
+
+ // 폼 상태
+ const [purchaseFormOpen, setPurchaseFormOpen] = useState(false);
+ const [purchaseForm, setPurchaseForm] = useState(emptyPurchaseForm);
+ const [purchaseFormSaving, setPurchaseFormSaving] = useState(false);
+ const [purchaseFormError, setPurchaseFormError] = useState('');
+ const [purchaseEditId, setPurchaseEditId] = useState(null);
+
+ const refreshPurchases = useCallback(async () => {
+ setPurchaseLoading(true);
+ try {
+ const [recs, st] = await Promise.all([getPurchases(), getPurchaseStats()]);
+ setPurchases(recs?.records ?? []);
+ setPurchaseStats(st);
+ } catch {}
+ finally { setPurchaseLoading(false); }
+ }, []);
+
+ const handlePurchaseFormOpen = useCallback(() => {
+ setPurchaseEditId(null);
+ setPurchaseForm(emptyPurchaseForm());
+ setPurchaseFormError('');
+ setPurchaseFormOpen(true);
+ }, []);
+
+ const handlePurchaseFormClose = useCallback(() => {
+ setPurchaseFormOpen(false);
+ setPurchaseEditId(null);
+ setPurchaseFormError('');
+ }, []);
+
+ const handlePurchaseFormChange = useCallback((field, value) => {
+ setPurchaseForm((prev) => ({ ...prev, [field]: value }));
+ }, []);
+
+ const handlePurchaseEditStart = useCallback((rec) => {
+ setPurchaseEditId(rec.id);
+ setPurchaseForm({
+ draw_no: String(rec.draw_no ?? ''),
+ amount: rec.amount ?? 5000,
+ sets: rec.sets ?? 5,
+ prize: rec.prize ?? 0,
+ note: rec.note ?? '',
+ });
+ setPurchaseFormError('');
+ setPurchaseFormOpen(true);
+ }, []);
+
+ const handlePurchaseFormSubmit = useCallback(async (e) => {
+ e.preventDefault();
+ setPurchaseFormSaving(true); setPurchaseFormError('');
+ const payload = {
+ draw_no: Number(purchaseForm.draw_no),
+ amount: Number(purchaseForm.amount),
+ sets: Number(purchaseForm.sets),
+ prize: Number(purchaseForm.prize),
+ note: purchaseForm.note.trim(),
+ };
+ try {
+ if (purchaseEditId != null) {
+ const updated = await updatePurchase(purchaseEditId, payload);
+ setPurchases((prev) =>
+ prev.map((r) => r.id === purchaseEditId ? (updated ?? { ...payload, id: purchaseEditId }) : r)
+ );
+ } else {
+ const saved = await addPurchase(payload);
+ setPurchases((prev) => [saved ?? { ...payload, id: Date.now() }, ...prev]);
+ }
+ try { setPurchaseStats(await getPurchaseStats()); } catch {}
+ handlePurchaseFormClose();
+ } catch (err) {
+ setPurchaseFormError(err?.message ?? String(err));
+ } finally {
+ setPurchaseFormSaving(false);
+ }
+ }, [purchaseForm, purchaseEditId, handlePurchaseFormClose]);
+
+ const handlePurchaseDelete = useCallback(async (id) => {
+ if (!confirm('이 구매 기록을 삭제할까요?')) return;
+ setPurchases((prev) => prev.filter((r) => r.id !== id));
+ try {
+ await deletePurchase(id);
+ try { setPurchaseStats(await getPurchaseStats()); } catch {}
+ } catch { refreshPurchases(); }
+ }, [refreshPurchases]);
+
+ useEffect(() => { refreshPurchases(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return {
+ purchases, purchaseStats, purchaseLoading,
+ purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId,
+ handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange,
+ handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete,
+ };
+}
diff --git a/src/pages/lotto/lottoUtils.jsx b/src/pages/lotto/lottoUtils.jsx
new file mode 100644
index 0000000..14e9830
--- /dev/null
+++ b/src/pages/lotto/lottoUtils.jsx
@@ -0,0 +1,141 @@
+/* ─────────────────────────────────────────────
+ 로또 공통 유틸리티
+───────────────────────────────────────────── */
+import React from 'react';
+
+export const fmtKST = (value) => value?.replace('T', ' ') ?? '';
+
+export const fmtWon = (n) => {
+ if (n == null || isNaN(Number(n))) return '-';
+ return new Intl.NumberFormat('ko-KR').format(Math.round(Number(n))) + '원';
+};
+
+export 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';
+};
+
+export const Ball = ({ n }) => {n};
+
+export const NumberRow = ({ nums }) => (
+
+ {nums.map((n) => )}
+
+);
+
+/* ─────────────────────────────────────────────
+ 통계 헬퍼
+───────────────────────────────────────────── */
+export const bucketOrder = ['1-10', '11-20', '21-30', '31-40', '41-45'];
+export const STATS_CACHE_KEY = 'lotto_stats_v1';
+export const BEST_PICKS_DEFAULT_SHOW = 5;
+
+export 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; }
+};
+
+export const writeStatsCache = (data) => {
+ if (typeof window === 'undefined') return;
+ try { localStorage.setItem(STATS_CACHE_KEY, JSON.stringify(data)); } catch {}
+};
+
+export 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 };
+};
+
+export const buildMetricsFromCounts = (counts) => {
+ if (!counts?.length) return null;
+ const total = counts.reduce((acc, v) => acc + v, 0);
+ if (!total) return null;
+ const min = Math.min(...counts), max = Math.max(...counts);
+ const odd = counts.reduce((acc, v, idx) => (idx % 2 === 0 ? acc + v : 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: max - min, odd, even, buckets };
+};
+
+export const buildMetricsFromFrequency = (frequency) => {
+ if (!frequency?.length) return null;
+ const counts = Array.from({ length: 45 }, () => 0);
+ frequency.forEach((item) => {
+ const number = Number(item?.number), count = Number(item?.count) || 0;
+ if (number >= 1 && number <= 45) counts[number - 1] = count;
+ });
+ return buildMetricsFromCounts(counts);
+};
+
+export 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);
+};
+
+export const toBucketEntries = (metrics) => {
+ if (!metrics?.buckets) return [];
+ const ordered = bucketOrder
+ .filter((key) => Object.prototype.hasOwnProperty.call(metrics.buckets, key))
+ .map((key) => [key, metrics.buckets[key]]);
+ const rest = Object.entries(metrics.buckets)
+ .filter(([key]) => !bucketOrder.includes(key))
+ .sort((a, b) => Number(a[0].split('-')[0]) - Number(b[0].split('-')[0]));
+ return [...ordered, ...rest];
+};
+
+export const emptyPurchaseForm = () => ({ draw_no: '', amount: 5000, sets: 5, prize: 0, note: '' });
+
+export const copyNumbers = async (nums) => {
+ const text = nums.join(', ');
+ try { await navigator.clipboard.writeText(text); alert(`복사 완료: ${text}`); }
+ catch { prompt('복사해서 사용하세요:', text); }
+};
+
+/* 종합 추론 상수 */
+export const METHOD_META = {
+ frequency: { label: '빈도 Z-score', desc: '역대 출현 빈도가 기댓값보다 높은 번호', color: '#818cf8', icon: '📊' },
+ fingerprint: { label: '조합 지문', desc: '역대 당첨 조합의 합계·홀짝·구간 분포에 맞는 번호', color: '#fbbf24', icon: '🔏' },
+ gap: { label: '갭 분석', desc: '가장 오래 등장하지 않은 오버듀 번호', color: '#34d399', icon: '⏳' },
+ cooccur: { label: '공동 출현', desc: '역대에 함께 출현한 빈도가 높은 번호', color: '#f472b6', icon: '🔗' },
+ diversity: { label: '다양성', desc: '구간 커버리지와 번호 범위를 극대화한 번호', color: '#fb923c', icon: '🌈' },
+};
+
+export const METHOD_ORDER = ['fingerprint', 'frequency', 'gap', 'cooccur', 'diversity'];
+
+export const SCORE_META = [
+ { key: 'score_fingerprint', label: '조합 지문', color: '#fbbf24', weight: 30 },
+ { key: 'score_frequency', label: '빈도 Z', color: '#818cf8', weight: 25 },
+ { key: 'score_gap', label: '갭 분석', color: '#34d399', weight: 20 },
+ { key: 'score_cooccur', label: '공동 출현', color: '#f472b6', weight: 15 },
+ { key: 'score_diversity', label: '다양성', color: '#fb923c', weight: 10 },
+];