Spaces:
Sleeping
Sleeping
| <html lang="zh-TW"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>今彩539 預測命中率回測報告</title> | |
| <meta name="description" content="今彩539 AI 預測命中率回測,對近1000期歷史開獎結果進行預測精準度驗證。" /> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" /> | |
| <link rel="stylesheet" href="style.css" /> | |
| <style> | |
| /* ─── Backtest-specific styles ─────────────────────────────────── */ | |
| .kpi-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
| gap: 16px; | |
| margin-bottom: 0; | |
| } | |
| .kpi-card { | |
| background: var(--bg-glass); | |
| border: 1px solid var(--border-glass); | |
| border-radius: var(--radius-md); | |
| padding: 20px; | |
| text-align: center; | |
| transition: all 0.3s; | |
| } | |
| .kpi-card:hover { border-color: var(--border-glass-hover); transform: translateY(-2px); } | |
| .kpi-value { | |
| font-size: 2.2rem; font-weight: 800; line-height: 1; | |
| background: linear-gradient(135deg, var(--purple-400), var(--cyan-400)); | |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; | |
| } | |
| .kpi-value.hot-val { | |
| background: linear-gradient(135deg, var(--gold-400), var(--red-400)); | |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; | |
| } | |
| .kpi-label { font-size: 0.75rem; color: var(--text-muted); margin-top: 6px; text-transform: uppercase; letter-spacing: 0.06em; } | |
| .kpi-sub { font-size: 0.85rem; color: var(--text-secondary); margin-top: 4px; font-weight: 600; } | |
| /* Distribution bars */ | |
| .dist-grid { display: flex; flex-direction: column; gap: 12px; } | |
| .dist-row { display: flex; align-items: center; gap: 12px; } | |
| .dist-label { width: 48px; text-align: right; font-weight: 700; font-size: 0.9rem; color: var(--text-secondary); flex-shrink: 0; } | |
| .dist-bar-wrap { flex: 1; height: 28px; background: rgba(255,255,255,0.04); border-radius: var(--radius-full); overflow: hidden; position: relative; } | |
| .dist-bar { | |
| height: 100%; border-radius: var(--radius-full); | |
| transition: width 1s cubic-bezier(0.25, 0.46, 0.45, 0.94); | |
| display: flex; align-items: center; padding-left: 10px; | |
| font-size: 0.75rem; font-weight: 700; color: white; | |
| white-space: nowrap; | |
| } | |
| .dist-count { width: 60px; text-align: right; font-size: 0.8rem; color: var(--text-secondary); flex-shrink: 0; } | |
| /* Hit colors per level */ | |
| .hit-5 { background: linear-gradient(90deg, #f59e0b, #ef4444); } | |
| .hit-4 { background: linear-gradient(90deg, #f97316, #dc2626); } | |
| .hit-3 { background: linear-gradient(90deg, #8b5cf6, #ec4899); } | |
| .hit-2 { background: linear-gradient(90deg, #3b82f6, #6366f1); } | |
| .hit-1 { background: linear-gradient(90deg, #0891b2, #3b82f6); } | |
| .hit-0 { background: rgba(255,255,255,0.08); } | |
| /* Table region */ | |
| .hit-badge { | |
| display: inline-block; padding: 3px 10px; border-radius: var(--radius-full); | |
| font-size: 0.75rem; font-weight: 700; white-space: nowrap; | |
| } | |
| .hb-0 { background: rgba(255,255,255,0.08); color: var(--text-muted); } | |
| .hb-1 { background: rgba(8,145,178,0.2); color: #67e8f9; border: 1px solid rgba(8,145,178,0.3); } | |
| .hb-2 { background: rgba(99,102,241,0.2); color: #a5b4fc; border: 1px solid rgba(99,102,241,0.3); } | |
| .hb-3 { background: rgba(139,92,246,0.3); color: #c4b5fd; border: 1px solid rgba(139,92,246,0.5); } | |
| .hb-4 { background: rgba(249,115,22,0.25); color: #fdba74; border: 1px solid rgba(249,115,22,0.4); } | |
| .hb-5 { background: rgba(251,191,36,0.3); color: #fde68a; border: 1px solid rgba(251,191,36,0.5); box-shadow: 0 0 12px rgba(251,191,36,0.3); } | |
| .num-mini { | |
| display: inline-flex; align-items: center; justify-content: center; | |
| width: 28px; height: 28px; border-radius: 50%; | |
| font-size: 0.75rem; font-weight: 700; | |
| border: 1px solid rgba(139,92,246,0.3); | |
| background: rgba(124,58,237,0.15); | |
| color: var(--purple-400); | |
| } | |
| .num-mini.hit { | |
| background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.2), transparent 60%), var(--gradient-hot); | |
| border-color: rgba(239,68,68,0.5); | |
| color: white; | |
| box-shadow: 0 0 8px rgba(239,68,68,0.4); | |
| } | |
| .nav-back { display: inline-flex; align-items: center; gap: 6px; color: var(--purple-400); text-decoration: none; font-size: 0.85rem; font-weight: 600; margin-bottom: 4px; } | |
| .nav-back:hover { color: var(--indigo-400); } | |
| /* Filter controls */ | |
| .filter-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } | |
| .filter-chip { | |
| padding: 5px 14px; border-radius: var(--radius-full); | |
| font-family: var(--font); font-size: 0.78rem; font-weight: 600; | |
| border: 1px solid var(--border-glass); background: var(--bg-glass); | |
| color: var(--text-secondary); cursor: pointer; transition: all 0.2s; | |
| } | |
| .filter-chip:hover { color: var(--text-primary); border-color: var(--border-glass-hover); } | |
| .filter-chip.active { background: rgba(124,58,237,0.25); border-color: rgba(124,58,237,0.5); color: var(--purple-400); } | |
| /* Chart #2 */ | |
| .trend-canvas-wrap { width: 100%; height: 260px; } | |
| #trendChart { width: 100% ; height: 100% ; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="bg-particles" id="bgParticles"></div> | |
| <header class="site-header"> | |
| <div class="header-inner"> | |
| <div class="logo"> | |
| <a href="index.html" class="nav-back">← 返回預測</a> | |
| <span class="logo-icon" style="margin-left:12px">📊</span> | |
| <div class="logo-text"> | |
| <h1>命中率回測報告</h1> | |
| <span id="headerSubtitle">載入中...</span> | |
| </div> | |
| </div> | |
| <div id="headerMeta" class="header-meta"> | |
| <div class="meta-item"><span class="meta-label">回測期數</span><span class="meta-value" id="metaN">—</span></div> | |
| <div class="meta-item"><span class="meta-label">回測範圍</span><span class="meta-value" id="metaRange">—</span></div> | |
| <div class="meta-item"><span class="meta-label">整體命中率</span><span class="meta-value" id="metaRate">—</span></div> | |
| </div> | |
| </div> | |
| </header> | |
| <main class="main-content"> | |
| <!-- KPI Cards --> | |
| <section class="section"> | |
| <div class="kpi-grid" id="kpiGrid"> | |
| <div class="kpi-card"><div class="kpi-value" id="kpiRate">—</div><div class="kpi-label">整體號碼命中率</div><div class="kpi-sub">每期5個號碼中平均命中數</div></div> | |
| <div class="kpi-card"><div class="kpi-value" id="kpi1">—</div><div class="kpi-label">至少命中 1 個</div><div class="kpi-sub" id="kpi1n">—</div></div> | |
| <div class="kpi-card"><div class="kpi-value" id="kpi2">—</div><div class="kpi-label">至少命中 2 個</div><div class="kpi-sub" id="kpi2n">—</div></div> | |
| <div class="kpi-card"><div class="kpi-value" id="kpi3">—</div><div class="kpi-label">至少命中 3 個</div><div class="kpi-sub" id="kpi3n">—</div></div> | |
| <div class="kpi-card"><div class="kpi-value hot-val" id="kpi5">—</div><div class="kpi-label">完全命中 5/5 🎉</div><div class="kpi-sub" id="kpi5n">—</div></div> | |
| </div> | |
| </section> | |
| <!-- Distribution + Trend charts --> | |
| <section class="section"> | |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;flex-wrap:wrap" id="chartsRow"> | |
| <div class="glass-card"> | |
| <div class="section-header"><h2 class="section-title">🎯 命中數分佈</h2></div> | |
| <div class="dist-grid" id="distGrid"></div> | |
| </div> | |
| <div class="glass-card"> | |
| <div class="section-header"><h2 class="section-title">📈 命中趨勢 (每100期)</h2></div> | |
| <div class="trend-canvas-wrap"><canvas id="trendChart"></canvas></div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Detailed Results Table --> | |
| <section class="section"> | |
| <div class="glass-card"> | |
| <div class="section-header"> | |
| <h2 class="section-title">📋 逐期回測詳情</h2> | |
| <div class="history-controls"> | |
| <div class="filter-bar" id="filterBar"> | |
| <span style="font-size:0.8rem;color:var(--text-muted)">篩選命中數:</span> | |
| <button class="filter-chip active" onclick="setFilter('all', this)">全部</button> | |
| <button class="filter-chip" onclick="setFilter(3, this)">≥3 命中</button> | |
| <button class="filter-chip" onclick="setFilter(2, this)">≥2 命中</button> | |
| <button class="filter-chip" onclick="setFilter(1, this)">≥1 命中</button> | |
| <button class="filter-chip" onclick="setFilter(0, this)">0 命中</button> | |
| </div> | |
| <span class="result-count" id="btResultCount"></span> | |
| </div> | |
| </div> | |
| <div class="table-wrapper"> | |
| <table class="history-table"> | |
| <thead> | |
| <tr> | |
| <th>期號</th> | |
| <th>日期</th> | |
| <th>預測號碼</th> | |
| <th>實際號碼</th> | |
| <th>命中數</th> | |
| <th>命中號碼</th> | |
| </tr> | |
| </thead> | |
| <tbody id="btBody"> | |
| <tr><td colspan="6" class="loading-row">⏳ 載入回測資料...</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| <div class="pagination" id="btPagination"></div> | |
| </div> | |
| </section> | |
| <!-- Note --> | |
| <section class="section"> | |
| <div class="glass-card" style="background:rgba(139,92,246,0.05);border-color:rgba(139,92,246,0.2)"> | |
| <h3 style="color:var(--purple-400);margin-bottom:12px">📌 關於回測方法</h3> | |
| <p style="color:var(--text-secondary);font-size:0.875rem;line-height:1.8"> | |
| 本回測使用 <strong>Walk-Forward 滾動測試</strong>:對第 <code id="noteStart">—</code> 到第 <code id="noteEnd">—</code> 期共 <code>1000</code> 期進行驗證。<br> | |
| 每一期的預測,<strong>僅使用該期之前的所有歷史資料</strong>作為訓練集(不使用未來資料),確保測試的公平性。<br> | |
| <strong>命中定義</strong>:預測的5個號碼中,有幾個與當期實際開出號碼重疊。<br> | |
| <strong>注意</strong>:今彩539為隨機抽獎,理論隨機基準命中率為 5/39 ≈ <strong>12.82%</strong>。 | |
| 本模型命中率與隨機基準相近,說明彩券具有強烈隨機性。 | |
| </p> | |
| </div> | |
| </section> | |
| </main> | |
| <footer class="site-footer"> | |
| <p>資料來源:<a href="https://www.pilio.idv.tw/lto539/list.asp" target="_blank" rel="noopener">pilio.idv.tw</a> · 本系統僅供參考娛樂,不保證預測準確性</p> | |
| </footer> | |
| <script> | |
| // ─── State ──────────────────────────────────────────────────────────────── | |
| let allResults = []; | |
| let filtered = []; | |
| let filterMin = 'all'; | |
| let currentPage = 1; | |
| const PAGE_SIZE = 50; | |
| // ─── Load Data ──────────────────────────────────────────────────────────── | |
| async function loadData() { | |
| try { | |
| const res = await fetch('backtest_results.json'); | |
| const data = await res.json(); | |
| const { meta, summary, results } = data; | |
| // newest first | |
| allResults = [...results].reverse(); | |
| filtered = allResults; | |
| // Update header | |
| document.getElementById('headerSubtitle').textContent = | |
| `第 ${meta.backtestStart} ~ ${meta.backtestEnd} 期`; | |
| document.getElementById('metaN').textContent = `${meta.backtestN} 期`; | |
| document.getElementById('metaRange').textContent = | |
| `${meta.backtestStart} ~ ${meta.backtestEnd}`; | |
| document.getElementById('metaRate').textContent = `${summary.overallHitRate}%`; | |
| document.getElementById('noteStart').textContent = meta.backtestStart; | |
| document.getElementById('noteEnd').textContent = meta.backtestEnd; | |
| // KPIs | |
| document.getElementById('kpiRate').textContent = `${summary.overallHitRate}%`; | |
| document.getElementById('kpi1').textContent = `${summary.atLeast1Pct}%`; | |
| document.getElementById('kpi1n').textContent = `${summary.atLeast1} 期`; | |
| document.getElementById('kpi2').textContent = `${summary.atLeast2Pct}%`; | |
| document.getElementById('kpi2n').textContent = `${summary.atLeast2} 期`; | |
| document.getElementById('kpi3').textContent = `${summary.atLeast3Pct}%`; | |
| document.getElementById('kpi3n').textContent = `${summary.atLeast3} 期`; | |
| document.getElementById('kpi5').textContent = `${summary.perfect5Pct}%`; | |
| document.getElementById('kpi5n').textContent = `${summary.perfect5} 期`; | |
| renderDistribution(summary.distribution, summary.totalTests); | |
| renderTrendChart(results); | |
| renderTable(); | |
| } catch (e) { | |
| document.getElementById('btBody').innerHTML = | |
| `<tr><td colspan="6" class="loading-row">❌ 無法載入回測資料:${e.message}<br>請先執行 node backtest.js</td></tr>`; | |
| } | |
| } | |
| // ─── Distribution Bars ──────────────────────────────────────────────────── | |
| function renderDistribution(dist, total) { | |
| const container = document.getElementById('distGrid'); | |
| const maxCount = Math.max(...Object.values(dist)); | |
| const labels = ['5/5 🎉', '4/5', '3/5', '2/5', '1/5', '0/5']; | |
| const colorClasses = ['hit-5', 'hit-4', 'hit-3', 'hit-2', 'hit-1', 'hit-0']; | |
| container.innerHTML = [5, 4, 3, 2, 1, 0].map((k, i) => { | |
| const cnt = dist[k] || 0; | |
| const pct = ((cnt / total) * 100).toFixed(1); | |
| const barW = maxCount > 0 ? ((cnt / maxCount) * 100) : 0; | |
| return ` | |
| <div class="dist-row"> | |
| <div class="dist-label">${labels[i]}</div> | |
| <div class="dist-bar-wrap"> | |
| <div class="dist-bar ${colorClasses[i]}" style="width:${barW}%"> | |
| ${pct + '%'} | |
| </div> | |
| </div> | |
| <div class="dist-count">${cnt} 期</div> | |
| </div>`; | |
| }).join(''); | |
| } | |
| // ─── Trend Chart (hits per 100-period block) ─────────────────────────── | |
| function renderTrendChart(results) { | |
| const canvas = document.getElementById('trendChart'); | |
| const ctx = canvas.getContext('2d'); | |
| const BLOCK = 100; | |
| const blocks = []; | |
| for (let i = 0; i < results.length; i += BLOCK) { | |
| const slice = results.slice(i, i + BLOCK); | |
| const hits = slice.reduce((s, r) => s + r.hits, 0); | |
| const pct = (hits / (slice.length * 5)) * 100; | |
| const label = `${slice[0].period}~${slice[slice.length-1].period}`; | |
| blocks.push({ label, pct, count: slice.length }); | |
| } | |
| const dpr = window.devicePixelRatio || 1; | |
| const rect = canvas.parentElement.getBoundingClientRect(); | |
| canvas.width = rect.width * dpr; | |
| canvas.height = 260 * dpr; | |
| ctx.scale(dpr, dpr); | |
| const W = rect.width, H = 260; | |
| ctx.clearRect(0, 0, W, H); | |
| const padL = 40, padR = 12, padT = 20, padB = 50; | |
| const cW = W - padL - padR, cH = H - padT - padB; | |
| const maxPct = Math.max(...blocks.map(b => b.pct), 20); | |
| // Baseline (random = 5/39 = 12.82%) | |
| const baselineY = padT + cH * (1 - 12.82 / maxPct); | |
| ctx.setLineDash([4, 4]); | |
| ctx.strokeStyle = 'rgba(251,191,36,0.4)'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); ctx.moveTo(padL, baselineY); ctx.lineTo(padL + cW, baselineY); ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.fillStyle = 'rgba(251,191,36,0.6)'; | |
| ctx.font = '9px Outfit'; | |
| ctx.fillText('隨機基準 12.82%', padL + 4, baselineY - 3); | |
| // Grid | |
| ctx.strokeStyle = 'rgba(255,255,255,0.05)'; | |
| ctx.lineWidth = 1; | |
| for (let g = 0; g <= 4; g++) { | |
| const y = padT + cH * (g / 4); | |
| ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + cW, y); ctx.stroke(); | |
| ctx.fillStyle = 'rgba(148,163,184,0.5)'; | |
| ctx.font = '9px Outfit'; ctx.textAlign = 'right'; | |
| ctx.fillText((maxPct * (1 - g / 4)).toFixed(0) + '%', padL - 3, y + 3); | |
| } | |
| // Bars | |
| const bw = (cW / blocks.length) * 0.65; | |
| blocks.forEach((b, i) => { | |
| const x = padL + (i / blocks.length) * cW + ((cW / blocks.length) - bw) / 2; | |
| const bh = (b.pct / maxPct) * cH; | |
| const y = padT + cH - bh; | |
| const grad = ctx.createLinearGradient(x, y, x, y + bh); | |
| grad.addColorStop(0, 'rgba(139,92,246,0.9)'); | |
| grad.addColorStop(1, 'rgba(59,130,246,0.5)'); | |
| ctx.fillStyle = grad; | |
| ctx.beginPath(); | |
| ctx.roundRect ? ctx.roundRect(x, y, bw, bh, 3) : ctx.rect(x, y, bw, bh); | |
| ctx.fill(); | |
| // value label on bar | |
| ctx.fillStyle = 'rgba(196,181,253,0.9)'; | |
| ctx.font = '9px Outfit'; ctx.textAlign = 'center'; | |
| if (bh > 14) ctx.fillText(b.pct.toFixed(1) + '%', x + bw / 2, y + 11); | |
| // x label | |
| ctx.fillStyle = 'rgba(148,163,184,0.5)'; | |
| ctx.font = '8px Outfit'; | |
| ctx.save(); ctx.translate(x + bw / 2, H - padB + 8); | |
| ctx.rotate(-Math.PI / 4); ctx.textAlign = 'right'; | |
| ctx.fillText(b.label, 0, 0); ctx.restore(); | |
| }); | |
| } | |
| // ─── Table ──────────────────────────────────────────────────────────────── | |
| function setFilter(min, btn) { | |
| filterMin = min; | |
| document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active')); | |
| btn.classList.add('active'); | |
| filtered = min === 'all' ? allResults : allResults.filter(r => r.hits >= min); | |
| currentPage = 1; | |
| renderTable(); | |
| } | |
| function renderTable() { | |
| const total = filtered.length; | |
| const start = (currentPage - 1) * PAGE_SIZE; | |
| const page = filtered.slice(start, start + PAGE_SIZE); | |
| document.getElementById('btResultCount').textContent = | |
| filterMin === 'all' ? `共 ${total} 期` : `篩選出 ${total} 期`; | |
| const tbody = document.getElementById('btBody'); | |
| if (page.length === 0) { | |
| tbody.innerHTML = '<tr><td colspan="6" class="loading-row">🔍 無符合資料</td></tr>'; | |
| } else { | |
| tbody.innerHTML = page.map(r => { | |
| const actualSet = new Set(r.actual); | |
| // Use predictedWithScores (score-sorted) if available, else fall back | |
| const predsRanked = r.predictedWithScores | |
| ? [...r.predictedWithScores] // already score-desc from backtest | |
| : r.predicted.map(n => ({ num: n, score: null })); | |
| const predNums = predsRanked.map(({ num, score }) => { | |
| const isHit = actualSet.has(num); | |
| const scoreLabel = score !== null | |
| ? `<div style="font-size:0.6rem;color:${isHit ? 'rgba(255,200,100,0.9)' : 'rgba(148,163,184,0.6)'};margin-top:2px;line-height:1">${score}期</div>` | |
| : ''; | |
| return `<span style="display:inline-flex;flex-direction:column;align-items:center;margin:1px"> | |
| <span class="num-mini ${isHit ? 'hit' : ''}">${num}</span> | |
| ${scoreLabel} | |
| </span>`; | |
| }).join(''); | |
| const actNums = r.actual.map(n => | |
| `<span class="num-mini ${r.predicted.includes(n) ? 'hit' : ''}">${n}</span>` | |
| ).join(' '); | |
| const hitNums = r.predicted.filter(p => actualSet.has(p)); | |
| const hitStr = hitNums.length > 0 | |
| ? hitNums.map(n => `<span class="num-mini hit">${n}</span>`).join(' ') | |
| : '<span style="color:var(--text-muted)">—</span>'; | |
| return `<tr class="fade-in"> | |
| <td class="draw-idx">${r.period}</td> | |
| <td class="draw-date">${r.date}</td> | |
| <td style="vertical-align:top;padding-top:6px">${predNums}</td> | |
| <td>${actNums}</td> | |
| <td><span class="hit-badge hb-${r.hits}">${r.hits}/5</span></td> | |
| <td>${hitStr}</td> | |
| </tr>`; | |
| }).join(''); | |
| } | |
| // pagination | |
| const totalPages = Math.ceil(total / PAGE_SIZE); | |
| const pag = document.getElementById('btPagination'); | |
| if (totalPages <= 1) { pag.innerHTML = ''; return; } | |
| const btns = []; | |
| btns.push(`<button class="page-btn" onclick="changePage(${currentPage-1})" ${currentPage===1?'disabled':''}>‹</button>`); | |
| 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) btns.push(`<span class="page-btn" style="opacity:0.4;pointer-events:none">…</span>`); | |
| btns.push(`<button class="page-btn ${p===currentPage?'active':''}" onclick="changePage(${p})">${p}</button>`); | |
| prev = p; | |
| } | |
| btns.push(`<button class="page-btn" onclick="changePage(${currentPage+1})" ${currentPage===totalPages?'disabled':''}>›</button>`); | |
| pag.innerHTML = btns.join(''); | |
| } | |
| function changePage(p) { | |
| const totalPages = Math.ceil(filtered.length / PAGE_SIZE); | |
| if (p < 1 || p > totalPages) return; | |
| currentPage = p; | |
| renderTable(); | |
| document.querySelector('.history-section, .section:last-of-type')?.scrollIntoView?.({ behavior: 'smooth', block: 'start' }); | |
| } | |
| // ─── BG Particles ───────────────────────────────────────────────────────── | |
| function initParticles() { | |
| const c = document.getElementById('bgParticles'); | |
| for (let i = 0; i < 15; i++) { | |
| const p = document.createElement('div'); | |
| p.style.cssText = `position:absolute;width:${2+Math.random()*2}px;height:${2+Math.random()*2}px;border-radius:50%;background:rgba(139,92,246,${0.15+Math.random()*0.2});left:${Math.random()*100}%;top:${Math.random()*100}%;animation:floatParticle ${6+Math.random()*8}s ease-in-out ${Math.random()*4}s infinite alternate;`; | |
| c.appendChild(p); | |
| } | |
| const s = document.createElement('style'); | |
| s.textContent = `@keyframes floatParticle{from{transform:translateY(0) translateX(0);opacity:.3}to{transform:translateY(-30px) translateX(20px);opacity:.8}}`; | |
| document.head.appendChild(s); | |
| } | |
| let rTimer; | |
| window.addEventListener('resize', () => { clearTimeout(rTimer); rTimer = setTimeout(() => { if (allResults.length) { renderTrendChart([...allResults].reverse()); } }, 250); }); | |
| initParticles(); | |
| loadData(); | |
| </script> | |
| </body> | |
| </html> | |