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 ( - - - - - {score} - - - ); -}; - -/* ───────────────────────────────────────────── - 종합 추론 추천 패널 -───────────────────────────────────────────── */ -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 && ( -
-

- {editId != null ? '기록 수정' : '구매 기록 추가'} -

-
- - - - - -
- {formError && ( -

{formError}

- )} -
- - -
-
- )} - - {/* 기록 목록 */} - {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}

-
- -

보너스 {latest.bonus}

+ +

보너스 {ld.latest.bonus}

{overallMetrics && ( )} @@ -1231,29 +108,29 @@ export default function Functions() {

- {loading.bestPicks ? 로딩 중 : null} - {simulating ? 분석 중 : null} - -
- {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 && ( )}

- 갱신: {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} -
- {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) => ( - {result ? ( + {mr.result ? (
-

추천 ID #{result.id}

-

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

+

추천 ID #{mr.result.id}

+

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

-
- {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 && ( )} -
- {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() { -
))} - +
)} 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 && 분석 중…} + + {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 ?? '-'}회 +
+ + +
+ ))} +
+ )} +
+ ); +}; + +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 ( + + + + + {score} + + + ); +}; + +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 && ( +
+

+ {editId != null ? '기록 수정' : '구매 기록 추가'} +

+
+ + + + + +
+ {formError && ( +

{formError}

+ )} +
+ + +
+
+ )} + + {/* 기록 목록 */} + {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 || '-'} +
+ + +
+
+ ); + })} +
+ )} +
+ ); +}; + +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 && ( + + )} +
+
+ + {/* 지난 리포트 목록 */} + {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}

+
+ ))} +
+ )} + + )} +
+ ); +}; + +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 }, +];