ML_LSTM_BTC / static /script.js
dunkindonuts123
fixed tensorflow
fb6547c
Raw
History Blame Contribute Delete
27.2 kB
// ═══════════════════════════════════════════════════════════════
// 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 = '<span>⏳</span> 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();