// /home/def/monitor/frontend/app.js function el(tag, cls) { const n = document.createElement(tag); if (cls) n.className = cls; return n; } /* ---------------- Theme toggle (checkbox-driven) ---------------- */ function applyTheme(theme) { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("theme", theme); const toggle = document.getElementById("themeToggle"); if (toggle) toggle.checked = (theme === "light"); } function toggleThemeFromCheckbox() { const toggle = document.getElementById("themeToggle"); const wantsLight = !!toggle?.checked; applyTheme(wantsLight ? "light" : "dark"); } (function initTheme() { const saved = localStorage.getItem("theme") || "dark"; applyTheme(saved); window.addEventListener("DOMContentLoaded", () => { const toggle = document.getElementById("themeToggle"); if (toggle) { toggle.addEventListener("change", toggleThemeFromCheckbox); toggle.checked = (document.documentElement.getAttribute("data-theme") === "light"); } }); })(); /* ---------------- Icons ---------------- */ function iconText(kind) { const map = { "flag-ca": "πŸ‡¨πŸ‡¦", "flag-us": "πŸ‡ΊπŸ‡Έ", "flag-eu": "πŸ‡ͺπŸ‡Ί", "btc": "β‚Ώ", "eth": "Ξ", "eti": "ETI", "egaz": "EGAZ", "etho": "ETHO", "cat": "CAT" }; return map[kind] || "β€’"; } /* ---------------- Formatting ---------------- */ function fmtNumber(v) { if (v == null || !isFinite(v)) return "β€”"; // match backend "display" style but usable for tooltip if (v >= 1000) return v.toFixed(0); if (v >= 1) return v.toFixed(4); return v.toFixed(6); } function fmtDate(ms) { try { const d = new Date(ms); // Nice short format, local timezone return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "2-digit" }); } catch { return String(ms); } } /* ---------------- Tooltip (singleton) ---------------- */ let tipEl = null; function ensureTip() { if (tipEl) return tipEl; tipEl = el("div", "spark-tip"); const date = el("div", "spark-tip-date"); const val = el("div", "spark-tip-val"); tipEl.appendChild(date); tipEl.appendChild(val); document.body.appendChild(tipEl); return tipEl; } function showTip(x, y, dateText, valueText, vsText) { const t = ensureTip(); t.children[0].textContent = dateText; // Value line: "123.45 USD" t.children[1].innerHTML = ""; const v = document.createElement("span"); v.textContent = valueText + " "; const s = document.createElement("span"); s.className = "spark-tip-vs"; s.textContent = vsText || ""; t.children[1].appendChild(v); t.children[1].appendChild(s); t.style.display = "block"; // keep inside viewport const pad = 12; const rect = t.getBoundingClientRect(); let left = x + 14; let top = y + 14; if (left + rect.width + pad > window.innerWidth) left = x - rect.width - 14; if (top + rect.height + pad > window.innerHeight) top = y - rect.height - 14; t.style.left = `${Math.max(pad, left)}px`; t.style.top = `${Math.max(pad, top)}px`; } function hideTip() { if (!tipEl) return; tipEl.style.display = "none"; } /* ---------------- Sparkline ---------------- */ function drawSpark(canvas, points, hoverIndex = -1) { const ctx = canvas.getContext("2d"); // HiDPI aware sizing const cssW = canvas.clientWidth || 96; const cssH = canvas.clientHeight || 26; const dpr = window.devicePixelRatio || 1; canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(cssH * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const w = cssW; const h = cssH; ctx.clearRect(0, 0, w, h); if (!points || points.length < 2) { ctx.globalAlpha = 0.25; ctx.beginPath(); ctx.moveTo(10, h / 2); ctx.lineTo(w - 10, h / 2); ctx.stroke(); ctx.globalAlpha = 1; return; } const min = Math.min(...points); const max = Math.max(...points); const pad = 6; const scaleY = (v) => { if (max === min) return h / 2; const t = (v - min) / (max - min); return (h - pad) - t * (h - pad * 2); }; const scaleX = (i) => { const t = i / (points.length - 1); return pad + t * (w - pad * 2); }; // line ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.lineCap = "round"; ctx.strokeStyle = getComputedStyle(canvas).color || "#9ab0d1"; ctx.beginPath(); ctx.moveTo(scaleX(0), scaleY(points[0])); for (let i = 1; i < points.length; i++) { ctx.lineTo(scaleX(i), scaleY(points[i])); } ctx.stroke(); // hover dot if (hoverIndex >= 0 && hoverIndex < points.length) { const x = scaleX(hoverIndex); const y = scaleY(points[hoverIndex]); ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("--toggle-on").trim() || "#2f6fff"; ctx.beginPath(); ctx.arc(x, y, 3.2, 0, Math.PI * 2); ctx.fill(); } } function attachSparkHover(canvas, line) { // Prefer spark_points (t/v pairs) for tooltip, fallback to spark values only const sp = Array.isArray(line.spark_points) ? line.spark_points : null; const points = Array.isArray(line.spark) ? line.spark : []; // initial draw drawSpark(canvas, points, -1); let lastHover = -1; function getHoverIndex(clientX) { if (!points || points.length < 2) return -1; const rect = canvas.getBoundingClientRect(); const x = clientX - rect.left; const pad = 6; const usableW = rect.width - pad * 2; if (usableW <= 0) return -1; let t = (x - pad) / usableW; t = Math.max(0, Math.min(1, t)); const idx = Math.round(t * (points.length - 1)); return idx; } function onMove(ev) { const idx = getHoverIndex(ev.clientX); if (idx === -1) return; if (idx !== lastHover) { lastHover = idx; drawSpark(canvas, points, idx); } // Tooltip content let when = ""; let val = points[idx]; if (sp && sp[idx] && typeof sp[idx].t === "number") { when = fmtDate(sp[idx].t); if (typeof sp[idx].v === "number") val = sp[idx].v; } else { // fallback: approximate day label when = `Day ${idx + 1}`; } showTip(ev.clientX, ev.clientY, when, fmtNumber(val), line.vs || ""); } function onLeave() { lastHover = -1; hideTip(); drawSpark(canvas, points, -1); } canvas.addEventListener("mousemove", onMove); canvas.addEventListener("mouseleave", onLeave); // store cleanup hooks if you ever re-render frequently canvas._sparkCleanup = () => { canvas.removeEventListener("mousemove", onMove); canvas.removeEventListener("mouseleave", onLeave); }; } /* ---------------- DOM builders ---------------- */ function makeRow(line) { const row = el("div", "row"); const left = el("div", "left"); const ic = el("div", "ic"); ic.textContent = iconText(line.icon); const mid = el("div", "mid"); const sym = el("div", "val"); sym.textContent = line.key || line.symbol || ""; const name = el("div", "subline"); name.textContent = line.name || ""; left.appendChild(ic); mid.appendChild(sym); mid.appendChild(name); left.appendChild(mid); const center = el("div", "mid"); center.style.textAlign = "right"; const value = el("div", "val"); value.textContent = line.display != null ? String(line.display) : fmtNumber(line.value); const vs = el("div", "subline"); vs.textContent = line.vs ? `in ${line.vs}` : ""; center.appendChild(value); center.appendChild(vs); const right = el("div", "right"); const spark = document.createElement("canvas"); spark.className = "spark"; right.appendChild(spark); row.appendChild(left); row.appendChild(center); row.appendChild(right); // Spark render + tooltip attachSparkHover(spark, line); return row; } function setStatus(ok, warnings, text) { const s = document.getElementById("status"); if (!s) return; if (text) { s.textContent = text; } else { s.textContent = ok ? (warnings ? "Data loaded (with warnings)" : "OK") : "ERROR"; } } function setCycle(progress) { const c = document.getElementById("cycle"); if (!c) return; if (!progress || typeof progress !== "object") { c.textContent = ""; return; } const total = progress.total ?? ""; const done = progress.done ?? ""; const cycle = progress.cycle ?? ""; const cur = progress.current ? ` β€’ updating ${progress.current}` : ""; if (total !== "" && done !== "") { c.textContent = `updated ${done}/${total} this cycle=${cycle}${cur}`; } else { c.textContent = ""; } } /* ---------------- Main render ---------------- */ async function refresh() { const root = document.getElementById("root"); if (!root) return; let data; try { const res = await fetch("/api/lines", { cache: "no-store" }); data = await res.json(); } catch (e) { setStatus(false, false, "Fetch failed"); root.innerHTML = ""; const m = el("div", "subline"); m.textContent = "Failed to load /api/lines"; root.appendChild(m); return; } const ok = !!data.ok; const warnings = Array.isArray(data.errors) && data.errors.length > 0; setStatus(ok, warnings); setCycle(data.progress); // Clear + rebuild root.innerHTML = ""; // Currency section const fiatTitle = el("div", "section-title"); fiatTitle.textContent = "Currency"; root.appendChild(fiatTitle); const fiatWrap = el("div", "rows"); const fiat = Array.isArray(data.fiat) ? data.fiat : []; for (const line of fiat) fiatWrap.appendChild(makeRow(line)); root.appendChild(fiatWrap); // Separator const sep = el("div", "sep"); root.appendChild(sep); // Crypto section const cryptoTitle = el("div", "section-title"); cryptoTitle.textContent = "Cryptocurrency"; root.appendChild(cryptoTitle); const cryptoWrap = el("div", "rows"); const crypto = Array.isArray(data.crypto) ? data.crypto : []; for (const line of crypto) cryptoWrap.appendChild(makeRow(line)); root.appendChild(cryptoWrap); // If errors exist, keep them out of the UI clutter; status already says warnings. } (function boot() { window.addEventListener("DOMContentLoaded", () => { refresh(); // keep it light: refresh view every 10s; backend rotates pricing independently setInterval(refresh, 10000); }); })();