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,176 +528,214 @@ 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}
|
||||||
</div>
|
{simulating ? (
|
||||||
</div>
|
<span className="lotto-chip lotto-chip--active">분석 중</span>
|
||||||
|
) : null}
|
||||||
<div className="lotto-presets">
|
|
||||||
{presets.map((preset) => (
|
|
||||||
<button
|
<button
|
||||||
key={preset.name}
|
|
||||||
className="button ghost small"
|
className="button ghost small"
|
||||||
onClick={() =>
|
onClick={refreshBestPicks}
|
||||||
setParams({
|
disabled={loading.bestPicks || simulating}
|
||||||
recent_window: preset.recent_window,
|
|
||||||
recent_weight: preset.recent_weight,
|
|
||||||
avoid_recent_k: preset.avoid_recent_k,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{preset.name}
|
새로고침
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button small"
|
||||||
|
onClick={onSimulate}
|
||||||
|
disabled={simulating || loading.bestPicks}
|
||||||
|
>
|
||||||
|
{simulating ? '실행 중...' : '지금 실행'}
|
||||||
</button>
|
</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>
|
||||||
|
</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>
|
</section>
|
||||||
|
|
||||||
</div>
|
</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">
|
<section className="lotto-panel lotto-panel--wide">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
@@ -646,9 +750,7 @@ export default function Functions() {
|
|||||||
<span className="lotto-chip">로딩 중</span>
|
<span className="lotto-chip">로딩 중</span>
|
||||||
) : null}
|
) : null}
|
||||||
{stats?.total_draws ? (
|
{stats?.total_draws ? (
|
||||||
<span className="lotto-chip">
|
<span className="lotto-chip">{stats.total_draws}회차</span>
|
||||||
{stats.total_draws}회차
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="button ghost small"
|
className="button ghost small"
|
||||||
@@ -664,19 +766,189 @@ export default function Functions() {
|
|||||||
{stats ? (
|
{stats ? (
|
||||||
<FrequencyChart stats={stats} />
|
<FrequencyChart stats={stats} />
|
||||||
) : (
|
) : (
|
||||||
<p className="lotto-empty">
|
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
|
||||||
통계 데이터를 불러오지 못했습니다.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</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">
|
<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