/** * 今彩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.score.toFixed(1)}
${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(``); 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();