Files
jaengseung-made/app/services/lotto/recommend/PatternTab.tsx
gahusb 4cacea69c8 feat: 로또 서비스 Phase 1-2 프론트엔드 고도화
- NAS 프록시 공통 헬퍼 (_nas.ts): nasGet/Post/Put/Delete + requireSubscription
- API 라우트 7개: stats/performance, report/latest, report/history, analysis/personal, purchase CRUD
- ReportTab: 주간 공략 리포트 (신뢰도, 추천 세트, 핫/콜드 번호, 히스토리)
- PurchaseTab: 구매 기록 CRUD + 투자 통계 (총구매/당첨금/순손익/최대당첨)
- PatternTab: 개인 번호 패턴 분석 (자주 선택/기피 번호, 구간 분포)
- 성과 배너: 실제 검증 통계 (3개 이상 일치율, 평균 일치 개수, 무작위 대비 개선율)
- 탭 네비게이션: 구독자 전용 (번호 생성/이번 주 공략/구매 기록/내 패턴)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 01:12:59 +09:00

182 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect } from 'react';
interface PersonalPattern {
total_analyzed: number;
number_frequency: Record<string, number>;
top_picks: number[];
least_picks: number[];
pattern: {
avg_odd_count: number;
avg_sum: number;
avg_range: number;
consecutive_rate: number;
zone_avg: Record<string, number>;
};
vs_draw_avg: {
odd_diff: number;
sum_diff: number;
odd_tendency: string;
sum_tendency: string;
};
}
function getBallStyle(n: number) {
if (n <= 10) return { bg: 'linear-gradient(145deg,#fde68a,#fbbf24,#d97706)', text: '#78350f' };
if (n <= 20) return { bg: 'linear-gradient(145deg,#93c5fd,#3b82f6,#1d4ed8)', text: '#fff' };
if (n <= 30) return { bg: 'linear-gradient(145deg,#fca5a5,#ef4444,#b91c1c)', text: '#fff' };
if (n <= 40) return { bg: 'linear-gradient(145deg,#d1d5db,#9ca3af,#4b5563)', text: '#fff' };
return { bg: 'linear-gradient(145deg,#86efac,#22c55e,#15803d)', text: '#fff' };
}
function SmallBall({ n, size = 30, freq }: { n: number; size?: number; freq?: number }) {
const { bg, text } = getBallStyle(n);
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3 }}>
<div style={{
width: size, height: size, borderRadius: '50%', background: bg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.35, fontWeight: 900, color: text, flexShrink: 0,
boxShadow: '0 2px 8px rgba(0,0,0,.3)',
}}>{n}</div>
{freq !== undefined && <div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.55rem', fontFamily: "'JetBrains Mono',monospace" }}>{freq}</div>}
</div>
);
}
function ZoneBar({ label, value, max }: { label: string; value: number; max: number }) {
const pct = max > 0 ? (value / max) * 100 : 0;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '.6rem' }}>
<div style={{ color: 'rgba(255,255,255,.4)', fontSize: '.65rem', minWidth: 44, fontFamily: "'JetBrains Mono',monospace" }}>{label}</div>
<div style={{ flex: 1, height: 8, background: 'rgba(255,255,255,.06)', borderRadius: 4, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: 'linear-gradient(90deg,#fbbf24,#f97316)', borderRadius: 4, transition: 'width 1s ease' }} />
</div>
<div style={{ color: '#fbbf24', fontSize: '.65rem', minWidth: 24, textAlign: 'right', fontFamily: "'JetBrains Mono',monospace" }}>{value.toFixed(1)}</div>
</div>
);
}
export default function PatternTab() {
const [data, setData] = useState<PersonalPattern | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetch('/api/lotto/analysis/personal').then(r => r.json())
.then(setData)
.catch(() => setError('패턴 분석을 불러오지 못했습니다.'))
.finally(() => setLoading(false));
}, []);
if (loading) return (
<div style={{ textAlign: 'center', padding: '4rem 0' }}>
<div style={{ width: 36, height: 36, borderRadius: '50%', border: '3px solid rgba(251,191,36,.15)', borderTop: '3px solid #fbbf24', animation: 'spin .8s linear infinite', margin: '0 auto 1rem' }} />
</div>
);
if (error) return <div style={{ textAlign: 'center', padding: '4rem 0', color: '#f87171', fontSize: '.85rem' }}>{error}</div>;
if (!data || data.total_analyzed === 0) return (
<div style={{ textAlign: 'center', padding: '5rem 1rem' }}>
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>📊</div>
<div style={{ color: 'rgba(255,255,255,.4)', fontSize: '.9rem', marginBottom: '.5rem' }}> </div>
<div style={{ color: 'rgba(255,255,255,.2)', fontSize: '.75rem' }}> </div>
</div>
);
const zoneMax = Math.max(...Object.values(data.pattern.zone_avg));
const tendencyColor = (tendency: string) =>
tendency.includes('고') || tendency.includes('홀수') ? '#f87171' : tendency.includes('저') || tendency.includes('짝수') ? '#60a5fa' : '#4ade80';
return (
<div style={{ animation: 'slideUp .4s ease forwards' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<div>
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: '.6rem', color: 'rgba(251,191,36,.5)', letterSpacing: '.12em', marginBottom: '.3rem' }}>PERSONAL PATTERN ANALYSIS</div>
<h2 style={{ color: '#fff', fontSize: '1.3rem', fontWeight: 900, margin: 0 }}> </h2>
</div>
<div style={{ background: 'rgba(251,191,36,.08)', border: '1px solid rgba(251,191,36,.2)', borderRadius: '.75rem', padding: '.5rem 1rem', marginLeft: 'auto' }}>
<span style={{ color: 'rgba(255,255,255,.4)', fontSize: '.65rem' }}> </span>
<span style={{ color: '#fbbf24', fontWeight: 900, fontFamily: "'JetBrains Mono',monospace" }}>{data.total_analyzed}</span>
<span style={{ color: 'rgba(255,255,255,.4)', fontSize: '.65rem' }}></span>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(280px,1fr))', gap: '1rem' }}>
{/* 자주 선택한 번호 */}
<div style={{ background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', padding: '1.25rem' }}>
<div style={{ color: '#fbbf24', fontSize: '.72rem', fontWeight: 700, marginBottom: '.75rem' }}> TOP 10</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '.5rem' }}>
{data.top_picks.map(n => (
<SmallBall key={n} n={n} size={34} freq={data.number_frequency[String(n)] ?? 0} />
))}
</div>
</div>
{/* 한 번도 안 쓴 번호 */}
<div style={{ background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', padding: '1.25rem' }}>
<div style={{ color: '#60a5fa', fontSize: '.72rem', fontWeight: 700, marginBottom: '.75rem' }}>💤 </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '.5rem' }}>
{data.least_picks.map(n => <SmallBall key={n} n={n} size={34} />)}
</div>
<div style={{ color: 'rgba(255,255,255,.25)', fontSize: '.65rem', marginTop: '.75rem' }}> </div>
</div>
{/* 패턴 지표 */}
<div style={{ background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', padding: '1.25rem' }}>
<div style={{ color: 'rgba(255,255,255,.5)', fontSize: '.72rem', fontWeight: 700, marginBottom: '.75rem' }}>📐 </div>
{[
{ label: '평균 홀수 개수', value: data.pattern.avg_odd_count.toFixed(1) + '개', ref: '역대 평균 3.0개', refColor: 'rgba(255,255,255,.2)' },
{ label: '평균 합계', value: data.pattern.avg_sum.toFixed(0), ref: '역대 평균 138', refColor: 'rgba(255,255,255,.2)' },
{ label: '평균 범위(최대-최소)', value: data.pattern.avg_range.toFixed(1), ref: '', refColor: '' },
{ label: '연속번호 포함률', value: `${(data.pattern.consecutive_rate * 100).toFixed(0)}%`, ref: '', refColor: '' },
].map(({ label, value, ref }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', padding: '.45rem 0', borderBottom: '1px solid rgba(255,255,255,.05)' }}>
<span style={{ color: 'rgba(255,255,255,.35)', fontSize: '.68rem' }}>{label}</span>
<div style={{ textAlign: 'right' }}>
<div style={{ color: '#fff', fontWeight: 700, fontSize: '.8rem', fontFamily: "'JetBrains Mono',monospace" }}>{value}</div>
{ref && <div style={{ color: 'rgba(255,255,255,.2)', fontSize: '.58rem' }}>{ref}</div>}
</div>
</div>
))}
</div>
{/* 구간별 선택 분포 */}
<div style={{ background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', padding: '1.25rem' }}>
<div style={{ color: 'rgba(255,255,255,.5)', fontSize: '.72rem', fontWeight: 700, marginBottom: '.75rem' }}>🎯 </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '.5rem' }}>
{Object.entries(data.pattern.zone_avg).map(([zone, val]) => (
<ZoneBar key={zone} label={zone} value={val} max={zoneMax} />
))}
</div>
</div>
{/* 역대 당첨과 비교 */}
<div style={{ gridColumn: '1/-1', background: 'rgba(255,255,255,.03)', border: '1px solid rgba(255,255,255,.07)', borderRadius: '1rem', padding: '1.25rem' }}>
<div style={{ color: 'rgba(255,255,255,.5)', fontSize: '.72rem', fontWeight: 700, marginBottom: '1rem' }}> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(200px,1fr))', gap: '1rem' }}>
<div style={{ background: 'rgba(255,255,255,.03)', borderRadius: '.75rem', padding: '1rem', textAlign: 'center' }}>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.65rem', marginBottom: '.4rem' }}> </div>
<div style={{ color: tendencyColor(data.vs_draw_avg.odd_tendency), fontSize: '1.1rem', fontWeight: 900 }}>{data.vs_draw_avg.odd_tendency}</div>
<div style={{ color: 'rgba(255,255,255,.25)', fontSize: '.65rem', marginTop: '.3rem', fontFamily: "'JetBrains Mono',monospace" }}>
{data.vs_draw_avg.odd_diff > 0 ? '+' : ''}{data.vs_draw_avg.odd_diff.toFixed(1)}
</div>
</div>
<div style={{ background: 'rgba(255,255,255,.03)', borderRadius: '.75rem', padding: '1rem', textAlign: 'center' }}>
<div style={{ color: 'rgba(255,255,255,.3)', fontSize: '.65rem', marginBottom: '.4rem' }}> </div>
<div style={{ color: tendencyColor(data.vs_draw_avg.sum_tendency), fontSize: '1.1rem', fontWeight: 900 }}>{data.vs_draw_avg.sum_tendency}</div>
<div style={{ color: 'rgba(255,255,255,.25)', fontSize: '.65rem', marginTop: '.3rem', fontFamily: "'JetBrains Mono',monospace" }}>
{data.vs_draw_avg.sum_diff > 0 ? '+' : ''}{data.vs_draw_avg.sum_diff.toFixed(1)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}