429 lines
23 KiB
JavaScript
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>
|
|
</>
|
|
);
|
|
}
|