/** * 大樂透 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.score.toFixed(1)}
${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(); }