Spaces:
Sleeping
Sleeping
| /** | |
| * δ»ε½©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 = | |
| `<tr><td colspan="7" class="loading-row">β η‘ζ³θΌε ₯θ³ζοΌ${e.message}<br>θ«ε ε·θ‘ node scraper.js ηζ lottery_539_data.json</td></tr>`; | |
| } | |
| } | |
| // βββ 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 `<tr class="fade-in"> | |
| <td style="${rankStyle}">${rank}</td> | |
| <td><span class="draw-num ${isPredicted ? 'highlight' : ''}" style="width:34px;height:34px;font-size:0.9rem">${s.num}</span></td> | |
| <td> | |
| <div style="display:flex;align-items:center;gap:8px"> | |
| <div style="flex:1;height:8px;background:rgba(255,255,255,0.06);border-radius:4px;overflow:hidden"> | |
| <div style="width:${barPct}%;height:100%;background:${isPredicted ? 'linear-gradient(90deg,#fbbf24,#ef4444)' : 'linear-gradient(90deg,#818cf8,#6366f1)'};border-radius:4px;transition:width 0.8s"></div> | |
| </div> | |
| <span style="font-size:0.78rem;color:var(--text-secondary);min-width:36px;text-align:right">${s.score.toFixed(1)}</span> | |
| </div> | |
| </td> | |
| <td style="font-size:0.78rem;color:var(--text-muted)">${s.gap} ζ</td> | |
| <td style="font-size:0.78rem;color:var(--text-muted)">${s.cnt} (${s.pct}%)</td> | |
| <td style="font-size:0.78rem;color:var(--text-muted)">${s.recentCnt}/30</td> | |
| <td style="font-size:0.72rem;color:var(--text-muted)"> | |
| <span title="ι²ε°" style="color:#c084fc">${s.overdueScore}</span> + | |
| <span title="ε·ι" style="color:#67e8f9">${s.coldScore}</span> + | |
| <span title="θΏζ" style="color:#86efac">${s.recentScore}</span> | |
| </td> | |
| ${isPredicted ? '<td><span style="font-size:0.7rem;padding:2px 8px;background:rgba(251,191,36,0.2);border:1px solid rgba(251,191,36,0.4);border-radius:9999px;color:#fbbf24">β¦ ι ζΈ¬</span></td>' : '<td></td>'} | |
| </tr>`; | |
| }).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]) => | |
| `<div class="num-badge hot fade-in" title="εΊηΎ ${count} 欑 (${((count/total)*100).toFixed(1)}%)"> | |
| <span>${num}</span><span class="count">${count}</span> | |
| </div>` | |
| ).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]) => | |
| `<div class="num-badge cold fade-in" title="εΊηΎ ${count} 欑 (${((count/total)*100).toFixed(1)}%)"> | |
| <span>${num}</span><span class="count">${count}</span> | |
| </div>` | |
| ).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]) => | |
| `<div class="num-badge overdue fade-in" title="θ·δΈζ¬‘εΊηΎ ${gap === total ? 'εΎζͺεΊηΎ' : gap + ' ζ'}"> | |
| <span>${num}</span><span class="count">${gap === total ? 'β' : gap}</span> | |
| </div>` | |
| ).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 = '<tr><td colspan="7" class="loading-row">π ζ²ζ符εηη΄ι</td></tr>'; | |
| return; | |
| } | |
| // ββ Prediction row: show at top of page 1 only ββββββββββββββββββββββββββ | |
| let predRow = ''; | |
| if (currentPage === 1 && prediction.length === 5) { | |
| const predNums = prediction.map(n => | |
| `<td><span class="draw-num highlight" style="box-shadow:0 0 10px rgba(251,191,36,0.5)">${n}</span></td>` | |
| ).join(''); | |
| predRow = `<tr style="background:rgba(251,191,36,0.06);border-bottom:1px solid rgba(251,191,36,0.2)"> | |
| <td class="draw-idx" style="color:#fbbf24;font-weight:700">ι ζΈ¬</td> | |
| <td class="draw-date" style="color:#fbbf24;font-size:0.78rem">βΆ δΈζι ζΈ¬θη’Ό</td> | |
| ${predNums} | |
| </tr>`; | |
| } | |
| tbody.innerHTML = predRow + pageDraws.map((draw, i) => { | |
| const globalIdx = totalFiltered - start - i; | |
| const nums = draw.numbers.map(n => { | |
| const isHL = highlightNums.has(n); | |
| return `<td><span class="draw-num ${isHL ? 'highlight' : ''}">${n}</span></td>`; | |
| }); | |
| return `<tr class="fade-in"> | |
| <td class="draw-idx">${globalIdx}</td> | |
| <td class="draw-date">${draw.date}</td> | |
| ${nums.join('')} | |
| </tr>`; | |
| }).join(''); | |
| } | |
| function renderPagination(totalPages) { | |
| const pag = document.getElementById('pagination'); | |
| if (totalPages <= 1) { pag.innerHTML = ''; return; } | |
| const buttons = []; | |
| // Prev | |
| buttons.push(`<button class="page-btn" onclick="changePage(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>βΉ</button>`); | |
| // 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(`<span class="page-btn" style="opacity:0.4;pointer-events:none">β¦</span>`); | |
| buttons.push(`<button class="page-btn ${p === currentPage ? 'active' : ''}" onclick="changePage(${p})">${p}</button>`); | |
| prev = p; | |
| } | |
| // Next | |
| buttons.push(`<button class="page-btn" onclick="changePage(${currentPage + 1})" ${currentPage === totalPages ? 'disabled' : ''}>βΊ</button>`); | |
| 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(); | |