로또 종합 추론 번호 추천 기능 추가
5가지 통계 기법(빈도Z-score·조합지문·갭분석·공동출현·다양성)을 기법별 가중치(30/25/20/15/10%)로 투표 집계하여 최적 6개 번호 도출. - 기법별 추천 번호 시각화 (최종 번호 하이라이트) - 투표 참여 기법 수 점 표시 (최대 5개) - 조합 품질 점수 5차원 바 차트 - 추천 이력 히스토리 누적 저장 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
11
src/api.js
11
src/api.js
@@ -304,6 +304,17 @@ export function getPersonalAnalysis() {
|
||||
return apiGet('/api/lotto/analysis/personal');
|
||||
}
|
||||
|
||||
// ── 종합 추론 추천 ──────────────────────────────────────────────────────────
|
||||
// GET /api/lotto/recommend/combined
|
||||
export function getCombinedRecommend() {
|
||||
return apiGet('/api/lotto/recommend/combined');
|
||||
}
|
||||
|
||||
// GET /api/lotto/recommend/combined/history
|
||||
export function getCombinedHistory(limit = 30) {
|
||||
return apiGet(`/api/lotto/recommend/combined/history?limit=${limit}`);
|
||||
}
|
||||
|
||||
// GET /api/lotto/purchase?draw_no=N&days=N
|
||||
export function getPurchases({ draw_no, days } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getPerformanceStats, getLatestReport, getReportHistory,
|
||||
getPersonalAnalysis, getPurchases, getPurchaseStats,
|
||||
addPurchase, updatePurchase, deletePurchase,
|
||||
getCombinedRecommend, getCombinedHistory,
|
||||
} from '../../api';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
@@ -286,6 +287,179 @@ const ConfidenceRing = ({ score }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
종합 추론 추천 패널
|
||||
───────────────────────────────────────────── */
|
||||
const METHOD_META = {
|
||||
frequency: { label: '빈도 Z-score', desc: '역대 출현 빈도가 기댓값보다 높은 번호', color: '#818cf8', icon: '📊' },
|
||||
fingerprint: { label: '조합 지문', desc: '역대 당첨 조합의 합계·홀짝·구간 분포에 맞는 번호', color: '#fbbf24', icon: '🔏' },
|
||||
gap: { label: '갭 분석', desc: '가장 오래 등장하지 않은 오버듀 번호', color: '#34d399', icon: '⏳' },
|
||||
cooccur: { label: '공동 출현', desc: '역대에 함께 출현한 빈도가 높은 번호', color: '#f472b6', icon: '🔗' },
|
||||
diversity: { label: '다양성', desc: '구간 커버리지와 번호 범위를 극대화한 번호', color: '#fb923c', icon: '🌈' },
|
||||
};
|
||||
const METHOD_ORDER = ['fingerprint', 'frequency', 'gap', 'cooccur', 'diversity'];
|
||||
const SCORE_META = [
|
||||
{ key: 'score_fingerprint', label: '조합 지문', color: '#fbbf24', weight: 30 },
|
||||
{ key: 'score_frequency', label: '빈도 Z', color: '#818cf8', weight: 25 },
|
||||
{ key: 'score_gap', label: '갭 분석', color: '#34d399', weight: 20 },
|
||||
{ key: 'score_cooccur', label: '공동 출현', color: '#f472b6', weight: 15 },
|
||||
{ key: 'score_diversity', label: '다양성', color: '#fb923c', weight: 10 },
|
||||
];
|
||||
|
||||
const CombinedRecommendPanel = ({ combined, history, loading, histLoading, onRun, onCopy }) => {
|
||||
const [histExpand, setHistExpand] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="lotto-panel lotto-panel--wide lotto-combined">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">AI · 종합 추론</p>
|
||||
<h3>종합 추론 번호 추천</h3>
|
||||
<p className="lotto-panel__sub">
|
||||
5가지 통계 기법(빈도·지문·갭·공동출현·다양성)을 가중 투표로 합산해
|
||||
최적 6개 번호를 도출합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{loading && <span className="lotto-chip">분석 중…</span>}
|
||||
<button className="button primary small" onClick={onRun} disabled={loading}>
|
||||
{loading ? '추론 중…' : '🔮 종합 추론 실행'}
|
||||
</button>
|
||||
{history.length > 0 && (
|
||||
<button className="button ghost small" onClick={() => setHistExpand(p => !p)}>
|
||||
이력 {history.length}건 {histExpand ? '▲' : '▼'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!combined && !loading && (
|
||||
<p className="lotto-empty">버튼을 눌러 종합 추론을 실행하세요.</p>
|
||||
)}
|
||||
|
||||
{combined && (
|
||||
<>
|
||||
{/* 기법별 추천 번호 */}
|
||||
<div className="lotto-combined__methods">
|
||||
{METHOD_ORDER.map((key) => {
|
||||
const meta = METHOD_META[key];
|
||||
const m = combined.methods?.[key];
|
||||
if (!m) return null;
|
||||
return (
|
||||
<div key={key} className="lotto-combined__method">
|
||||
<div className="lotto-combined__method-head">
|
||||
<span className="lotto-combined__method-icon">{meta.icon}</span>
|
||||
<div>
|
||||
<p className="lotto-combined__method-name" style={{ color: meta.color }}>
|
||||
{meta.label}
|
||||
<span className="lotto-combined__method-weight"> ({m.weight_pct}%)</span>
|
||||
</p>
|
||||
<p className="lotto-combined__method-desc">{meta.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lotto-combined__method-nums">
|
||||
{m.numbers.map((n) => {
|
||||
const inFinal = combined.final_numbers.includes(n);
|
||||
return (
|
||||
<span
|
||||
key={n}
|
||||
className={`lotto-ball ${ballClass(n).replace('lotto-ball ', '')} ${inFinal ? 'is-final' : 'is-dim'}`}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 최종 추론 결과 */}
|
||||
<div className="lotto-combined__final">
|
||||
<div className="lotto-combined__final-head">
|
||||
<span className="lotto-combined__final-badge">종합 추론 결과</span>
|
||||
{combined.deduped && (
|
||||
<span className="lotto-chip lotto-chip--muted">중복 (이미 저장됨)</span>
|
||||
)}
|
||||
<button className="button ghost small" onClick={() => onCopy(combined.final_numbers)}>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
<div className="lotto-combined__final-balls">
|
||||
{combined.final_numbers.map((n) => {
|
||||
const votes = combined.vote_counts?.[String(n)] ?? 0;
|
||||
return (
|
||||
<div key={n} className="lotto-combined__final-ball-wrap">
|
||||
<span className={ballClass(n)}>{n}</span>
|
||||
<span className="lotto-combined__vote-dots">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<span key={i} className={`lotto-combined__vote-dot ${i < votes ? 'is-on' : ''}`} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="lotto-combined__final-sub">
|
||||
● 점은 해당 번호가 채택된 기법 수 (최대 5개)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 점수 바 */}
|
||||
<div className="lotto-combined__scores">
|
||||
<p className="lotto-combined__scores-title">조합 품질 점수</p>
|
||||
{SCORE_META.map(({ key, label, color, weight }) => {
|
||||
const val = combined.scores?.[key] ?? 0;
|
||||
const pct = Math.round(val * 100);
|
||||
return (
|
||||
<div key={key} className="lotto-combined__score-row">
|
||||
<span className="lotto-combined__score-label">{label}</span>
|
||||
<span className="lotto-combined__score-weight">{weight}%</span>
|
||||
<div className="lotto-combined__score-bar-wrap">
|
||||
<div
|
||||
className="lotto-combined__score-bar"
|
||||
style={{ width: `${pct}%`, background: color }}
|
||||
/>
|
||||
</div>
|
||||
<span className="lotto-combined__score-val">{pct}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="lotto-combined__score-total">
|
||||
종합 점수 <strong>{Math.round((combined.scores?.score_total ?? 0) * 100)}</strong> / 100
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="lotto-combined__disclaimer">
|
||||
※ 이 추천은 역대 통계 패턴 기반 참고 자료이며, 당첨을 보장하지 않습니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 추천 이력 */}
|
||||
{histExpand && (
|
||||
<div className="lotto-combined__history">
|
||||
<p className="lotto-combined__history-title">종합 추론 이력</p>
|
||||
{histLoading && <p className="lotto-empty">로딩 중…</p>}
|
||||
{history.map((item) => (
|
||||
<div key={item.id} className="lotto-combined__history-item">
|
||||
<div className="lotto-combined__history-meta">
|
||||
<span>#{item.id}</span>
|
||||
<span>{fmtKST(item.created_at)}</span>
|
||||
<span>기준 {item.based_on_draw ?? '-'}회</span>
|
||||
</div>
|
||||
<NumberRow nums={item.numbers} />
|
||||
<button className="button ghost small" onClick={() => onCopy(item.numbers)}>복사</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/* 공략 리포트 패널 */
|
||||
const ReportPanel = ({ report, history, loading, onRefresh, onSelectDrw }) => {
|
||||
const [histExpand, setHistExpand] = useState(false);
|
||||
@@ -710,6 +884,11 @@ export default function Functions() {
|
||||
const [simResult, setSimResult] = useState(null);
|
||||
|
||||
// ── 신규 상태 ──────────────────────────────────────────────────────────────
|
||||
const [combined, setCombined] = useState(null);
|
||||
const [combinedLoading, setCombinedLoading] = useState(false);
|
||||
const [combinedHistory, setCombinedHistory] = useState([]);
|
||||
const [combinedHistLoading, setCombinedHistLoading] = useState(false);
|
||||
|
||||
const [perfStats, setPerfStats] = useState(null);
|
||||
const [report, setReport] = useState(null);
|
||||
const [reportHistory, setReportHistory] = useState([]);
|
||||
@@ -811,6 +990,27 @@ export default function Functions() {
|
||||
finally { setReportLoading(false); }
|
||||
};
|
||||
|
||||
const runCombinedRecommend = async () => {
|
||||
setCombinedLoading(true);
|
||||
try {
|
||||
const data = await getCombinedRecommend();
|
||||
setCombined(data);
|
||||
// 이력도 새로고침
|
||||
const hist = await getCombinedHistory(30);
|
||||
setCombinedHistory(hist?.items ?? []);
|
||||
} catch (e) { setError(e?.message ?? String(e)); }
|
||||
finally { setCombinedLoading(false); }
|
||||
};
|
||||
|
||||
const loadCombinedHistory = async () => {
|
||||
setCombinedHistLoading(true);
|
||||
try {
|
||||
const hist = await getCombinedHistory(30);
|
||||
setCombinedHistory(hist?.items ?? []);
|
||||
} catch {}
|
||||
finally { setCombinedHistLoading(false); }
|
||||
};
|
||||
|
||||
const refreshPersonalAnalysis = async () => {
|
||||
setPersonalLoading(true);
|
||||
try { setPersonalAnalysis(await getPersonalAnalysis()); }
|
||||
@@ -943,6 +1143,7 @@ export default function Functions() {
|
||||
refreshReport();
|
||||
refreshPersonalAnalysis();
|
||||
refreshPurchases();
|
||||
loadCombinedHistory();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -970,6 +1171,16 @@ export default function Functions() {
|
||||
{/* ── 신뢰도 배너 ── */}
|
||||
<PerformanceBanner perf={perfStats} />
|
||||
|
||||
{/* ── 종합 추론 번호 추천 ── */}
|
||||
<CombinedRecommendPanel
|
||||
combined={combined}
|
||||
history={combinedHistory}
|
||||
loading={combinedLoading}
|
||||
histLoading={combinedHistLoading}
|
||||
onRun={runCombinedRecommend}
|
||||
onCopy={copyNumbers}
|
||||
/>
|
||||
|
||||
{/* ── 최신 회차 + 시뮬레이션 추천 ── */}
|
||||
<div className="lotto-grid">
|
||||
{/* Latest Draw */}
|
||||
|
||||
@@ -1195,3 +1195,283 @@
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
종합 추론 패널
|
||||
═══════════════════════════════════════════════════════ */
|
||||
|
||||
.lotto-combined {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 기법별 추천 행 */
|
||||
.lotto-combined__methods {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--line, rgba(255,255,255,0.08));
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.lotto-combined__method {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lotto-combined__method-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.lotto-combined__method-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.lotto-combined__method-name {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lotto-combined__method-weight {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.lotto-combined__method-desc {
|
||||
margin: 2px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, rgba(255,255,255,0.45));
|
||||
}
|
||||
|
||||
.lotto-combined__method-nums {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 최종 결과 */
|
||||
.lotto-combined__final {
|
||||
padding: 20px;
|
||||
background: rgba(129, 140, 248, 0.06);
|
||||
border: 1px solid rgba(129, 140, 248, 0.25);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.lotto-combined__final-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lotto-combined__final-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
color: #818cf8;
|
||||
background: rgba(129, 140, 248, 0.15);
|
||||
border: 1px solid rgba(129, 140, 248, 0.3);
|
||||
border-radius: 20px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
.lotto-combined__final-balls {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lotto-combined__final-ball-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lotto-combined__final-ball-wrap .lotto-ball {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.lotto-combined__vote-dots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.lotto-combined__vote-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.lotto-combined__vote-dot.is-on {
|
||||
background: #818cf8;
|
||||
}
|
||||
|
||||
.lotto-combined__final-sub {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, rgba(255,255,255,0.4));
|
||||
}
|
||||
|
||||
/* 볼 상태 */
|
||||
.lotto-ball.is-dim {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.lotto-ball.is-final {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.5);
|
||||
}
|
||||
|
||||
/* 점수 바 */
|
||||
.lotto-combined__scores {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lotto-combined__scores-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, rgba(255,255,255,0.5));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
|
||||
.lotto-combined__score-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lotto-combined__score-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted, rgba(255,255,255,0.5));
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lotto-combined__score-weight {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, rgba(255,255,255,0.35));
|
||||
width: 28px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.lotto-combined__score-bar-wrap {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lotto-combined__score-bar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.lotto-combined__score-val {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright, #fff);
|
||||
width: 28px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lotto-combined__score-total {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted, rgba(255,255,255,0.5));
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.lotto-combined__score-total strong {
|
||||
color: #818cf8;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.lotto-combined__disclaimer {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, rgba(255,255,255,0.35));
|
||||
}
|
||||
|
||||
/* 이력 */
|
||||
.lotto-combined__history {
|
||||
border-top: 1px solid var(--line, rgba(255,255,255,0.08));
|
||||
padding-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lotto-combined__history-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, rgba(255,255,255,0.45));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
|
||||
.lotto-combined__history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid var(--line, rgba(255,255,255,0.06));
|
||||
border-radius: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lotto-combined__history-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, rgba(255,255,255,0.4));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.lotto-combined__method {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.lotto-combined__final-ball-wrap .lotto-ball {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.lotto-combined__final-balls {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user