Files
web-page/src/pages/lotto/tabs/AnalysisTab.jsx

429 lines
23 KiB
JavaScript

import React, { useMemo } from 'react';
import {
fmtKST, Ball, NumberRow, copyNumbers,
buildMetricsFromFrequency, BEST_PICKS_DEFAULT_SHOW,
} from '../lottoUtils';
import useLottoData from '../hooks/useLottoData';
import useManualRecommend from '../hooks/useManualRecommend';
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';
export default function AnalysisTab() {
const ld = useLottoData();
const mr = useManualRecommend();
const overallMetrics = useMemo(() => buildMetricsFromFrequency(ld.stats?.frequency), [ld.stats]);
const visibleBestPicks = ld.bestPicksExpanded ? ld.bestPicks : ld.bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW);
const error = ld.error || mr.error;
const clearError = () => { ld.setError(''); mr.setError(''); };
return (
<>
{error ? (
<div className="lotto-alert">
<div>
<p className="lotto-alert__title">오류</p>
<p className="lotto-alert__message">{error}</p>
</div>
<button className="button ghost small" onClick={clearError}>닫기</button>
</div>
) : null}
{/* 신뢰도 배너 */}
<PerformanceBanner perf={ld.perfStats} />
{/* 종합 추론 번호 추천 */}
<CombinedRecommendPanel
combined={ld.combined}
history={ld.combinedHistory}
loading={ld.combinedLoading}
histLoading={ld.combinedHistLoading}
onRun={ld.runCombinedRecommend}
onCopy={copyNumbers}
/>
{/* 최신 회차 + 시뮬레이션 추천 */}
<div className="lotto-grid">
{/* Latest Draw */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Latest Draw</p>
<h3>최신 회차</h3>
<p className="lotto-panel__sub">최신 회차와 번호를 빠르게 확인할 있습니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.latest ? <span className="lotto-chip">로딩 </span> : null}
<button className="button ghost small" onClick={ld.refreshLatest} disabled={ld.loading.latest}>
새로고침
</button>
</div>
</div>
{ld.latest ? (
<>
<div className="lotto-meta">
<div>
<p className="lotto-meta__title">{ld.latest.drawNo}</p>
<p className="lotto-meta__date">{ld.latest.date}</p>
</div>
<button className="button small" onClick={() => copyNumbers(ld.latest.numbers)}>
번호 복사
</button>
</div>
<NumberRow nums={ld.latest.numbers} />
<p className="lotto-bonus">보너스 <strong>{ld.latest.bonus}</strong></p>
{overallMetrics && (
<MetricBlock title="당첨 통계 (전체 회차)" metrics={overallMetrics} />
)}
</>
) : (
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
)}
</section>
{/* Simulation Picks */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Simulation Picks</p>
<h3>시뮬레이션 추천</h3>
<p className="lotto-panel__sub">
하루 6 몬테카를로 시뮬레이션으로 선별된 최적 번호입니다.
</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.bestPicks ? <span className="lotto-chip">로딩 </span> : null}
{ld.simulating ? <span className="lotto-chip lotto-chip--active">분석 </span> : null}
<button className="button ghost small" onClick={ld.refreshBestPicks}
disabled={ld.loading.bestPicks || ld.simulating}>
새로고침
</button>
<button className="button small" onClick={ld.onSimulate}
disabled={ld.simulating || ld.loading.bestPicks}>
{ld.simulating ? '실행 중...' : '지금 실행'}
</button>
</div>
</div>
{ld.simResult && (
<div className="lotto-sim-result">
<p>완료: {ld.simResult.total_generated?.toLocaleString()} 후보 상위 {ld.simResult.best_n_saved} 저장</p>
<p>최고 점수 {((ld.simResult.best_score ?? 0) * 100).toFixed(1)}% / {((ld.simResult.avg_score ?? 0) * 100).toFixed(1)}%</p>
</div>
)}
{ld.bestPicks.length === 0 ? (
<p className="lotto-empty">
{ld.loading.bestPicks ? '불러오는 중...' : "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."}
</p>
) : (
<>
<div className="lotto-picks">
{visibleBestPicks.map((pick) => (
<div key={pick.id} className="lotto-pick">
<span className="lotto-pick__rank">#{pick.rank}</span>
<div className="lotto-pick__content">
<NumberRow nums={pick.numbers} />
<div className="lotto-pick__score">
<span className="lotto-pick__score-label">
{((pick.score_total ?? 0) * 100).toFixed(1)}%
</span>
<div className="lotto-pick__bar">
<span style={{ width: `${(pick.score_total ?? 0) * 100}%` }} />
</div>
</div>
</div>
<button className="button ghost small" onClick={() => copyNumbers(pick.numbers)}>
복사
</button>
</div>
))}
</div>
{ld.bestPicks.length > BEST_PICKS_DEFAULT_SHOW && (
<button
className="button ghost small lotto-history-toggle"
onClick={() => ld.setBestPicksExpanded((p) => !p)}
aria-expanded={ld.bestPicksExpanded}
>
{ld.bestPicksExpanded ? '접기' : `모두 보기 (${ld.bestPicks.length}개)`}
<span className={`lotto-history-toggle__icon ${ld.bestPicksExpanded ? 'is-open' : ''}`} aria-hidden></span>
</button>
)}
<p className="lotto-panel__sub">
갱신: {fmtKST(ld.bestPicks[0]?.created_at) || '-'}
{ld.bestPicks[0]?.based_on_draw ? ` · ${ld.bestPicks[0].based_on_draw}회 기준` : ''}
</p>
</>
)}
</section>
</div>
{/* 이번 주 공략 리포트 */}
<ReportPanel
report={ld.report}
history={ld.reportHistory}
loading={ld.reportLoading}
onRefresh={ld.refreshReport}
onSelectDrw={ld.loadSpecificReport}
/>
{/* 통계 분석 */}
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Analysis</p>
<h3>통계 분석</h3>
<p className="lotto-panel__sub">빈도, Z-score, 분석으로 번호를 분류합니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.analysis ? <span className="lotto-chip">로딩 </span> : null}
<button className="button ghost small" onClick={ld.refreshAnalysis} disabled={ld.loading.analysis}>
새로고침
</button>
</div>
</div>
{ld.analysis ? (
<div className="lotto-analysis">
<div className="lotto-analysis__row">
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">🔥 번호 <span>출현 빈도 상위 10</span></p>
<div className="lotto-row">
{(ld.analysis.hot_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
</div>
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">🧊 콜드 번호 <span>출현 빈도 하위 10</span></p>
<div className="lotto-row">
{(ld.analysis.cold_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
</div>
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label"> 오버듀 번호 <span>오래 나온 번호 (회차 )</span></p>
<div className="lotto-row">
{(ld.analysis.overdue_numbers ?? []).map((n) => {
const stat = (ld.analysis.number_stats ?? []).find((s) => s.number === n);
return (
<div key={n} className="lotto-overdue">
<Ball n={n} />
<span className="lotto-overdue__gap">{stat?.gap ?? '-'}</span>
</div>
);
})}
</div>
</div>
</div>
<div className="lotto-analysis__stats">
<span>역대 합계 평균 <strong>{ld.analysis.mean_sum}</strong></span>
<span>표준편차 <strong>±{ld.analysis.std_sum}</strong></span>
<span>분석 회차 <strong>{ld.analysis.total_draws?.toLocaleString()}</strong></span>
<span>
홀수 3:짝수 3 확률{' '}
<strong>
{ld.analysis.odd_distribution?.['3'] ? `${ld.analysis.odd_distribution['3']}%` : '-'}
</strong>
</span>
</div>
</div>
) : (
<p className="lotto-empty">
{ld.loading.analysis ? '불러오는 중...' : '통계 분석 데이터가 없습니다.'}
</p>
)}
</section>
{/* 전체 번호 분포 */}
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Distribution</p>
<h3>전체 회차 번호 분포</h3>
<p className="lotto-panel__sub">1~45 번호가 등장한 횟수를 기준으로 분포를 표시합니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.statsLoading ? <span className="lotto-chip">로딩 </span> : null}
{ld.stats?.total_draws ? (
<span className="lotto-chip">{ld.stats.total_draws}회차</span>
) : null}
<button className="button ghost small" onClick={ld.refreshStats} disabled={ld.statsLoading}>
새로고침
</button>
</div>
</div>
{ld.statsError ? <p className="lotto-empty">{ld.statsError}</p> : null}
{ld.stats ? (
<FrequencyChart stats={ld.stats} />
) : (
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
)}
</section>
{/* 내 번호 패턴 */}
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
{/* 수동 추천 */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
<h3>수동 추천</h3>
<p className="lotto-panel__sub">파라미터를 직접 조정해 번호를 추천받을 있습니다.</p>
</div>
<div className="lotto-panel__actions">
{mr.loading.recommend ? <span className="lotto-chip">계산 </span> : null}
</div>
</div>
<div className="lotto-presets">
{mr.presets.map((preset) => (
<button key={preset.name} className="button ghost small"
onClick={() => mr.setParams({
recent_window: preset.recent_window,
recent_weight: preset.recent_weight,
avoid_recent_k: preset.avoid_recent_k,
})}>
{preset.name}
</button>
))}
</div>
<div className="lotto-form">
<label className="lotto-field">
recent_window <span>최근 N회차 가중치 범위</span>
<input type="number" min={20} max={1000} value={mr.params.recent_window}
onChange={(e) => mr.setParams((s) => ({ ...s, recent_window: Number(e.target.value) }))} />
</label>
<label className="lotto-field">
recent_weight <span>최근 회차 가중치</span>
<input type="number" step="0.1" min={0.5} max={10} value={mr.params.recent_weight}
onChange={(e) => mr.setParams((s) => ({ ...s, recent_weight: Number(e.target.value) }))} />
</label>
<label className="lotto-field">
avoid_recent_k <span>최근 K회차 중복 회피</span>
<input type="number" min={0} max={50} value={mr.params.avoid_recent_k}
onChange={(e) => mr.setParams((s) => ({ ...s, avoid_recent_k: Number(e.target.value) }))} />
</label>
</div>
<button className="button primary" onClick={mr.onRecommend} disabled={mr.loading.recommend}>
추천 받기
</button>
{mr.result ? (
<div className="lotto-result">
<div className="lotto-result__meta">
<div>
<p className="lotto-result__id">추천 ID #{mr.result.id}</p>
<p className="lotto-result__based">기준 회차 {mr.result.based_on_latest_draw ?? '-'}</p>
</div>
<button className="button small" onClick={() => copyNumbers(mr.result.numbers)}>
번호 복사
</button>
</div>
{mr.result.numbers && <NumberRow nums={mr.result.numbers} />}
{mr.historyMetrics && (
<div className="lotto-compare">
<MetricBlock title="추천 통계 (히스토리)" metrics={mr.historyMetrics} />
</div>
)}
{Array.isArray(mr.result.items) && mr.result.items.length ? (
<details className="lotto-details">
<summary>추천 후보 보기</summary>
<div className="lotto-batch">
{mr.result.items.map((item, idx) => (
<div key={item.id ?? `candidate-${idx}`} className="lotto-batch__item">
<div className="lotto-batch__meta">
<div>
<p className="lotto-batch__title">후보 #{item.id ?? idx + 1}</p>
<p className="lotto-batch__sub">기준 회차 {item.based_on_draw ?? '-'}</p>
</div>
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
복사
</button>
</div>
<NumberRow nums={item.numbers} />
{item.metrics && <MetricBlock title="후보 통계" metrics={item.metrics} />}
</div>
))}
</div>
</details>
) : null}
{mr.result.explain && (
<details className="lotto-details">
<summary>설명 보기</summary>
<pre>{JSON.stringify(mr.result.explain, null, 2)}</pre>
</details>
)}
</div>
) : (
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
)}
</section>
{/* 추천 히스토리 */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">History</p>
<h3>추천 히스토리</h3>
<p className="lotto-panel__sub">수동 추천 결과를 모아서 확인할 있습니다.</p>
</div>
<div className="lotto-panel__actions">
<span className="lotto-chip">{mr.history.length}</span>
{mr.history.length > 5 && (
<button className="button ghost small lotto-history-toggle"
onClick={() => mr.setHistoryExpanded((p) => !p)}
aria-expanded={mr.historyExpanded}>
{mr.historyExpanded ? '접기' : '더보기'}
<span className={`lotto-history-toggle__icon ${mr.historyExpanded ? 'is-open' : ''}`} aria-hidden></span>
</button>
)}
<button className="button ghost small" onClick={mr.refreshHistory} disabled={mr.loading.history}>
새로고침
</button>
</div>
</div>
{mr.loading.history ? <p className="lotto-empty">불러오는 ...</p> : null}
{mr.history.length === 0 ? (
<p className="lotto-empty">저장된 히스토리가 없습니다.</p>
) : (
<div className="lotto-history">
{mr.visibleHistory.map((item) => (
<div key={item.id} className="lotto-history__item">
<div className="lotto-history__meta">
<p>#{item.id}</p>
<p>{fmtKST(item.created_at)}</p>
<p>기준 회차 {item.based_on_draw ?? '-'}</p>
</div>
<div className="lotto-history__body">
<NumberRow nums={item.numbers} />
<p className="lotto-history__params">
window={item.params?.recent_window}, weight={item.params?.recent_weight},
avoid_k={item.params?.avoid_recent_k}
</p>
</div>
<div className="lotto-history__actions">
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
복사
</button>
<button className="button danger small" onClick={() => mr.onDelete(item.id)}>
삭제
</button>
</div>
</div>
))}
<span ref={mr.historyEndRef} />
</div>
)}
</section>
</>
);
}