lotto 추천 및 시뮬레이션 시스템 고도화
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { deleteHistory, getHistory, getLatest, getStats, recommend } from '../../api';
|
||||
import {
|
||||
deleteHistory, getHistory, getLatest, getStats, recommend,
|
||||
getBestPicks, getAnalysis, triggerSimulate,
|
||||
} from '../../api';
|
||||
|
||||
const fmtKST = (value) => value?.replace('T', ' ') ?? '';
|
||||
|
||||
@@ -23,6 +26,7 @@ const NumberRow = ({ nums }) => (
|
||||
|
||||
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;
|
||||
@@ -243,6 +247,7 @@ const FrequencyChart = ({ stats }) => {
|
||||
};
|
||||
|
||||
export default function Functions() {
|
||||
// ── 기존 상태 ──────────────────────────────────────────────────────────────
|
||||
const [latest, setLatest] = useState(null);
|
||||
const [params, setParams] = useState({
|
||||
recent_window: 200,
|
||||
@@ -258,7 +263,6 @@ export default function Functions() {
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const [result, setResult] = useState(null);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [historyExpanded, setHistoryExpanded] = useState(false);
|
||||
@@ -271,8 +275,19 @@ export default function Functions() {
|
||||
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 overallMetrics = useMemo(
|
||||
() => buildMetricsFromFrequency(stats?.frequency),
|
||||
[stats]
|
||||
@@ -281,7 +296,12 @@ export default function Functions() {
|
||||
() => 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('');
|
||||
@@ -339,6 +359,49 @@ export default function Functions() {
|
||||
}
|
||||
};
|
||||
|
||||
// ── 시뮬레이션 관련 함수 ───────────────────────────────────────────────────
|
||||
const refreshBestPicks = async () => {
|
||||
setLoading((s) => ({ ...s, bestPicks: true }));
|
||||
try {
|
||||
const data = await getBestPicks(20);
|
||||
setBestPicks(data.items ?? []);
|
||||
} catch (e) {
|
||||
// best picks 없을 수 있음 (첫 실행 전) - 전역 에러 표시 안 함
|
||||
} finally {
|
||||
setLoading((s) => ({ ...s, bestPicks: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAnalysis = async () => {
|
||||
setLoading((s) => ({ ...s, analysis: true }));
|
||||
try {
|
||||
const data = await getAnalysis();
|
||||
setAnalysis(data);
|
||||
} catch {
|
||||
// 분석 데이터는 보조 정보이므로 에러 무시
|
||||
} finally {
|
||||
setLoading((s) => ({ ...s, analysis: 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('');
|
||||
@@ -356,7 +419,6 @@ export default function Functions() {
|
||||
const onDelete = async (id) => {
|
||||
const ok = confirm(`히스토리 #${id}를 삭제할까요?`);
|
||||
if (!ok) return;
|
||||
|
||||
setError('');
|
||||
try {
|
||||
await deleteHistory(id);
|
||||
@@ -376,14 +438,15 @@ export default function Functions() {
|
||||
}
|
||||
};
|
||||
|
||||
// ── 초기 로드 ──────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
refreshLatest();
|
||||
refreshHistory();
|
||||
refreshStats();
|
||||
refreshBestPicks();
|
||||
refreshAnalysis();
|
||||
}, []);
|
||||
|
||||
const visibleHistory = historyExpanded ? history : history.slice(0, 5);
|
||||
|
||||
useEffect(() => {
|
||||
if (historyExpanded && !prevHistoryExpandedRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -396,6 +459,7 @@ export default function Functions() {
|
||||
prevHistoryExpandedRef.current = historyExpanded;
|
||||
}, [historyExpanded, visibleHistory.length]);
|
||||
|
||||
// ── 렌더 ───────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="lotto-functions">
|
||||
{error ? (
|
||||
@@ -410,7 +474,9 @@ export default function Functions() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── 상단 2열 그리드: 최신 회차 + 시뮬레이션 추천 ── */}
|
||||
<div className="lotto-grid">
|
||||
{/* Latest Draw */}
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
@@ -462,176 +528,214 @@ export default function Functions() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Simulation Picks */}
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Recommendation</p>
|
||||
<h3>추천 생성</h3>
|
||||
<p className="lotto-panel__eyebrow">Simulation Picks</p>
|
||||
<h3>시뮬레이션 추천</h3>
|
||||
<p className="lotto-panel__sub">
|
||||
파라미터를 조정해 다른 추천 전략을 만들 수 있습니다.
|
||||
하루 6회 몬테카를로 시뮬레이션으로 선별된 최적 번호입니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{loading.recommend ? <span className="lotto-chip">계산 중</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lotto-presets">
|
||||
{presets.map((preset) => (
|
||||
{loading.bestPicks ? <span className="lotto-chip">로딩 중</span> : null}
|
||||
{simulating ? (
|
||||
<span className="lotto-chip lotto-chip--active">분석 중</span>
|
||||
) : null}
|
||||
<button
|
||||
key={preset.name}
|
||||
className="button ghost small"
|
||||
onClick={() =>
|
||||
setParams({
|
||||
recent_window: preset.recent_window,
|
||||
recent_weight: preset.recent_weight,
|
||||
avoid_recent_k: preset.avoid_recent_k,
|
||||
})
|
||||
}
|
||||
onClick={refreshBestPicks}
|
||||
disabled={loading.bestPicks || simulating}
|
||||
>
|
||||
{preset.name}
|
||||
새로고침
|
||||
</button>
|
||||
<button
|
||||
className="button small"
|
||||
onClick={onSimulate}
|
||||
disabled={simulating || loading.bestPicks}
|
||||
>
|
||||
{simulating ? '실행 중...' : '지금 실행'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lotto-form">
|
||||
<label className="lotto-field">
|
||||
recent_window
|
||||
<span>최근 N회차 가중치 범위</span>
|
||||
<input
|
||||
type="number"
|
||||
min={20}
|
||||
max={1000}
|
||||
value={params.recent_window}
|
||||
onChange={(e) =>
|
||||
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={params.recent_weight}
|
||||
onChange={(e) =>
|
||||
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={params.avoid_recent_k}
|
||||
onChange={(e) =>
|
||||
setParams((s) => ({
|
||||
...s,
|
||||
avoid_recent_k: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="button primary"
|
||||
onClick={onRecommend}
|
||||
disabled={loading.recommend}
|
||||
>
|
||||
추천 받기
|
||||
</button>
|
||||
|
||||
{result ? (
|
||||
<div className="lotto-result">
|
||||
<div className="lotto-result__meta">
|
||||
<div>
|
||||
<p className="lotto-result__id">추천 ID #{result.id}</p>
|
||||
<p className="lotto-result__based">
|
||||
기준 회차 {result.based_on_latest_draw ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="button small"
|
||||
onClick={() => copyNumbers(result.numbers)}
|
||||
>
|
||||
번호 복사
|
||||
</button>
|
||||
</div>
|
||||
{result.numbers ? <NumberRow nums={result.numbers} /> : null}
|
||||
{historyMetrics ? (
|
||||
<div className="lotto-compare">
|
||||
<MetricBlock
|
||||
title="추천 통계 (히스토리)"
|
||||
metrics={historyMetrics}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{Array.isArray(result.items) && result.items.length ? (
|
||||
<details className="lotto-details">
|
||||
<summary>추천 후보 보기</summary>
|
||||
<div className="lotto-batch">
|
||||
{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}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
{result.explain ? (
|
||||
<details className="lotto-details">
|
||||
<summary>설명 보기</summary>
|
||||
<pre>{JSON.stringify(result.explain, null, 2)}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{simResult ? (
|
||||
<div className="lotto-sim-result">
|
||||
<p>
|
||||
완료: {simResult.total_generated?.toLocaleString()}개 후보 →{' '}
|
||||
상위 {simResult.best_n_saved}개 저장
|
||||
</p>
|
||||
<p>
|
||||
최고 점수 {((simResult.best_score ?? 0) * 100).toFixed(1)}% /{' '}
|
||||
평균 {((simResult.avg_score ?? 0) * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{bestPicks.length === 0 ? (
|
||||
<p className="lotto-empty">
|
||||
{loading.bestPicks
|
||||
? '불러오는 중...'
|
||||
: "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."}
|
||||
</p>
|
||||
) : (
|
||||
<p className="lotto-empty">아직 추천 결과가 없습니다.</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>
|
||||
|
||||
{bestPicks.length > BEST_PICKS_DEFAULT_SHOW ? (
|
||||
<button
|
||||
className="button ghost small lotto-history-toggle"
|
||||
onClick={() => setBestPicksExpanded((prev) => !prev)}
|
||||
aria-expanded={bestPicksExpanded}
|
||||
>
|
||||
{bestPicksExpanded
|
||||
? '접기'
|
||||
: `모두 보기 (${bestPicks.length}개)`}
|
||||
<span
|
||||
className={`lotto-history-toggle__icon ${bestPicksExpanded ? 'is-open' : ''}`}
|
||||
aria-hidden
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<p className="lotto-panel__sub">
|
||||
갱신: {fmtKST(bestPicks[0]?.created_at) || '-'}
|
||||
{bestPicks[0]?.based_on_draw
|
||||
? ` · ${bestPicks[0].based_on_draw}회 기준`
|
||||
: ''}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── 통계 분석 ── */}
|
||||
<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">
|
||||
{loading.analysis ? <span className="lotto-chip">로딩 중</span> : null}
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={refreshAnalysis}
|
||||
disabled={loading.analysis}
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
{(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">
|
||||
{(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">
|
||||
{(analysis.overdue_numbers ?? []).map((n) => {
|
||||
const stat = (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>{analysis.mean_sum}</strong>
|
||||
</span>
|
||||
<span>
|
||||
표준편차 <strong>±{analysis.std_sum}</strong>
|
||||
</span>
|
||||
<span>
|
||||
분석 회차 <strong>{analysis.total_draws?.toLocaleString()}</strong>
|
||||
</span>
|
||||
<span>
|
||||
홀수 3:짝수 3 확률{' '}
|
||||
<strong>
|
||||
{analysis.odd_distribution?.['3']
|
||||
? `${analysis.odd_distribution['3']}%`
|
||||
: '-'}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="lotto-empty">
|
||||
{loading.analysis ? '불러오는 중...' : '통계 분석 데이터가 없습니다.'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 전체 번호 분포 ── */}
|
||||
<section className="lotto-panel lotto-panel--wide">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
@@ -646,9 +750,7 @@ export default function Functions() {
|
||||
<span className="lotto-chip">로딩 중</span>
|
||||
) : null}
|
||||
{stats?.total_draws ? (
|
||||
<span className="lotto-chip">
|
||||
{stats.total_draws}회차
|
||||
</span>
|
||||
<span className="lotto-chip">{stats.total_draws}회차</span>
|
||||
) : null}
|
||||
<button
|
||||
className="button ghost small"
|
||||
@@ -664,19 +766,189 @@ export default function Functions() {
|
||||
{stats ? (
|
||||
<FrequencyChart stats={stats} />
|
||||
) : (
|
||||
<p className="lotto-empty">
|
||||
통계 데이터를 불러오지 못했습니다.
|
||||
</p>
|
||||
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 수동 추천 ── */}
|
||||
<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">
|
||||
{loading.recommend ? (
|
||||
<span className="lotto-chip">계산 중</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lotto-presets">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset.name}
|
||||
className="button ghost small"
|
||||
onClick={() =>
|
||||
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={params.recent_window}
|
||||
onChange={(e) =>
|
||||
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={params.recent_weight}
|
||||
onChange={(e) =>
|
||||
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={params.avoid_recent_k}
|
||||
onChange={(e) =>
|
||||
setParams((s) => ({
|
||||
...s,
|
||||
avoid_recent_k: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="button primary"
|
||||
onClick={onRecommend}
|
||||
disabled={loading.recommend}
|
||||
>
|
||||
추천 받기
|
||||
</button>
|
||||
|
||||
{result ? (
|
||||
<div className="lotto-result">
|
||||
<div className="lotto-result__meta">
|
||||
<div>
|
||||
<p className="lotto-result__id">추천 ID #{result.id}</p>
|
||||
<p className="lotto-result__based">
|
||||
기준 회차 {result.based_on_latest_draw ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="button small"
|
||||
onClick={() => copyNumbers(result.numbers)}
|
||||
>
|
||||
번호 복사
|
||||
</button>
|
||||
</div>
|
||||
{result.numbers ? <NumberRow nums={result.numbers} /> : null}
|
||||
{historyMetrics ? (
|
||||
<div className="lotto-compare">
|
||||
<MetricBlock
|
||||
title="추천 통계 (히스토리)"
|
||||
metrics={historyMetrics}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{Array.isArray(result.items) && result.items.length ? (
|
||||
<details className="lotto-details">
|
||||
<summary>추천 후보 보기</summary>
|
||||
<div className="lotto-batch">
|
||||
{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}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
{result.explain ? (
|
||||
<details className="lotto-details">
|
||||
<summary>설명 보기</summary>
|
||||
<pre>{JSON.stringify(result.explain, null, 2)}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</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">
|
||||
@@ -684,15 +956,9 @@ export default function Functions() {
|
||||
{history.length > 5 ? (
|
||||
<button
|
||||
className="button ghost small lotto-history-toggle"
|
||||
onClick={() =>
|
||||
setHistoryExpanded((prev) => !prev)
|
||||
}
|
||||
onClick={() => setHistoryExpanded((prev) => !prev)}
|
||||
aria-expanded={historyExpanded}
|
||||
aria-label={
|
||||
historyExpanded
|
||||
? '히스토리 접기'
|
||||
: '히스토리 더보기'
|
||||
}
|
||||
aria-label={historyExpanded ? '히스토리 접기' : '히스토리 더보기'}
|
||||
>
|
||||
{historyExpanded ? '접기' : '더보기'}
|
||||
<span
|
||||
|
||||
@@ -589,6 +589,151 @@
|
||||
background: rgba(247, 116, 125, 0.15);
|
||||
}
|
||||
|
||||
/* ── 시뮬레이션 추천 (Best Picks) ────────────────────────────────────────── */
|
||||
|
||||
.lotto-chip--active {
|
||||
background: rgba(151, 201, 170, 0.2);
|
||||
border-color: rgba(151, 201, 170, 0.5);
|
||||
color: #97c9aa;
|
||||
}
|
||||
|
||||
.lotto-sim-result {
|
||||
padding: 12px 14px;
|
||||
background: rgba(151, 201, 170, 0.08);
|
||||
border: 1px solid rgba(151, 201, 170, 0.3);
|
||||
border-radius: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.lotto-picks {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lotto-pick {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.lotto-pick__rank {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lotto-pick__content {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lotto-pick__score {
|
||||
display: grid;
|
||||
grid-template-columns: 46px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lotto-pick__score-label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lotto-pick__bar {
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.lotto-pick__bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(151, 201, 170, 0.85),
|
||||
rgba(133, 165, 216, 0.85)
|
||||
);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* ── 통계 분석 (Analysis) ─────────────────────────────────────────────────── */
|
||||
|
||||
.lotto-analysis {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.lotto-analysis__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.lotto-analysis__group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lotto-analysis__label {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lotto-analysis__label span {
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.lotto-analysis__stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.lotto-analysis__stats strong {
|
||||
color: var(--text);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* ── 오버듀 번호 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.lotto-overdue {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.lotto-overdue__gap {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 반응형 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.lotto-header {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -597,6 +742,16 @@
|
||||
.lotto-history__item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lotto-analysis__row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.lotto-pick {
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -15,11 +15,11 @@ const Lotto = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="lotto-card">
|
||||
<p className="lotto-card__title">다음 업데이트 아이디어</p>
|
||||
<p className="lotto-card__title">시뮬레이션 추천 시스템</p>
|
||||
<ul>
|
||||
<li>로또 기록을 캘린더 형태로 정리</li>
|
||||
<li>자주 등장하는 번호 조합 분석</li>
|
||||
<li>그래프로 추첨 추세 확인</li>
|
||||
<li>하루 6회 몬테카를로 시뮬레이션 자동 실행</li>
|
||||
<li>20,000개 후보를 5가지 통계 기법으로 스코어링</li>
|
||||
<li>핫·콜드·오버듀 번호 통계 분석 제공</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user