/**
* 大樂透 AI 預測系統 — App Controller (649)
* Loads real historical data from lottery_649_data.json,
* runs prediction model, renders charts, and manages the UI.
* Numbers: 1-49, pick 6 regular numbers + 1 special number (特別號)
*
* All state and functions are namespaced with _649 suffix to avoid
* conflicts with the 今彩539 app.js loaded on the same page.
*/
// ─── State ────────────────────────────────────────────────────────────────────
let allDraws_649 = [];
let filteredDraws_649 = [];
let prediction_649 = { numbers: [], special: '00' };
let frequency_649 = {};
let chartSortMode_649 = 'number'; // 'number' | 'freq'
let currentPage_649 = 1;
const PAGE_SIZE_649 = 50;
let loaded_649 = false;
// Numbers to highlight in history (the predicted ones)
let highlightNums_649 = new Set();
// ─── Data Loading ─────────────────────────────────────────────────────────────
async function loadData_649() {
try {
const res = await fetch('lottery_649_data.json');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
allDraws_649 = data.draws || [];
filteredDraws_649 = [...allDraws_649].reverse(); // newest first for display
prediction_649 = data.prediction || { numbers: [], special: '00' };
// Update header meta for 649 tab
const el649Total = document.getElementById('totalDraws_649');
const el649Latest = document.getElementById('latestDate_649');
const el649Gen = document.getElementById('generatedAt_649');
if (el649Total) el649Total.textContent = allDraws_649.length.toLocaleString() + ' 期';
const latest = allDraws_649[allDraws_649.length - 1];
if (el649Latest) el649Latest.textContent = latest ? latest.date.replace(/ \(.\)/, '') : '—';
const gen = data.generatedAt ? new Date(data.generatedAt).toLocaleDateString('zh-TW') : '—';
if (el649Gen) el649Gen.textContent = gen;
// Compute stats
frequency_649 = computeFrequency_649(allDraws_649);
// Render all sections
renderPrediction_649(prediction_649);
renderHotColdOverdue_649();
renderChart_649();
renderRanking_649();
renderHistory_649();
} catch (e) {
console.error('Failed to load 649 data:', e);
const hb = document.getElementById('historyBody_649');
if (hb) hb.innerHTML =
`
❌ 無法載入資料:${e.message} 請先執行 node scraper649.js 生成 lottery_649_data.json |
`;
}
}
// ─── Prediction Model (M3 SpreadOverdue) ─────────────────────────────────────
// 6 buckets spanning 1-49 — picks the most-overdue number from each bucket
const SPREAD_BUCKETS_649 = [
[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,40,41],
[42,43,44,45,46,47,48,49],
];
function getLastSeenMap_649(draws) {
const lastSeen = {};
for (let i = 1; i <= 49; 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 getSpecialLastSeenMap_649(draws) {
const lastSeen = {};
for (let i = 1; i <= 49; i++) lastSeen[String(i).padStart(2, '0')] = draws.length;
for (let i = draws.length - 1; i >= 0; i--) {
if (draws[i].special && draws[i].special !== '00') {
const key = String(parseInt(draws[i].special)).padStart(2, '0');
if (lastSeen[key] === draws.length) lastSeen[key] = draws.length - 1 - i;
}
}
return lastSeen;
}
function predictNextDraw_649(draws) {
if (draws.length < 10) return { numbers: [], special: '00' };
const lastSeen = getLastSeenMap_649(draws);
const numbers = SPREAD_BUCKETS_649.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));
// Special number: most overdue from 1-49 not in regular predicted numbers
const specialLastSeen = getSpecialLastSeenMap_649(draws);
const predictedSet = new Set(numbers);
const specialCandidates = Object.entries(specialLastSeen)
.filter(([k]) => !predictedSet.has(k))
.sort((a, b) => b[1] - a[1]);
const special = specialCandidates.length > 0 ? specialCandidates[0][0] : '00';
return { numbers, special };
}
// ─── Full Probability Scoring (all 49 numbers) ────────────────────────────────
function computeFrequency_649(draws) {
const freq = {};
for (let i = 1; i <= 49; 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_649(draws) {
if (draws.length < 5) return [];
const freq = computeFrequency_649(draws);
const lastSeen = getLastSeenMap_649(draws);
const total = draws.length;
const totalNums = total * 6 || 1;
// Recent 30-draw absence count
const recent30 = {};
for (let i = 1; i <= 49; 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 <= 49; i++) {
const key = String(i).padStart(2, '0');
const gap = lastSeen[key] || 0;
const cnt = freq[key] || 0;
const recentCnt = recent30[key] || 0;
const overdueScore = (gap / maxGap) * 50;
const coldScore = ((maxFreq - cnt) / (maxFreq - minFreq || 1)) * 30;
const recentScore = (recentCnt === 0 ? 20 : Math.max(0, (1 - recentCnt / 6) * 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_649() {
const scores = computeAllScores_649(allDraws_649);
const maxScore = scores[0]?.score || 1;
const container = document.getElementById('rankingBody_649');
if (!container) return;
container.innerHTML = scores.map((s, idx) => {
const isPredicted = highlightNums_649.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_649(pred) {
const container = document.getElementById('predictionBalls_649');
const specialContainer = document.getElementById('predictionSpecial_649');
if (!container) return;
container.innerHTML = '';
if (specialContainer) specialContainer.innerHTML = '';
const nums = pred.numbers || pred;
const special = pred.special || null;
highlightNums_649 = new Set(Array.isArray(nums) ? nums : []);
(Array.isArray(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);
});
// Render special number
if (special && special !== '00' && specialContainer) {
const ball = document.createElement('div');
ball.className = 'ball special-ball';
ball.textContent = special;
ball.style.animationDelay = `${(Array.isArray(nums) ? nums.length : 0) * 0.12}s`;
specialContainer.appendChild(ball);
}
// Refresh history to update highlights
if (filteredDraws_649.length > 0) renderHistoryPage_649();
}
function runPrediction_649() {
const btn = document.getElementById('predictBtn_649');
if (btn) btn.classList.add('loading');
const container = document.getElementById('predictionBalls_649');
const specialContainer = document.getElementById('predictionSpecial_649');
if (!container) return;
container.innerHTML = '';
if (specialContainer) specialContainer.innerHTML = '';
for (let i = 0; i < 6; i++) {
const ph = document.createElement('div');
ph.className = 'ball-placeholder pulse';
container.appendChild(ph);
}
if (specialContainer) {
const ph = document.createElement('div');
ph.className = 'ball-placeholder pulse';
specialContainer.appendChild(ph);
}
setTimeout(() => {
const newPrediction = predictNextDraw_649(allDraws_649);
renderPrediction_649(newPrediction);
if (btn) btn.classList.remove('loading');
}, 600);
}
// ─── Hot / Cold / Overdue ─────────────────────────────────────────────────────
function renderHotColdOverdue_649() {
const lastSeen = getLastSeenMap_649(allDraws_649);
const total = allDraws_649.length;
// Hot
const hot = Object.entries(frequency_649).sort((a, b) => b[1] - a[1]).slice(0, 10);
const hotEl = document.getElementById('hotNumbers_649');
if (hotEl) hotEl.innerHTML = hot.map(([num, count]) =>
`
${num}${count}
`
).join('');
// Cold
const cold = Object.entries(frequency_649).sort((a, b) => a[1] - b[1]).slice(0, 10);
const coldEl = document.getElementById('coldNumbers_649');
if (coldEl) 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_649');
if (overdueEl) overdueEl.innerHTML = overdue.map(([num, gap]) =>
`
${num}${gap === total ? '∞' : gap}
`
).join('');
}
// ─── Frequency Chart ──────────────────────────────────────────────────────────
function renderChart_649() {
const canvas = document.getElementById('freqChart_649');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const entries = Object.entries(frequency_649)
.sort((a, b) => chartSortMode_649 === '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_649.has(num);
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(239,68,68,0.7)');
grad.addColorStop(1, 'rgba(234,88,12,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 (only show every other label if bars are narrow)
if (barW > 10 || i % 2 === 0) {
ctx.fillStyle = isHot ? 'rgba(251,191,36,0.9)' : 'rgba(148,163,184,0.7)';
ctx.font = `${barW > 14 ? 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_649(mode, btn) {
chartSortMode_649 = mode;
document.querySelectorAll('#tab-649 .chip').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
renderChart_649();
}
// ─── History Table ────────────────────────────────────────────────────────────
function filterHistory_649() {
const input = document.getElementById('historySearch_649');
if (!input) return;
const q = input.value.trim().toLowerCase();
const reversed = [...allDraws_649].reverse();
if (!q) {
filteredDraws_649 = reversed;
} else {
filteredDraws_649 = reversed.filter(d => {
const dateMatch = d.date.toLowerCase().includes(q);
const numMatch = d.numbers.some(n => n === q.padStart(2, '0') || n.includes(q));
const specialMatch = d.special && d.special.includes(q.padStart(2, '0'));
return dateMatch || numMatch || specialMatch;
});
}
currentPage_649 = 1;
const rc = document.getElementById('resultCount_649');
if (rc) rc.textContent = filteredDraws_649.length < allDraws_649.length ? `找到 ${filteredDraws_649.length} 筆` : '';
renderHistory_649();
}
function renderHistory_649() {
const totalPages = Math.ceil(filteredDraws_649.length / PAGE_SIZE_649);
renderHistoryPage_649();
renderPagination_649(totalPages);
}
function renderHistoryPage_649() {
const start = (currentPage_649 - 1) * PAGE_SIZE_649;
const pageDraws = filteredDraws_649.slice(start, start + PAGE_SIZE_649);
const totalFiltered = filteredDraws_649.length;
const tbody = document.getElementById('historyBody_649');
if (!tbody) return;
if (pageDraws.length === 0) {
tbody.innerHTML = '| 🔍 沒有符合的紀錄 |
';
return;
}
// Prediction row at top of page 1
let predRow = '';
const predNums = prediction_649.numbers || [];
if (currentPage_649 === 1 && predNums.length === 6) {
const predNumCells = predNums.map(n =>
`${n} | `
).join('');
const predSpecial = prediction_649.special && prediction_649.special !== '00'
? `${prediction_649.special} | `
: '— | ';
predRow = `
| 預測 |
▶ 下期預測號碼 |
${predNumCells}
${predSpecial}
`;
}
tbody.innerHTML = predRow + pageDraws.map((draw, i) => {
const globalIdx = totalFiltered - start - i;
const nums = draw.numbers.map(n => {
const isHL = highlightNums_649.has(n);
return `${n} | `;
});
const specialCell = draw.special && draw.special !== '00'
? `${draw.special} | `
: '— | ';
return `
| ${globalIdx} |
${draw.date} |
${nums.join('')}
${specialCell}
`;
}).join('');
}
function renderPagination_649(totalPages) {
const pag = document.getElementById('pagination_649');
if (!pag) return;
if (totalPages <= 1) { pag.innerHTML = ''; return; }
const buttons = [];
buttons.push(``);
const range = [];
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage_649 - 2 && i <= currentPage_649 + 2)) {
range.push(i);
}
}
let prev = null;
for (const p of range) {
if (prev && p - prev > 1) buttons.push(`…`);
buttons.push(``);
prev = p;
}
buttons.push(``);
pag.innerHTML = buttons.join('');
}
function changePage_649(page) {
const totalPages = Math.ceil(filteredDraws_649.length / PAGE_SIZE_649);
if (page < 1 || page > totalPages) return;
currentPage_649 = page;
renderHistory_649();
const hs = document.querySelector('#tab-649 .history-section');
if (hs) hs.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ─── Resize Handler ───────────────────────────────────────────────────────────
let resizeTimer_649;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer_649);
resizeTimer_649 = setTimeout(() => {
// Only re-render chart if 649 tab is active
if (allDraws_649.length && document.getElementById('tab-649')?.classList.contains('active')) {
renderChart_649();
}
}, 250);
});
// ─── Init (called lazily when tab is first activated) ─────────────────────────
function init649() {
if (loaded_649) return;
loaded_649 = true;
loadData_649();
}