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

// /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);
});
})();