You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
385 lines
10 KiB
385 lines
10 KiB
// /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); |
|
}); |
|
})();
|
|
|