lotto 추천 및 시뮬레이션 시스템 고도화
This commit is contained in:
19
src/api.js
19
src/api.js
@@ -82,6 +82,25 @@ export function deleteHistory(id) {
|
|||||||
return apiDelete(`/api/history/${id}`);
|
return apiDelete(`/api/history/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 시뮬레이션 관련 API ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getBestPicks(limit = 20) {
|
||||||
|
return apiGet(`/api/lotto/best?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAnalysis() {
|
||||||
|
return apiGet('/api/lotto/analysis');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerSimulate(nCandidates = 20000, topK = 100, bestN = 20) {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
n_candidates: String(nCandidates),
|
||||||
|
top_k: String(topK),
|
||||||
|
best_n: String(bestN),
|
||||||
|
});
|
||||||
|
return apiPost(`/api/admin/simulate?${qs.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function getStockNews(limit = 20, category) {
|
export function getStockNews(limit = 20, category) {
|
||||||
const qs = new URLSearchParams({ limit: String(limit) });
|
const qs = new URLSearchParams({ limit: String(limit) });
|
||||||
if (category) {
|
if (category) {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
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', ' ') ?? '';
|
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 bucketOrder = ['1-10', '11-20', '21-30', '31-40', '41-45'];
|
||||||
const STATS_CACHE_KEY = 'lotto_stats_v1';
|
const STATS_CACHE_KEY = 'lotto_stats_v1';
|
||||||
|
const BEST_PICKS_DEFAULT_SHOW = 5;
|
||||||
|
|
||||||
const readStatsCache = () => {
|
const readStatsCache = () => {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
@@ -243,6 +247,7 @@ const FrequencyChart = ({ stats }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Functions() {
|
export default function Functions() {
|
||||||
|
// ── 기존 상태 ──────────────────────────────────────────────────────────────
|
||||||
const [latest, setLatest] = useState(null);
|
const [latest, setLatest] = useState(null);
|
||||||
const [params, setParams] = useState({
|
const [params, setParams] = useState({
|
||||||
recent_window: 200,
|
recent_window: 200,
|
||||||
@@ -258,7 +263,6 @@ export default function Functions() {
|
|||||||
],
|
],
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [history, setHistory] = useState([]);
|
const [history, setHistory] = useState([]);
|
||||||
const [historyExpanded, setHistoryExpanded] = useState(false);
|
const [historyExpanded, setHistoryExpanded] = useState(false);
|
||||||
@@ -271,8 +275,19 @@ export default function Functions() {
|
|||||||
latest: false,
|
latest: false,
|
||||||
recommend: false,
|
recommend: false,
|
||||||
history: false,
|
history: false,
|
||||||
|
bestPicks: false,
|
||||||
|
analysis: false,
|
||||||
});
|
});
|
||||||
const [error, setError] = useState('');
|
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(
|
const overallMetrics = useMemo(
|
||||||
() => buildMetricsFromFrequency(stats?.frequency),
|
() => buildMetricsFromFrequency(stats?.frequency),
|
||||||
[stats]
|
[stats]
|
||||||
@@ -281,7 +296,12 @@ export default function Functions() {
|
|||||||
() => buildMetricsFromHistory(history),
|
() => buildMetricsFromHistory(history),
|
||||||
[history]
|
[history]
|
||||||
);
|
);
|
||||||
|
const visibleHistory = historyExpanded ? history : history.slice(0, 5);
|
||||||
|
const visibleBestPicks = bestPicksExpanded
|
||||||
|
? bestPicks
|
||||||
|
: bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW);
|
||||||
|
|
||||||
|
// ── 기존 데이터 로드 함수 ──────────────────────────────────────────────────
|
||||||
const refreshLatest = async () => {
|
const refreshLatest = async () => {
|
||||||
setLoading((s) => ({ ...s, latest: true }));
|
setLoading((s) => ({ ...s, latest: true }));
|
||||||
setError('');
|
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 () => {
|
const onRecommend = async () => {
|
||||||
setLoading((s) => ({ ...s, recommend: true }));
|
setLoading((s) => ({ ...s, recommend: true }));
|
||||||
setError('');
|
setError('');
|
||||||
@@ -356,7 +419,6 @@ export default function Functions() {
|
|||||||
const onDelete = async (id) => {
|
const onDelete = async (id) => {
|
||||||
const ok = confirm(`히스토리 #${id}를 삭제할까요?`);
|
const ok = confirm(`히스토리 #${id}를 삭제할까요?`);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await deleteHistory(id);
|
await deleteHistory(id);
|
||||||
@@ -376,14 +438,15 @@ export default function Functions() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 초기 로드 ──────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshLatest();
|
refreshLatest();
|
||||||
refreshHistory();
|
refreshHistory();
|
||||||
refreshStats();
|
refreshStats();
|
||||||
|
refreshBestPicks();
|
||||||
|
refreshAnalysis();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const visibleHistory = historyExpanded ? history : history.slice(0, 5);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (historyExpanded && !prevHistoryExpandedRef.current) {
|
if (historyExpanded && !prevHistoryExpandedRef.current) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -396,6 +459,7 @@ export default function Functions() {
|
|||||||
prevHistoryExpandedRef.current = historyExpanded;
|
prevHistoryExpandedRef.current = historyExpanded;
|
||||||
}, [historyExpanded, visibleHistory.length]);
|
}, [historyExpanded, visibleHistory.length]);
|
||||||
|
|
||||||
|
// ── 렌더 ───────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div className="lotto-functions">
|
<div className="lotto-functions">
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -410,7 +474,9 @@ export default function Functions() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* ── 상단 2열 그리드: 최신 회차 + 시뮬레이션 추천 ── */}
|
||||||
<div className="lotto-grid">
|
<div className="lotto-grid">
|
||||||
|
{/* Latest Draw */}
|
||||||
<section className="lotto-panel">
|
<section className="lotto-panel">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
@@ -462,17 +528,262 @@ export default function Functions() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Simulation Picks */}
|
||||||
<section className="lotto-panel">
|
<section className="lotto-panel">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="lotto-panel__eyebrow">Recommendation</p>
|
<p className="lotto-panel__eyebrow">Simulation Picks</p>
|
||||||
<h3>추천 생성</h3>
|
<h3>시뮬레이션 추천</h3>
|
||||||
<p className="lotto-panel__sub">
|
<p className="lotto-panel__sub">
|
||||||
파라미터를 조정해 다른 추천 전략을 만들 수 있습니다.
|
하루 6회 몬테카를로 시뮬레이션으로 선별된 최적 번호입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="lotto-panel__actions">
|
<div className="lotto-panel__actions">
|
||||||
{loading.recommend ? <span className="lotto-chip">계산 중</span> : null}
|
{loading.bestPicks ? <span className="lotto-chip">로딩 중</span> : null}
|
||||||
|
{simulating ? (
|
||||||
|
<span className="lotto-chip lotto-chip--active">분석 중</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={refreshBestPicks}
|
||||||
|
disabled={loading.bestPicks || simulating}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button small"
|
||||||
|
onClick={onSimulate}
|
||||||
|
disabled={simulating || loading.bestPicks}
|
||||||
|
>
|
||||||
|
{simulating ? '실행 중...' : '지금 실행'}
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
<p className="lotto-panel__eyebrow">Distribution</p>
|
||||||
|
<h3>전체 회차 번호 분포</h3>
|
||||||
|
<p className="lotto-panel__sub">
|
||||||
|
1~45번 번호가 등장한 횟수를 기준으로 분포를 표시합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-panel__actions">
|
||||||
|
{statsLoading ? (
|
||||||
|
<span className="lotto-chip">로딩 중</span>
|
||||||
|
) : null}
|
||||||
|
{stats?.total_draws ? (
|
||||||
|
<span className="lotto-chip">{stats.total_draws}회차</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={refreshStats}
|
||||||
|
disabled={statsLoading}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statsError ? <p className="lotto-empty">{statsError}</p> : null}
|
||||||
|
{stats ? (
|
||||||
|
<FrequencyChart stats={stats} />
|
||||||
|
) : (
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -630,53 +941,14 @@ export default function Functions() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
{/* ── 추천 히스토리 ── */}
|
||||||
|
|
||||||
<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">
|
|
||||||
{statsLoading ? (
|
|
||||||
<span className="lotto-chip">로딩 중</span>
|
|
||||||
) : null}
|
|
||||||
{stats?.total_draws ? (
|
|
||||||
<span className="lotto-chip">
|
|
||||||
{stats.total_draws}회차
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
className="button ghost small"
|
|
||||||
onClick={refreshStats}
|
|
||||||
disabled={statsLoading}
|
|
||||||
>
|
|
||||||
새로고침
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{statsError ? <p className="lotto-empty">{statsError}</p> : null}
|
|
||||||
{stats ? (
|
|
||||||
<FrequencyChart stats={stats} />
|
|
||||||
) : (
|
|
||||||
<p className="lotto-empty">
|
|
||||||
통계 데이터를 불러오지 못했습니다.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="lotto-panel">
|
<section className="lotto-panel">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="lotto-panel__eyebrow">History</p>
|
<p className="lotto-panel__eyebrow">History</p>
|
||||||
<h3>추천 히스토리</h3>
|
<h3>추천 히스토리</h3>
|
||||||
<p className="lotto-panel__sub">
|
<p className="lotto-panel__sub">
|
||||||
최근 추천 결과를 모아서 확인할 수 있습니다.
|
수동 추천 결과를 모아서 확인할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="lotto-panel__actions">
|
<div className="lotto-panel__actions">
|
||||||
@@ -684,15 +956,9 @@ export default function Functions() {
|
|||||||
{history.length > 5 ? (
|
{history.length > 5 ? (
|
||||||
<button
|
<button
|
||||||
className="button ghost small lotto-history-toggle"
|
className="button ghost small lotto-history-toggle"
|
||||||
onClick={() =>
|
onClick={() => setHistoryExpanded((prev) => !prev)}
|
||||||
setHistoryExpanded((prev) => !prev)
|
|
||||||
}
|
|
||||||
aria-expanded={historyExpanded}
|
aria-expanded={historyExpanded}
|
||||||
aria-label={
|
aria-label={historyExpanded ? '히스토리 접기' : '히스토리 더보기'}
|
||||||
historyExpanded
|
|
||||||
? '히스토리 접기'
|
|
||||||
: '히스토리 더보기'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{historyExpanded ? '접기' : '더보기'}
|
{historyExpanded ? '접기' : '더보기'}
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -589,6 +589,151 @@
|
|||||||
background: rgba(247, 116, 125, 0.15);
|
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) {
|
@media (max-width: 900px) {
|
||||||
.lotto-header {
|
.lotto-header {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -597,6 +742,16 @@
|
|||||||
.lotto-history__item {
|
.lotto-history__item {
|
||||||
grid-template-columns: 1fr;
|
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) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ const Lotto = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="lotto-card">
|
<div className="lotto-card">
|
||||||
<p className="lotto-card__title">다음 업데이트 아이디어</p>
|
<p className="lotto-card__title">시뮬레이션 추천 시스템</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>로또 기록을 캘린더 형태로 정리</li>
|
<li>하루 6회 몬테카를로 시뮬레이션 자동 실행</li>
|
||||||
<li>자주 등장하는 번호 조합 분석</li>
|
<li>20,000개 후보를 5가지 통계 기법으로 스코어링</li>
|
||||||
<li>그래프로 추첨 추세 확인</li>
|
<li>핫·콜드·오버듀 번호 통계 분석 제공</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in New Issue
Block a user