/**
* 今彩539 AI 預測系統 — App Controller
* Loads real historical data from lottery_539_data.json,
* runs prediction model, renders charts, and manages the UI.
*/
// ─── State ────────────────────────────────────────────────────────────────────
let allDraws = [];
let filteredDraws = [];
let prediction = [];
let frequency = {};
let chartSortMode = 'number'; // 'number' | 'freq'
let currentPage = 1;
const PAGE_SIZE = 50;
// Numbers to highlight in history (the predicted ones)
let highlightNums = new Set();
// ─── Data Loading ─────────────────────────────────────────────────────────────
async function loadData() {
try {
const res = await fetch('lottery_539_data.json');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
allDraws = data.draws || [];
filteredDraws = [...allDraws].reverse(); // newest first for display
prediction = data.prediction || [];
// Update header meta
document.getElementById('totalDraws').textContent = allDraws.length.toLocaleString() + ' 期';
const latest = allDraws[allDraws.length - 1];
document.getElementById('latestDate').textContent = latest ? latest.date.replace(/ \(.\)/, '') : '—';
const gen = data.generatedAt ? new Date(data.generatedAt).toLocaleDateString('zh-TW') : '—';
document.getElementById('generatedAt').textContent = gen;
// Compute stats
frequency = computeFrequency(allDraws);
// Render all sections
renderPrediction(prediction);
renderHotColdOverdue();
renderChart();
renderRanking();
renderHistory();
} catch (e) {
console.error('Failed to load data:', e);
document.getElementById('historyBody').innerHTML =
`
❌ 無法載入資料:${e.message} 請先執行 node scraper.js 生成 lottery_539_data.json `;
}
}
// ─── Prediction Model (M3 SpreadOverdue) ─────────────────────────────────────
// 5 buckets spanning 1–39 — picks the most-overdue number from each bucket
const SPREAD_BUCKETS = [
[1,2,3,4,5,6,7,8],
[9,10,11,12,13,14,15],
[16,17,18,19,20,21,22],
[23,24,25,26,27,28,29,30],
[31,32,33,34,35,36,37,38,39],
];
function getLastSeenMap(draws) {
const lastSeen = {};
for (let i = 1; i <= 39; i++) lastSeen[String(i).padStart(2, '0')] = draws.length;
for (let i = draws.length - 1; i >= 0; i--) {
for (const num of draws[i].numbers) {
const key = String(parseInt(num)).padStart(2, '0');
if (lastSeen[key] === draws.length) lastSeen[key] = draws.length - 1 - i;
}
}
return lastSeen;
}
function predictNextDraw(draws) {
if (draws.length < 10) return [];
const lastSeen = getLastSeenMap(draws);
return SPREAD_BUCKETS.map(bucket =>
bucket.map(n => ({ n: String(n).padStart(2,'0'), gap: lastSeen[String(n).padStart(2,'0')] || 0 }))
.sort((a, b) => b.gap - a.gap)[0].n
).sort((a, b) => parseInt(a) - parseInt(b));
}
// ─── Full Probability Scoring (all 39 numbers) ────────────────────────────────
// Composite score: 遲到(gap) 50% + 冷門(inverse freq) 30% + 近期未出現(recent absence) 20%
function computeFrequency(draws) {
const freq = {};
for (let i = 1; i <= 39; i++) freq[String(i).padStart(2, '0')] = 0;
for (const draw of draws) {
for (const n of draw.numbers) {
const key = String(parseInt(n)).padStart(2, '0');
if (freq[key] !== undefined) freq[key]++;
}
}
return freq;
}
function computeAllScores(draws) {
if (draws.length < 5) return [];
const freq = computeFrequency(draws);
const lastSeen = getLastSeenMap(draws);
const total = draws.length;
const totalNums = total * 5 || 1;
// Recent 30-draw absence count
const recent30 = {};
for (let i = 1; i <= 39; i++) recent30[String(i).padStart(2,'0')] = 0;
const recentDraws = draws.slice(-30);
for (const d of recentDraws) {
for (const n of d.numbers) recent30[String(parseInt(n)).padStart(2,'0')]++;
}
const maxGap = Math.max(...Object.values(lastSeen)) || 1;
const maxFreq = Math.max(...Object.values(freq)) || 1;
const minFreq = Math.min(...Object.values(freq));
const scores = [];
for (let i = 1; i <= 39; i++) {
const key = String(i).padStart(2, '0');
const gap = lastSeen[key] || 0; // periods since last seen
const cnt = freq[key] || 0; // total appearances
const recentCnt = recent30[key] || 0; // appearances in last 30 draws
// Overdue score (50%) — bigger gap = higher score
const overdueScore = (gap / maxGap) * 50;
// Cold score (30%) — fewer appearances = higher score
const coldScore = ((maxFreq - cnt) / (maxFreq - minFreq || 1)) * 30;
// Recent absence score (20%) — not seen recently = higher score
const recentScore = (recentCnt === 0 ? 20 : Math.max(0, (1 - recentCnt / 5) * 20));
const total_score = overdueScore + coldScore + recentScore;
const pct = ((cnt / totalNums) * 100).toFixed(2);
scores.push({
num: key,
score: total_score,
gap,
cnt,
pct,
recentCnt,
overdueScore: overdueScore.toFixed(1),
coldScore: coldScore.toFixed(1),
recentScore: recentScore.toFixed(1),
});
}
return scores.sort((a, b) => b.score - a.score);
}
// ─── Render Probability Ranking ───────────────────────────────────────────────
function renderRanking() {
const scores = computeAllScores(allDraws);
const maxScore = scores[0]?.score || 1;
const container = document.getElementById('rankingBody');
if (!container) return;
container.innerHTML = scores.map((s, idx) => {
const isPredicted = highlightNums.has(s.num);
const barPct = (s.score / maxScore * 100).toFixed(1);
const rank = idx + 1;
let rankStyle = '';
if (rank === 1) rankStyle = 'color:#fbbf24;font-weight:800';
else if (rank <= 3) rankStyle = 'color:#c084fc;font-weight:700';
else if (rank <= 5) rankStyle = 'color:#67e8f9;font-weight:600';
return `
${rank}
${s.num}
${s.gap} 期
${s.cnt} (${s.pct}%)
${s.recentCnt}/30
${s.overdueScore} +
${s.coldScore} +
${s.recentScore}
${isPredicted ? '✦ 預測 ' : ' '}
`;
}).join('');
}
// ─── Render Prediction ────────────────────────────────────────────────────────
function renderPrediction(nums) {
const container = document.getElementById('predictionBalls');
container.innerHTML = '';
highlightNums = new Set(nums);
nums.forEach((num, idx) => {
const ball = document.createElement('div');
ball.className = 'ball';
ball.textContent = num;
ball.style.animationDelay = `${idx * 0.12}s`;
container.appendChild(ball);
});
// Refresh history to update highlights
if (filteredDraws.length > 0) renderHistoryPage();
}
function runPrediction() {
const btn = document.getElementById('predictBtn');
btn.classList.add('loading');
// Animate balls out
const container = document.getElementById('predictionBalls');
container.innerHTML = '';
for (let i = 0; i < 5; i++) {
const ph = document.createElement('div');
ph.className = 'ball-placeholder pulse';
container.appendChild(ph);
}
// Small delay for visual effect
setTimeout(() => {
const newPrediction = predictNextDraw(allDraws);
renderPrediction(newPrediction);
btn.classList.remove('loading');
}, 600);
}
// ─── Hot / Cold / Overdue ─────────────────────────────────────────────────────
function renderHotColdOverdue() {
const lastSeen = getLastSeenMap(allDraws);
const total = allDraws.length;
// Hot
const hot = Object.entries(frequency).sort((a, b) => b[1] - a[1]).slice(0, 10);
const hotEl = document.getElementById('hotNumbers');
hotEl.innerHTML = hot.map(([num, count]) =>
`
${num} ${count}
`
).join('');
// Cold
const cold = Object.entries(frequency).sort((a, b) => a[1] - b[1]).slice(0, 10);
const coldEl = document.getElementById('coldNumbers');
coldEl.innerHTML = cold.map(([num, count]) =>
`
${num} ${count}
`
).join('');
// Overdue
const overdue = Object.entries(lastSeen).sort((a, b) => b[1] - a[1]).slice(0, 10);
const overdueEl = document.getElementById('overdueNumbers');
overdueEl.innerHTML = overdue.map(([num, gap]) =>
`
${num} ${gap === total ? '∞' : gap}
`
).join('');
}
// ─── Frequency Chart ──────────────────────────────────────────────────────────
function renderChart() {
const canvas = document.getElementById('freqChart');
const ctx = canvas.getContext('2d');
const entries = Object.entries(frequency)
.sort((a, b) => chartSortMode === 'number' ? parseInt(a[0]) - parseInt(b[0]) : b[1] - a[1]);
const labels = entries.map(([num]) => num);
const values = entries.map(([, count]) => count);
const maxVal = Math.max(...values);
// Set canvas resolution
const dpr = window.devicePixelRatio || 1;
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = 320 * dpr;
ctx.scale(dpr, dpr);
const W = rect.width;
const H = 320;
ctx.clearRect(0, 0, W, H);
const padL = 40, padR = 12, padT = 20, padB = 40;
const chartW = W - padL - padR;
const chartH = H - padT - padB;
const barW = chartW / labels.length;
// Grid lines
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const y = padT + (chartH * (1 - i / 5));
ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + chartW, y); ctx.stroke();
ctx.fillStyle = 'rgba(148,163,184,0.5)';
ctx.font = '10px Outfit';
ctx.textAlign = 'right';
ctx.fillText(Math.round(maxVal * i / 5), padL - 4, y + 3);
}
// Bars
entries.forEach(([num, count], i) => {
const x = padL + i * barW;
const barH = (count / maxVal) * chartH;
const y = padT + chartH - barH;
const isHot = highlightNums.has(num);
// Bar gradient
const grad = ctx.createLinearGradient(x, y, x, y + barH);
if (isHot) {
grad.addColorStop(0, 'rgba(251,191,36,0.9)');
grad.addColorStop(1, 'rgba(239,68,68,0.7)');
} else {
grad.addColorStop(0, 'rgba(139,92,246,0.8)');
grad.addColorStop(1, 'rgba(59,130,246,0.4)');
}
ctx.fillStyle = grad;
const bw = barW * 0.65;
const bx = x + (barW - bw) / 2;
const radius = Math.min(3, barH / 2);
ctx.beginPath();
ctx.moveTo(bx + radius, y);
ctx.lineTo(bx + bw - radius, y);
ctx.quadraticCurveTo(bx + bw, y, bx + bw, y + radius);
ctx.lineTo(bx + bw, y + barH);
ctx.lineTo(bx, y + barH);
ctx.lineTo(bx, y + radius);
ctx.quadraticCurveTo(bx, y, bx + radius, y);
ctx.closePath();
ctx.fill();
// Label
ctx.fillStyle = isHot ? 'rgba(251,191,36,0.9)' : 'rgba(148,163,184,0.7)';
ctx.font = `${barW > 18 ? 9 : 8}px Outfit`;
ctx.textAlign = 'center';
ctx.fillText(num, x + barW / 2, H - padB + 14);
});
// Axis line
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padL, padT + chartH);
ctx.lineTo(padL + chartW, padT + chartH);
ctx.stroke();
}
function setSort(mode, btn) {
chartSortMode = mode;
document.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
renderChart();
}
// ─── History Table ────────────────────────────────────────────────────────────
function filterHistory() {
const q = document.getElementById('historySearch').value.trim().toLowerCase();
const reversed = [...allDraws].reverse();
if (!q) {
filteredDraws = reversed;
} else {
filteredDraws = reversed.filter(d => {
const dateMatch = d.date.toLowerCase().includes(q);
const numMatch = d.numbers.some(n => n === q.padStart(2, '0') || n.includes(q));
return dateMatch || numMatch;
});
}
currentPage = 1;
document.getElementById('resultCount').textContent =
filteredDraws.length < allDraws.length ? `找到 ${filteredDraws.length} 筆` : '';
renderHistory();
}
function renderHistory() {
const totalPages = Math.ceil(filteredDraws.length / PAGE_SIZE);
renderHistoryPage();
renderPagination(totalPages);
}
function renderHistoryPage() {
const start = (currentPage - 1) * PAGE_SIZE;
const pageDraws = filteredDraws.slice(start, start + PAGE_SIZE);
const totalFiltered = filteredDraws.length;
const tbody = document.getElementById('historyBody');
if (pageDraws.length === 0) {
tbody.innerHTML = '🔍 沒有符合的紀錄 ';
return;
}
// ── Prediction row: show at top of page 1 only ──────────────────────────
let predRow = '';
if (currentPage === 1 && prediction.length === 5) {
const predNums = prediction.map(n =>
`${n} `
).join('');
predRow = `
預測
▶ 下期預測號碼
${predNums}
`;
}
tbody.innerHTML = predRow + pageDraws.map((draw, i) => {
const globalIdx = totalFiltered - start - i;
const nums = draw.numbers.map(n => {
const isHL = highlightNums.has(n);
return `${n} `;
});
return `
${globalIdx}
${draw.date}
${nums.join('')}
`;
}).join('');
}
function renderPagination(totalPages) {
const pag = document.getElementById('pagination');
if (totalPages <= 1) { pag.innerHTML = ''; return; }
const buttons = [];
// Prev
buttons.push(`‹ `);
// Page numbers with ellipsis
const range = [];
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
range.push(i);
}
}
let prev = null;
for (const p of range) {
if (prev && p - prev > 1) buttons.push(`… `);
buttons.push(`${p} `);
prev = p;
}
// Next
buttons.push(`› `);
pag.innerHTML = buttons.join('');
}
function changePage(page) {
const totalPages = Math.ceil(filteredDraws.length / PAGE_SIZE);
if (page < 1 || page > totalPages) return;
currentPage = page;
renderHistory();
document.querySelector('.history-section').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ─── Background Particles ─────────────────────────────────────────────────────
function initParticles() {
const container = document.getElementById('bgParticles');
if (!container || container.dataset.initialized) return;
container.dataset.initialized = '1';
for (let i = 0; i < 20; i++) {
const p = document.createElement('div');
p.style.cssText = `
position: absolute;
width: ${2 + Math.random() * 3}px;
height: ${2 + Math.random() * 3}px;
border-radius: 50%;
background: rgba(${Math.random() > 0.5 ? '139,92,246' : '99,102,241'},${0.2 + Math.random() * 0.3});
left: ${Math.random() * 100}%;
top: ${Math.random() * 100}%;
animation: floatParticle ${6 + Math.random() * 8}s ease-in-out ${Math.random() * 4}s infinite alternate;
`;
container.appendChild(p);
}
const style = document.createElement('style');
style.textContent = `@keyframes floatParticle {
from { transform: translateY(0px) translateX(0px); opacity: 0.3; }
to { transform: translateY(${-20 - Math.random() * 40}px) translateX(${(-20 + Math.random() * 40)}px); opacity: 0.8; }
}`;
document.head.appendChild(style);
}
// ─── Resize Handler ───────────────────────────────────────────────────────────
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
// Only re-render chart if 539 tab is active (or no tabs present)
const tab539 = document.getElementById('tab-539');
if (allDraws.length && (!tab539 || tab539.classList.contains('active'))) renderChart();
}, 250);
});
// ─── Init (called lazily when 539 tab is first activated) ─────────────────────
let loaded_539 = false;
function init539() {
if (loaded_539) return;
loaded_539 = true;
loadData();
}
// ─── Bootstrap ────────────────────────────────────────────────────────────────
// Particles are always initialized; data is loaded via tab switching
initParticles();