// ═══════════════════════════════════════════════════════════════ // BitPredict AI — script.js // Dataset : Kaggle novandraanugrah BTC Binance 2018-01-01 → 2026-03-26 // Link : https://www.kaggle.com/datasets/novandraanugrah/ // bitcoin-historical-datasets-2018-2024 // Model : 3-Layer LSTM (100→100→50) | 60-day lookback | 80/20 split // Test set: Aug 2024 → Mar 2026 (602 days) // Metrics : MAPE=2.74% | R²=0.9476 | Accuracy=97.26% // Live : CoinGecko public API (no key needed) | refresh 30s // ═══════════════════════════════════════════════════════════════ // ────────────────────────────────────────────────────────────── // DYNAMIC FORECAST HORIZON — label & hint berubah per type // ────────────────────────────────────────────────────────────── function updateHorizonLabel() { const type = document.getElementById('predType')?.value || 'daily'; const label = document.getElementById('horizonLabel'); const unit = document.getElementById('horizonUnit'); const hint = document.getElementById('horizonHint'); const input = document.getElementById('numDays'); const cfg = { daily: { lbl:'Forecast Horizon (Days)', unit:'days', hint:'Each step = 1 day · range 1–60', max:60, def:7 }, weekly: { lbl:'Forecast Horizon (Weeks)', unit:'weeks', hint:'Each step = 7 days · range 1–8 weeks', max:8, def:4 }, monthly: { lbl:'Forecast Horizon (Months)', unit:'months', hint:'Each step = 30 days · range 1–2 months', max:2, def:1 }, }; const c = cfg[type] || cfg.daily; if (label) label.textContent = c.lbl; if (unit) unit.textContent = c.unit; if (hint) hint.textContent = c.hint; if (input) { input.max = c.max; input.placeholder = 'e.g. ' + c.def; if (parseInt(input.value) > c.max) input.value = c.def; } } // ────────────────────────────────────────────────────────────── // PREDICTION TYPE CARDS — click to select // ────────────────────────────────────────────────────────────── function selectPredType(val) { document.getElementById('predType').value = val; document.querySelectorAll('.ptcard').forEach(c => { c.classList.toggle('ptcard-active', c.dataset.val === val); }); updateHorizonLabel(); } // ────────────────────────────────────────────────────────────── // LIVE DATA — fetched from backend (CoinGecko proxy) // ────────────────────────────────────────────────────────────── async function fetchHistory(days = 60) { const res = await fetch(`/api/history?days=${days}`, { cache: 'no-cache' }); if (!res.ok) throw new Error('HTTP ' + res.status); return res.json(); } // ────────────────────────────────────────────────────────────── // NAVBAR // ────────────────────────────────────────────────────────────── const hamburger = document.getElementById('hamburger'); const navLinks = document.getElementById('navLinks'); hamburger?.addEventListener('click', () => navLinks?.classList.toggle('open')); navLinks?.querySelectorAll('a').forEach(a => a.addEventListener('click', () => navLinks.classList.remove('open')) ); // ────────────────────────────────────────────────────────────── // DARK / LIGHT MODE TOGGLE (Moon 🌙 | Sun ☀️) // ────────────────────────────────────────────────────────────── (function initTheme() { const sw = document.getElementById('themeSwitch'); const body = document.body; const saved = localStorage.getItem('cp-theme') || 'dark'; if (saved === 'light') body.classList.add('light'); sw?.addEventListener('click', () => { const isLight = body.classList.toggle('light'); localStorage.setItem('cp-theme', isLight ? 'light' : 'dark'); }); })(); // ────────────────────────────────────────────────────────────── // SCROLL REVEAL // ────────────────────────────────────────────────────────────── (function initReveal() { const obs = new IntersectionObserver(entries => { entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('revealed'); obs.unobserve(e.target); } }); }, { threshold: 0.10 }); document.querySelectorAll('.reveal').forEach(el => obs.observe(el)); })(); // ────────────────────────────────────────────────────────────── // ACTIVE NAV LINK — highlight saat section aktif atau diklik // ────────────────────────────────────────────────────────────── (function initActiveNav() { const links = document.querySelectorAll('.nav-links a'); const sections = ['home','about','technology','features','forecast','compare']; // includes Prediction=#forecast function setActive(id) { links.forEach(a => { const href = a.getAttribute('href'); if (href === '#' + id) { a.classList.add('nav-active'); } else { a.classList.remove('nav-active'); } }); } // Set active on click immediately links.forEach(a => { a.addEventListener('click', () => { const id = a.getAttribute('href').slice(1); setActive(id); }); }); // Set active on scroll using IntersectionObserver const obs = new IntersectionObserver(entries => { entries.forEach(e => { if (e.isIntersecting) setActive(e.target.id); }); }, { threshold: 0.35 }); sections.forEach(id => { const el = document.getElementById(id); if (el) obs.observe(el); }); // Set home as default setActive('home'); })(); // ────────────────────────────────────────────────────────────── // UTILS // ────────────────────────────────────────────────────────────── const fmt = v => '$' + (+v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const fmtShort = v => v >= 1e12 ? '$' + (v/1e12).toFixed(2) + 'T' : v >= 1e9 ? '$' + (v/1e9).toFixed(2) + 'B' : v >= 1e6 ? '$' + (v/1e6).toFixed(0) + 'M' : fmt(v); const avgArr = arr => arr.reduce((a, b) => a + b, 0) / arr.length; const stdArr = arr => { const m = avgArr(arr); return Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length); }; function sparseLabels(arr, maxTicks = 20) { const step = Math.ceil(arr.length / maxTicks); return arr.map((v, i) => i % step === 0 ? v : ''); } function baseChartOpts() { return { responsive: true, maintainAspectRatio: true, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: false }, tooltip: { backgroundColor: '#1d2132', borderColor: 'rgba(255,255,255,0.09)', borderWidth: 1, titleColor: '#8b90ab', bodyColor: '#eef0f8', titleFont: { family: 'JetBrains Mono', size: 11 }, bodyFont: { family: 'Sora', size: 12 }, padding: 14, callbacks: { label: ctx => ` ${ctx.dataset.label}: ${fmt(ctx.parsed.y)}` } } }, scales: { x: { ticks: { color: '#484d68', font: { family: 'JetBrains Mono', size: 10 }, maxRotation: 45, autoSkip: false }, grid: { color: 'rgba(255,255,255,0.04)' }, border: { color: 'transparent' } }, y: { ticks: { color: '#484d68', font: { family: 'JetBrains Mono', size: 10 }, callback: v => v >= 1000 ? '$' + (v/1000).toFixed(0) + 'K' : '$' + v }, grid: { color: 'rgba(255,255,255,0.05)' }, border: { color: 'transparent', dash: [3, 4] } } } }; } // ────────────────────────────────────────────────────────────── // 🔴 LIVE PRICE + CHART — CoinGecko Public API // Timeframes: 1D (hourly) | 1W | 1M | 1Y (daily) // Refresh : every 30 seconds automatically // ────────────────────────────────────────────────────────────── let livePrice = null; let chartInst = null; let currentTf = '1D'; let cachedSparkPrices = null; // 7d hourly from /markets let isUpGlobal = true; // ── CoinGecko endpoints ── // NOTE: 15m/1h/4h use free endpoint (returns finest grain available per days param) function tfUrl(tf) { const base = 'https://api.coingecko.com/api/v3/coins/bitcoin/market_chart'; const map = { '15m': base + '?vs_currency=usd&days=1', // ~hourly; finest free tier = 5min per 1 day '1h' : base + '?vs_currency=usd&days=2', // 2 days → hourly granularity '4h' : base + '?vs_currency=usd&days=7', // 7 days → hourly, sample every 4 '1D' : base + '?vs_currency=usd&days=30', // 30 days → daily '1W' : base + '?vs_currency=usd&days=90', // 90 days → daily '1M' : base + '?vs_currency=usd&days=365&interval=daily', '1Y' : base + '?vs_currency=usd&days=365&interval=daily', '4Y' : base + '?vs_currency=usd&days=1461&interval=daily', }; return map[tf] || map['1D']; } // ── For 4h: sample every 4th datapoint from hourly data ── function sampleEvery(arr, n) { return arr.filter((_, i) => i % n === 0); } // ── Fetch & render chart for a given timeframe ── async function fetchAndRenderChart(tf) { currentTf = tf; document.querySelectorAll('.ld-tab').forEach(btn => { btn.classList.toggle('active', btn.dataset.tf === tf); }); try { const res = await fetch(tfUrl(tf), { cache: 'no-cache' }); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); let rawPrices = data.prices.map(p => p[1]); let rawTimes = data.prices.map(p => { const d = new Date(p[0]); switch(tf) { case '15m': return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); case '1h': return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); case '4h': return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); case '1D': return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); case '1W': return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); case '1M': return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); case '1Y': return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); case '4Y': return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); default: return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } }); // 4h: sample every 4th hourly point if (tf === '4h') { rawPrices = sampleEvery(rawPrices, 4); rawTimes = sampleEvery(rawTimes, 4); } const isUp = rawPrices[rawPrices.length-1] >= rawPrices[0]; buildDashChart(rawPrices, rawTimes, isUp); } catch(e) { console.warn('[CryptoPedia] Chart fetch failed, trying /api/history:', e.message); try { const slices = { '15m':1, '1h':2, '4h':7, '1D':30, '1W':90, '1M':365, '1Y':365, '4Y':365 }; const hist = await fetchHistory(slices[tf] || 30); const pts = hist.prices; const labels = hist.dates.map(d => { const dt = new Date(d + 'T00:00:00'); return tf === '15m' || tf === '1h' || tf === '4h' ? dt.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) : dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }); const isUp = pts[pts.length - 1] >= pts[0]; buildDashChart(pts, labels, isUp); } catch (err) { console.warn('[CryptoPedia] History fallback also failed:', err.message); } } } // ── Build / refresh the dashboard chart ── function buildDashChart(prices, labels, isUp) { const canvas = document.getElementById('ldChart'); if (!canvas) return; const ctx = canvas.getContext('2d'); const color = isUp ? '#22c55e' : '#ff6b6b'; if (chartInst) { chartInst.destroy(); chartInst = null; } const g = ctx.createLinearGradient(0, 0, 0, 110); g.addColorStop(0, isUp ? 'rgba(34,197,94,0.22)' : 'rgba(255,107,107,0.22)'); g.addColorStop(1, 'rgba(0,0,0,0)'); // Sparse labels — show max 8 const step = Math.ceil(labels.length / 8); const sparseL = labels.map((l, i) => i % step === 0 ? l : ''); chartInst = new Chart(ctx, { type: 'line', data: { labels: sparseL, datasets: [{ data: prices, borderColor: color, backgroundColor: g, borderWidth: 1.8, pointRadius: 0, tension: 0.3, fill: true }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 500 }, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: false }, tooltip: { backgroundColor: '#1d2132', borderColor: 'rgba(255,255,255,.09)', borderWidth: 1, titleColor: '#8b90ab', bodyColor: '#eef0f8', callbacks: { title: (items) => { // Show actual label (time/date) as tooltip title const lbl = labels[items[0].dataIndex]; return lbl || ''; }, label: ctx => ' BTC: ' + fmt(ctx.parsed.y) } } }, scales: { x: { ticks: { color: '#484d68', font: { size: 9 }, maxRotation: 0 }, grid: { color: 'rgba(255,255,255,0.04)' }, border: { color: 'transparent' } }, y: { ticks: { color: '#484d68', font: { size: 9 }, callback: v => '$' + (v/1000).toFixed(0) + 'K' }, grid: { color: 'rgba(255,255,255,0.05)' }, border: { color: 'transparent' } } } } }); // ── Tampilkan range waktu di bawah chart ── const timeEl = document.getElementById('ldChartTime'); if (timeEl && labels.length > 0) { const first = labels.find(l => l !== ''); const last = [...labels].reverse().find(l => l !== ''); if (first && last && first !== last) { timeEl.textContent = first + ' → ' + last; } else { timeEl.textContent = ''; } } } // ── Tab click handlers ── document.querySelectorAll('.ld-tab').forEach(btn => { btn.addEventListener('click', () => fetchAndRenderChart(btn.dataset.tf)); }); // ── Main live price fetch ── async function fetchLivePrice() { try { const res = await fetch('/api/live', { cache: 'no-cache' }); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); livePrice = data.price; const chg = data.change; isUpGlobal = chg >= 0; // Big price display const ldP = document.getElementById('ldPrice'); if (ldP) { ldP.textContent = fmt(livePrice); ldP.className = 'ld-price ' + (isUpGlobal ? 'up' : 'down'); } // Pill const pill = document.getElementById('ldPill'); if (pill) { pill.className = 'ld-pill ' + (isUpGlobal ? 'up' : 'down'); document.getElementById('ldArrow').textContent = isUpGlobal ? '▲' : '▼'; document.getElementById('ldPct').textContent = Math.abs(chg).toFixed(2) + '%'; } // Meta const setEl = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; setEl('ldHigh', fmt(data.high)); setEl('ldLow', fmt(data.low)); setEl('ldMcap', fmtShort(data.mcap)); setEl('ldVol', fmtShort(data.volume)); setEl('ldRank', '#' + data.rank); setEl('ldAth', fmt(data.ath)); // Navbar pill const navP = document.getElementById('navPrice'); const navC = document.getElementById('navChg'); if (navP) navP.textContent = fmt(livePrice); if (navC) { navC.textContent = (isUpGlobal ? '▲' : '▼') + Math.abs(chg).toFixed(2) + '%'; navC.className = 'nlp-chg ' + (isUpGlobal ? 'up' : 'down'); } // Hero stat const hp = document.getElementById('heroPrice'); if (hp) hp.textContent = fmt(livePrice); // Timestamp const upd = document.getElementById('ldUpdated'); if (upd) upd.textContent = new Date().toLocaleTimeString('en-US', { hour12: false }); } catch (err) { console.warn('[CryptoPedia] Live API unavailable, using fallback.', err.message); fallbackTick(); } } function fallbackTick() { if (!livePrice) return; livePrice *= (1 + (Math.random() - 0.499) * 0.0006); ['ldPrice','heroPrice','navPrice'].forEach(id => { const el = document.getElementById(id); if (el) el.textContent = fmt(livePrice); }); const upd = document.getElementById('ldUpdated'); if (upd) upd.textContent = new Date().toLocaleTimeString('en-US', { hour12: false }) + ' (offline)'; } // ── Init: fetch price then chart ── updateHorizonLabel(); fetchLivePrice(); fetchAndRenderChart('15m'); // load default 15m chart setInterval(fetchLivePrice, 30000); setInterval(() => fetchAndRenderChart(currentTf), 60000); // refresh chart every 60s // ────────────────────────────────────────────────────────────── // COMPARE CHART — Actual vs Predicted (live rolling LSTM) // ────────────────────────────────────────────────────────────── async function buildCompareChart() { const canvas = document.getElementById('compareChart'); if (!canvas) return; try { const res = await fetch('/api/compare?days=90', { cache: 'no-cache' }); if (!res.ok) throw new Error('HTTP ' + res.status); const { dates, actual, predicted } = await res.json(); const s = actual[0]; const e = actual[actual.length - 1]; const ch = ((e - s) / s) * 100; const av = avgArr(actual); const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; set('cmpStart', fmt(s)); set('cmpEnd', fmt(e)); set('cmpAvg', fmt(av)); const chEl = document.getElementById('cmpChange'); if (chEl) { chEl.textContent = (ch >= 0 ? '+' : '') + ch.toFixed(2) + '%'; chEl.classList.add(ch >= 0 ? 'pos' : 'neg'); } const ctx = canvas.getContext('2d'); const gA = ctx.createLinearGradient(0, 0, 0, 420); gA.addColorStop(0, 'rgba(240,185,11,0.22)'); gA.addColorStop(1, 'rgba(240,185,11,0)'); const gP = ctx.createLinearGradient(0, 0, 0, 420); gP.addColorStop(0, 'rgba(78,205,196,0.15)'); gP.addColorStop(1, 'rgba(78,205,196,0)'); new Chart(ctx, { type: 'line', data: { labels: sparseLabels(dates, 22), datasets: [ { label: 'Actual Price', data: actual, borderColor: '#F0B90B', backgroundColor: gA, borderWidth: 2, pointRadius: 0, pointHoverRadius: 5, tension: 0.3, fill: true }, { label: 'Predicted Price', data: predicted, borderColor: '#4ecdc4', backgroundColor: gP, borderWidth: 2, borderDash: [5, 4], pointRadius: 0, pointHoverRadius: 5, tension: 0.3, fill: true, spanGaps: false } ] }, options: baseChartOpts() }); } catch (err) { console.warn('[CryptoPedia] Compare chart fetch failed:', err.message); } } // ────────────────────────────────────────────────────────────── // FORECAST GENERATOR // ────────────────────────────────────────────────────────────── let forecastInst = null; document.getElementById('generateBtn')?.addEventListener('click', function () { const btn = this; btn.innerHTML = ' Generating…'; btn.disabled = true; runForecast().finally(() => { btn.innerHTML = 'Generate Prediction'; btn.disabled = false; }); }); async function runForecast() { const rawN = parseInt(document.getElementById('numDays').value) || 7; const pType = document.getElementById('predType')?.value || 'daily'; const mult = pType === 'monthly' ? 30 : pType === 'weekly' ? 7 : 1; const maxAllowed = pType === 'monthly' ? 2 : pType === 'weekly' ? 8 : 60; const n = Math.min(rawN * mult, 60); if (rawN < 1 || rawN > maxAllowed) { alert(`Please enter a value between 1 and ${maxAllowed} ${pType === 'daily' ? 'days' : pType === 'weekly' ? 'weeks' : 'months'}.`); return; } let recentPrices = []; try { const hist = await fetchHistory(60); recentPrices = hist.prices.slice(-60); console.log('[BitPredict] Live 60-day input loaded. Latest close:', recentPrices.at(-1)); } catch (e) { console.warn('[BitPredict] History fetch failed:', e.message); } let fDates, fPrices; try { const res = await fetch('/api/forecast', { method : 'POST', headers: { 'Content-Type': 'application/json' }, body : JSON.stringify({ days: n, prices: recentPrices }) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); fDates = data.dates; fPrices = data.prices; console.log(`[BitPredict] Forecast source: ${data.source}`); } catch (err) { console.warn('[BitPredict] API forecast failed, using local fallback.', err); if (!livePrice || recentPrices.length < 2) { alert('Unable to generate forecast — live price data is unavailable.'); return; } fDates = []; fPrices = []; const base = livePrice; const slice = recentPrices.slice(-30); const mom = (slice[slice.length - 1] - slice[0]) / slice.length; const vol = stdArr(recentPrices.slice(-60)) / base; let p = base; const baseDate = new Date(); for (let i = 1; i <= n; i++) { const d = new Date(baseDate); d.setDate(baseDate.getDate() + i); fDates.push(d.toISOString().slice(0, 10)); p += mom * 0.38 + p * (Math.random() - 0.48) * vol * 1.6; p = Math.max(p, 10_000); fPrices.push(Math.round(p * 100) / 100); } } const sp = fPrices[0], ep = fPrices[fPrices.length - 1]; const ch = ((ep - sp) / sp) * 100; const av = avgArr(fPrices); const lo = Math.min(...fPrices), hi = Math.max(...fPrices); const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; set('predStart', fmt(sp)); set('predEnd', fmt(ep)); set('predAvg', fmt(av)); const chEl = document.getElementById('predChange'); if (chEl) { chEl.textContent = (ch >= 0 ? '+' : '') + ch.toFixed(2) + '%'; chEl.style.color = ch >= 0 ? '#22c55e' : '#ff6b6b'; } ['predSummary', 'forecastChartBox', 'aiPanel'].forEach(id => document.getElementById(id)?.classList.remove('hidden')); if (forecastInst) { forecastInst.destroy(); forecastInst = null; } const ctx2 = document.getElementById('forecastChart').getContext('2d'); const gF = ctx2.createLinearGradient(0, 0, 0, 380); gF.addColorStop(0, 'rgba(240,185,11,0.30)'); gF.addColorStop(1, 'rgba(240,185,11,0)'); forecastInst = new Chart(ctx2, { type: 'line', data: { labels: fDates, datasets: [{ label: 'LSTM Prediction', data: fPrices, borderColor: '#F0B90B', backgroundColor: gF, borderWidth: 2.5, pointRadius: 5, pointBackgroundColor: '#F0B90B', pointBorderColor: '#0d0e12', pointBorderWidth: 2, tension: 0.35, fill: true }] }, options: baseChartOpts() }); // AI text const base = livePrice; const dir = ch < 0 ? 'penurunan' : 'kenaikan'; const pct = Math.abs(ch).toFixed(2); const aiEl = document.getElementById('aiText'); if (aiEl) aiEl.textContent = `Harga Bitcoin diprediksi mengalami ${dir} sebesar ${pct}% dari ${fDates[0]} sampai ${fDates[fDates.length - 1]}, ` + `dengan harga rata-rata ${fmt(av)}. Kisaran prediksi antara ${fmt(lo)} hingga ${fmt(hi)}. ` + `Berdasarkan harga live saat ini (${fmt(base)}) dan volatilitas historis pasar terkini, ` + (ch < -5 ? 'tekanan jual masih dominan — waspadai penurunan lebih lanjut.' : ch > 5 ? 'momentum bullish terdeteksi — potensi kenaikan signifikan.' : 'pasar dalam fase konsolidasi — tunggu konfirmasi arah breakout.'); const rb = document.getElementById('recBadge'); if (rb) { if (ch < -5) { rb.textContent = 'Sell'; rb.className = 'rec-text sell'; } else if (ch > 5) { rb.textContent = 'Buy'; rb.className = 'rec-text buy'; } else { rb.textContent = 'Hold'; rb.className = 'rec-text hold'; } } document.getElementById('predSummary')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } // ────────────────────────────────────────────────────────────── // INIT // ────────────────────────────────────────────────────────────── buildCompareChart();