|
|
|
@ -53,7 +53,6 @@ function iconText(kind) { |
|
|
|
/* ---------------- Formatting ---------------- */ |
|
|
|
/* ---------------- Formatting ---------------- */ |
|
|
|
function fmtNumber(v) { |
|
|
|
function fmtNumber(v) { |
|
|
|
if (v == null || !isFinite(v)) return "—"; |
|
|
|
if (v == null || !isFinite(v)) return "—"; |
|
|
|
// match backend "display" style but usable for tooltip
|
|
|
|
|
|
|
|
if (v >= 1000) return v.toFixed(0); |
|
|
|
if (v >= 1000) return v.toFixed(0); |
|
|
|
if (v >= 1) return v.toFixed(4); |
|
|
|
if (v >= 1) return v.toFixed(4); |
|
|
|
return v.toFixed(6); |
|
|
|
return v.toFixed(6); |
|
|
|
@ -62,13 +61,31 @@ function fmtNumber(v) { |
|
|
|
function fmtDate(ms) { |
|
|
|
function fmtDate(ms) { |
|
|
|
try { |
|
|
|
try { |
|
|
|
const d = new Date(ms); |
|
|
|
const d = new Date(ms); |
|
|
|
// Nice short format, local timezone
|
|
|
|
|
|
|
|
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "2-digit" }); |
|
|
|
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "2-digit" }); |
|
|
|
} catch { |
|
|
|
} catch { |
|
|
|
return String(ms); |
|
|
|
return String(ms); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function fmtDateTime(iso) { |
|
|
|
|
|
|
|
if (!iso) return "—"; |
|
|
|
|
|
|
|
const d = new Date(iso); |
|
|
|
|
|
|
|
if (Number.isNaN(d.getTime())) return "—"; |
|
|
|
|
|
|
|
return d.toLocaleString(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function ageTextFromIso(iso) { |
|
|
|
|
|
|
|
if (!iso) return "unknown"; |
|
|
|
|
|
|
|
const ts = new Date(iso).getTime(); |
|
|
|
|
|
|
|
if (Number.isNaN(ts)) return "unknown"; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000)); |
|
|
|
|
|
|
|
if (s < 60) return `${s}s ago`; |
|
|
|
|
|
|
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`; |
|
|
|
|
|
|
|
if (s < 86400) return `${Math.floor(s / 3600)}h ago`; |
|
|
|
|
|
|
|
return `${Math.floor(s / 86400)}d ago`; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* ---------------- Tooltip (singleton) ---------------- */ |
|
|
|
/* ---------------- Tooltip (singleton) ---------------- */ |
|
|
|
let tipEl = null; |
|
|
|
let tipEl = null; |
|
|
|
|
|
|
|
|
|
|
|
@ -87,7 +104,6 @@ function showTip(x, y, dateText, valueText, vsText) { |
|
|
|
const t = ensureTip(); |
|
|
|
const t = ensureTip(); |
|
|
|
t.children[0].textContent = dateText; |
|
|
|
t.children[0].textContent = dateText; |
|
|
|
|
|
|
|
|
|
|
|
// Value line: "123.45 USD"
|
|
|
|
|
|
|
|
t.children[1].innerHTML = ""; |
|
|
|
t.children[1].innerHTML = ""; |
|
|
|
const v = document.createElement("span"); |
|
|
|
const v = document.createElement("span"); |
|
|
|
v.textContent = valueText + " "; |
|
|
|
v.textContent = valueText + " "; |
|
|
|
@ -99,7 +115,6 @@ function showTip(x, y, dateText, valueText, vsText) { |
|
|
|
|
|
|
|
|
|
|
|
t.style.display = "block"; |
|
|
|
t.style.display = "block"; |
|
|
|
|
|
|
|
|
|
|
|
// keep inside viewport
|
|
|
|
|
|
|
|
const pad = 12; |
|
|
|
const pad = 12; |
|
|
|
const rect = t.getBoundingClientRect(); |
|
|
|
const rect = t.getBoundingClientRect(); |
|
|
|
let left = x + 14; |
|
|
|
let left = x + 14; |
|
|
|
@ -121,7 +136,6 @@ function hideTip() { |
|
|
|
function drawSpark(canvas, points, hoverIndex = -1) { |
|
|
|
function drawSpark(canvas, points, hoverIndex = -1) { |
|
|
|
const ctx = canvas.getContext("2d"); |
|
|
|
const ctx = canvas.getContext("2d"); |
|
|
|
|
|
|
|
|
|
|
|
// HiDPI aware sizing
|
|
|
|
|
|
|
|
const cssW = canvas.clientWidth || 96; |
|
|
|
const cssW = canvas.clientWidth || 96; |
|
|
|
const cssH = canvas.clientHeight || 26; |
|
|
|
const cssH = canvas.clientHeight || 26; |
|
|
|
const dpr = window.devicePixelRatio || 1; |
|
|
|
const dpr = window.devicePixelRatio || 1; |
|
|
|
@ -157,7 +171,6 @@ function drawSpark(canvas, points, hoverIndex = -1) { |
|
|
|
return pad + t * (w - pad * 2); |
|
|
|
return pad + t * (w - pad * 2); |
|
|
|
}; |
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
// line
|
|
|
|
|
|
|
|
ctx.lineWidth = 2; |
|
|
|
ctx.lineWidth = 2; |
|
|
|
ctx.lineJoin = "round"; |
|
|
|
ctx.lineJoin = "round"; |
|
|
|
ctx.lineCap = "round"; |
|
|
|
ctx.lineCap = "round"; |
|
|
|
@ -170,7 +183,6 @@ function drawSpark(canvas, points, hoverIndex = -1) { |
|
|
|
} |
|
|
|
} |
|
|
|
ctx.stroke(); |
|
|
|
ctx.stroke(); |
|
|
|
|
|
|
|
|
|
|
|
// hover dot
|
|
|
|
|
|
|
|
if (hoverIndex >= 0 && hoverIndex < points.length) { |
|
|
|
if (hoverIndex >= 0 && hoverIndex < points.length) { |
|
|
|
const x = scaleX(hoverIndex); |
|
|
|
const x = scaleX(hoverIndex); |
|
|
|
const y = scaleY(points[hoverIndex]); |
|
|
|
const y = scaleY(points[hoverIndex]); |
|
|
|
@ -183,11 +195,9 @@ function drawSpark(canvas, points, hoverIndex = -1) { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function attachSparkHover(canvas, line) { |
|
|
|
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 sp = Array.isArray(line.spark_points) ? line.spark_points : null; |
|
|
|
const points = Array.isArray(line.spark) ? line.spark : []; |
|
|
|
const points = Array.isArray(line.spark) ? line.spark : []; |
|
|
|
|
|
|
|
|
|
|
|
// initial draw
|
|
|
|
|
|
|
|
drawSpark(canvas, points, -1); |
|
|
|
drawSpark(canvas, points, -1); |
|
|
|
|
|
|
|
|
|
|
|
let lastHover = -1; |
|
|
|
let lastHover = -1; |
|
|
|
@ -204,8 +214,7 @@ function attachSparkHover(canvas, line) { |
|
|
|
|
|
|
|
|
|
|
|
let t = (x - pad) / usableW; |
|
|
|
let t = (x - pad) / usableW; |
|
|
|
t = Math.max(0, Math.min(1, t)); |
|
|
|
t = Math.max(0, Math.min(1, t)); |
|
|
|
const idx = Math.round(t * (points.length - 1)); |
|
|
|
return Math.round(t * (points.length - 1)); |
|
|
|
return idx; |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function onMove(ev) { |
|
|
|
function onMove(ev) { |
|
|
|
@ -217,14 +226,12 @@ function attachSparkHover(canvas, line) { |
|
|
|
drawSpark(canvas, points, idx); |
|
|
|
drawSpark(canvas, points, idx); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Tooltip content
|
|
|
|
|
|
|
|
let when = ""; |
|
|
|
let when = ""; |
|
|
|
let val = points[idx]; |
|
|
|
let val = points[idx]; |
|
|
|
if (sp && sp[idx] && typeof sp[idx].t === "number") { |
|
|
|
if (sp && sp[idx] && typeof sp[idx].t === "number") { |
|
|
|
when = fmtDate(sp[idx].t); |
|
|
|
when = fmtDate(sp[idx].t); |
|
|
|
if (typeof sp[idx].v === "number") val = sp[idx].v; |
|
|
|
if (typeof sp[idx].v === "number") val = sp[idx].v; |
|
|
|
} else { |
|
|
|
} else { |
|
|
|
// fallback: approximate day label
|
|
|
|
|
|
|
|
when = `Day ${idx + 1}`; |
|
|
|
when = `Day ${idx + 1}`; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@ -240,7 +247,6 @@ function attachSparkHover(canvas, line) { |
|
|
|
canvas.addEventListener("mousemove", onMove); |
|
|
|
canvas.addEventListener("mousemove", onMove); |
|
|
|
canvas.addEventListener("mouseleave", onLeave); |
|
|
|
canvas.addEventListener("mouseleave", onLeave); |
|
|
|
|
|
|
|
|
|
|
|
// store cleanup hooks if you ever re-render frequently
|
|
|
|
|
|
|
|
canvas._sparkCleanup = () => { |
|
|
|
canvas._sparkCleanup = () => { |
|
|
|
canvas.removeEventListener("mousemove", onMove); |
|
|
|
canvas.removeEventListener("mousemove", onMove); |
|
|
|
canvas.removeEventListener("mouseleave", onLeave); |
|
|
|
canvas.removeEventListener("mouseleave", onLeave); |
|
|
|
@ -285,12 +291,100 @@ function makeRow(line) { |
|
|
|
row.appendChild(center); |
|
|
|
row.appendChild(center); |
|
|
|
row.appendChild(right); |
|
|
|
row.appendChild(right); |
|
|
|
|
|
|
|
|
|
|
|
// Spark render + tooltip
|
|
|
|
|
|
|
|
attachSparkHover(spark, line); |
|
|
|
attachSparkHover(spark, line); |
|
|
|
|
|
|
|
|
|
|
|
return row; |
|
|
|
return row; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function makeOracleAssetRow(asset) { |
|
|
|
|
|
|
|
const row = el("div", "oracle-asset-row"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const left = el("div", "oracle-asset-left"); |
|
|
|
|
|
|
|
const title = el("div", "oracle-asset-key"); |
|
|
|
|
|
|
|
title.textContent = `${asset.symbol} • ${asset.chain}`; |
|
|
|
|
|
|
|
const sub = el("div", "oracle-asset-sub"); |
|
|
|
|
|
|
|
const source = asset.source || "—"; |
|
|
|
|
|
|
|
const sourceStatus = asset.source_status || "—"; |
|
|
|
|
|
|
|
const freshness = asset.freshness || "unknown"; |
|
|
|
|
|
|
|
sub.textContent = `source: ${source} (${sourceStatus}) • ${freshness}`; |
|
|
|
|
|
|
|
left.appendChild(title); |
|
|
|
|
|
|
|
left.appendChild(sub); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const right = el("div", "oracle-asset-right"); |
|
|
|
|
|
|
|
const badges = el("div", "oracle-badges"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const freshBadge = el("span", `oracle-badge ${asset.stale ? "badge-stale" : "badge-fresh"}`); |
|
|
|
|
|
|
|
freshBadge.textContent = asset.stale ? "stale" : "fresh"; |
|
|
|
|
|
|
|
badges.appendChild(freshBadge); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (asset.billing_enabled) { |
|
|
|
|
|
|
|
const billingBadge = el("span", "oracle-badge badge-billing"); |
|
|
|
|
|
|
|
billingBadge.textContent = "billing"; |
|
|
|
|
|
|
|
badges.appendChild(billingBadge); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
right.appendChild(badges); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const price = el("div", "oracle-price"); |
|
|
|
|
|
|
|
if (asset.price_cad != null && isFinite(asset.price_cad)) { |
|
|
|
|
|
|
|
price.textContent = `${fmtNumber(asset.price_cad)} CAD`; |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
price.textContent = "—"; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
right.appendChild(price); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
row.appendChild(left); |
|
|
|
|
|
|
|
row.appendChild(right); |
|
|
|
|
|
|
|
return row; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function makeOraclePanel(pricesData) { |
|
|
|
|
|
|
|
const wrap = el("div", "oracle-panel"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const top = el("div", "oracle-top"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const left = el("div"); |
|
|
|
|
|
|
|
const title = el("div", "section-title"); |
|
|
|
|
|
|
|
title.textContent = "Oracle Status"; |
|
|
|
|
|
|
|
title.style.margin = "0 0 8px 0"; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const updated = el("div", "oracle-updated"); |
|
|
|
|
|
|
|
updated.textContent = `Last update: ${fmtDateTime(pricesData.updated_at)} • ${ageTextFromIso(pricesData.updated_at)}`; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
left.appendChild(title); |
|
|
|
|
|
|
|
left.appendChild(updated); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const right = el("div", "oracle-summary"); |
|
|
|
|
|
|
|
const overall = el("span", `oracle-badge ${pricesData.status === "fresh" ? "badge-fresh" : "badge-stale"}`); |
|
|
|
|
|
|
|
overall.textContent = pricesData.status || "unknown"; |
|
|
|
|
|
|
|
right.appendChild(overall); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
top.appendChild(left); |
|
|
|
|
|
|
|
top.appendChild(right); |
|
|
|
|
|
|
|
wrap.appendChild(top); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const assets = Object.values(pricesData.assets || {}) |
|
|
|
|
|
|
|
.sort((a, b) => (a.quote_priority ?? 9999) - (b.quote_priority ?? 9999)); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const billingEnabled = assets.filter(a => a.billing_enabled).length; |
|
|
|
|
|
|
|
const freshCount = assets.filter(a => !a.stale).length; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const meta = el("div", "oracle-meta"); |
|
|
|
|
|
|
|
meta.innerHTML = ` |
|
|
|
|
|
|
|
<div class="oracle-meta-item"><span class="oracle-meta-label">Billing assets</span><span class="oracle-meta-value">${billingEnabled}</span></div> |
|
|
|
|
|
|
|
<div class="oracle-meta-item"><span class="oracle-meta-label">Fresh assets</span><span class="oracle-meta-value">${freshCount}/${assets.length}</span></div> |
|
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
wrap.appendChild(meta); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const list = el("div", "oracle-assets"); |
|
|
|
|
|
|
|
for (const asset of assets) { |
|
|
|
|
|
|
|
list.appendChild(makeOracleAssetRow(asset)); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
wrap.appendChild(list); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return wrap; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function setStatus(ok, warnings, text) { |
|
|
|
function setStatus(ok, warnings, text) { |
|
|
|
const s = document.getElementById("status"); |
|
|
|
const s = document.getElementById("status"); |
|
|
|
if (!s) return; |
|
|
|
if (!s) return; |
|
|
|
@ -327,59 +421,66 @@ async function refresh() { |
|
|
|
const root = document.getElementById("root"); |
|
|
|
const root = document.getElementById("root"); |
|
|
|
if (!root) return; |
|
|
|
if (!root) return; |
|
|
|
|
|
|
|
|
|
|
|
let data; |
|
|
|
let linesData; |
|
|
|
|
|
|
|
let oraclePricesData; |
|
|
|
|
|
|
|
|
|
|
|
try { |
|
|
|
try { |
|
|
|
const res = await fetch("/api/lines", { cache: "no-store" }); |
|
|
|
const [linesRes, oracleRes] = await Promise.all([ |
|
|
|
data = await res.json(); |
|
|
|
fetch("/api/lines", { cache: "no-store" }), |
|
|
|
|
|
|
|
fetch("/api/oracle/prices", { cache: "no-store" }) |
|
|
|
|
|
|
|
]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
linesData = await linesRes.json(); |
|
|
|
|
|
|
|
oraclePricesData = await oracleRes.json(); |
|
|
|
} catch (e) { |
|
|
|
} catch (e) { |
|
|
|
setStatus(false, false, "Fetch failed"); |
|
|
|
setStatus(false, false, "Fetch failed"); |
|
|
|
root.innerHTML = ""; |
|
|
|
root.innerHTML = ""; |
|
|
|
const m = el("div", "subline"); |
|
|
|
const m = el("div", "subline"); |
|
|
|
m.textContent = "Failed to load /api/lines"; |
|
|
|
m.textContent = "Failed to load monitor/oracle data"; |
|
|
|
root.appendChild(m); |
|
|
|
root.appendChild(m); |
|
|
|
return; |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const ok = !!data.ok; |
|
|
|
const ok = !!linesData.ok; |
|
|
|
const warnings = Array.isArray(data.errors) && data.errors.length > 0; |
|
|
|
const warnings = Array.isArray(linesData.errors) && linesData.errors.length > 0; |
|
|
|
|
|
|
|
|
|
|
|
setStatus(ok, warnings); |
|
|
|
setStatus(ok, warnings); |
|
|
|
setCycle(data.progress); |
|
|
|
setCycle(linesData.progress); |
|
|
|
|
|
|
|
|
|
|
|
// Clear + rebuild
|
|
|
|
|
|
|
|
root.innerHTML = ""; |
|
|
|
root.innerHTML = ""; |
|
|
|
|
|
|
|
|
|
|
|
// Currency section
|
|
|
|
if (oraclePricesData && oraclePricesData.assets) { |
|
|
|
|
|
|
|
root.appendChild(makeOraclePanel(oraclePricesData)); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const sep0 = el("div", "sep"); |
|
|
|
|
|
|
|
root.appendChild(sep0); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const fiatTitle = el("div", "section-title"); |
|
|
|
const fiatTitle = el("div", "section-title"); |
|
|
|
fiatTitle.textContent = "Currency"; |
|
|
|
fiatTitle.textContent = "Currency"; |
|
|
|
root.appendChild(fiatTitle); |
|
|
|
root.appendChild(fiatTitle); |
|
|
|
|
|
|
|
|
|
|
|
const fiatWrap = el("div", "rows"); |
|
|
|
const fiatWrap = el("div", "rows"); |
|
|
|
const fiat = Array.isArray(data.fiat) ? data.fiat : []; |
|
|
|
const fiat = Array.isArray(linesData.fiat) ? linesData.fiat : []; |
|
|
|
for (const line of fiat) fiatWrap.appendChild(makeRow(line)); |
|
|
|
for (const line of fiat) fiatWrap.appendChild(makeRow(line)); |
|
|
|
root.appendChild(fiatWrap); |
|
|
|
root.appendChild(fiatWrap); |
|
|
|
|
|
|
|
|
|
|
|
// Separator
|
|
|
|
|
|
|
|
const sep = el("div", "sep"); |
|
|
|
const sep = el("div", "sep"); |
|
|
|
root.appendChild(sep); |
|
|
|
root.appendChild(sep); |
|
|
|
|
|
|
|
|
|
|
|
// Crypto section
|
|
|
|
|
|
|
|
const cryptoTitle = el("div", "section-title"); |
|
|
|
const cryptoTitle = el("div", "section-title"); |
|
|
|
cryptoTitle.textContent = "Cryptocurrency"; |
|
|
|
cryptoTitle.textContent = "Cryptocurrency"; |
|
|
|
root.appendChild(cryptoTitle); |
|
|
|
root.appendChild(cryptoTitle); |
|
|
|
|
|
|
|
|
|
|
|
const cryptoWrap = el("div", "rows"); |
|
|
|
const cryptoWrap = el("div", "rows"); |
|
|
|
const crypto = Array.isArray(data.crypto) ? data.crypto : []; |
|
|
|
const crypto = Array.isArray(linesData.crypto) ? linesData.crypto : []; |
|
|
|
for (const line of crypto) cryptoWrap.appendChild(makeRow(line)); |
|
|
|
for (const line of crypto) cryptoWrap.appendChild(makeRow(line)); |
|
|
|
root.appendChild(cryptoWrap); |
|
|
|
root.appendChild(cryptoWrap); |
|
|
|
|
|
|
|
|
|
|
|
// If errors exist, keep them out of the UI clutter; status already says warnings.
|
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
(function boot() { |
|
|
|
(function boot() { |
|
|
|
window.addEventListener("DOMContentLoaded", () => { |
|
|
|
window.addEventListener("DOMContentLoaded", () => { |
|
|
|
refresh(); |
|
|
|
refresh(); |
|
|
|
// keep it light: refresh view every 10s; backend rotates pricing independently
|
|
|
|
|
|
|
|
setInterval(refresh, 10000); |
|
|
|
setInterval(refresh, 10000); |
|
|
|
}); |
|
|
|
}); |
|
|
|
})(); |
|
|
|
})(); |
|
|
|
|