// Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // Shared front-end: nav, search, formatting, tooltip, bar rows, SVG line/column charts. export const $ = (id) => document.getElementById(id); export const qs = new URLSearchParams(location.search); export async function j(url) { const r = await fetch(url); if (!r.ok) throw new Error(`${r.status} ${url}`); return r.json(); } // ---------- formatting ---------- export const fmtUsd = (n) => { const a = Math.abs(n); if (a >= 1e12) return "$" + (n / 1e12).toFixed(2) + "T"; if (a >= 1e9) return "$" + (n / 1e9).toFixed(2) + "B"; if (a >= 1e6) return "$" + (n / 1e6).toFixed(1) + "M"; if (a >= 1e3) return "$" + (n / 1e3).toFixed(1) + "K"; return "$" + n.toFixed(2); }; export const fmtN = (n) => (n ?? 0).toLocaleString("en-US"); export const fmtQty = (n) => { const a = Math.abs(n); if (a >= 1e9) return (n / 1e9).toFixed(2) + "B"; if (a >= 1e6) return (n / 1e6).toFixed(2) + "M"; if (a >= 1e3) return (n / 1e3).toFixed(1) + "K"; return n.toFixed(a < 1 ? 4 : 2); }; // token categories (stablecoin vs crypto) — loaded once by mountNav export let CATS = {}; export const isStable = (sym) => (CATS[sym] || "stablecoin") !== "crypto"; // row amount: stablecoins read as USD directly; crypto shows units + USD export function fmtAmt(r) { const v = parseFloat(r.value); if (!isStable(r.symbol) && r.usd != null) return `${fmtQty(v)} ${r.symbol} · ${fmtUsd(r.usd)}`; return fmtUsd(r.usd ?? v); } export const short = (s) => (s ? s.slice(0, 8) + "…" + s.slice(-4) : "—"); export const ago = (ts) => { if (!ts) return "—"; const d = Math.max(0, Date.now() / 1000 - ts); return d < 90 ? Math.round(d) + "s" : d < 5400 ? Math.round(d / 60) + "m" : d < 129600 ? Math.round(d / 3600) + "h" : Math.round(d / 86400) + "d"; }; const timeLabel = (t, span) => { const d = new Date(t * 1000); return span > 3 * 86400 ? d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) : d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); }; export const addrLink = (a) => a ? `${short(a)}` : "—"; export const txLink = (chain, h) => `${short(h)}`; // ---------- asset & chain icons ---------- // Real logos from the cryptocurrency-icons CDN where available; everything // else falls back to a brand-colored coin with the ticker's initial — the // sits above the fallback and simply removes itself on 404. const BRAND = { BTC:"#f7931a", WBTC:"#f09242", cbBTC:"#0052ff", ETH:"#627eea", WETH:"#627eea", USDT:"#26a17b", USDC:"#2775ca", DAI:"#f5ac37", USDS:"#f5ac37", USDe:"#24354f", FDUSD:"#01c38d", PYUSD:"#0070e0", RLUSD:"#0085c0", USDG:"#02414c", TUSD:"#1a5aff", USDB:"#fcfc03", LINK:"#2a5ada", UNI:"#ff007a", AAVE:"#9391f7", SHIB:"#ffa409", PEPE:"#4c9641", LDO:"#f69988", CRV:"#870f9e", ONDO:"#0f1728", MKR:"#1aab9b", ENA:"#11314f", ARB:"#12aaff", OP:"#ff0420", BNB:"#f3ba2f", WBNB:"#f3ba2f", CAKE:"#d1884f", AVAX:"#e84142", WAVAX:"#e84142", POL:"#8247e5", WPOL:"#8247e5", SOL:"#9945ff", TRX:"#ff0013", BONK:"#f9a33d", JUP:"#16bee2", WIF:"#a58b6f", }; const CHAIN_BRAND = { ethereum:"#627eea", bitcoin:"#f7931a", tron:"#ff0013", solana:"#9945ff", bsc:"#f3ba2f", polygon:"#8247e5", avalanche:"#e84142", arbitrum:"#12aaff", optimism:"#ff0420", base:"#0052ff", celo:"#fcff52", gnosis:"#04795b", linea:"#121212", scroll:"#eb7106", mantle:"#141414", zksync:"#8c8dfc", blast:"#fcfc03", unichain:"#ff007a", world_chain:"#1e1e1e", sonic:"#0a1e3f", sei:"#8c1d18", hyperevm:"#0f3933", kaia:"#5f7f12", ink:"#7132f5", }; const CDN = "https://cdn.jsdelivr.net/npm/cryptocurrency-icons@0.18.1/svg/color/"; const CDN_SYM = { // package symbol overrides / aliases WETH:"eth", cbBTC:"btc", WBNB:"bnb", WAVAX:"avax", WPOL:"matic", POL:"matic", }; const CHAIN_SYM = { // chain → package symbol (only where a real logo exists) ethereum:"eth", bitcoin:"btc", tron:"trx", solana:"sol", bsc:"bnb", polygon:"matic", avalanche:"avax", }; const NO_CDN = new Set(["USDe","FDUSD","PYUSD","RLUSD","USDG","USDB","PEPE","ONDO", "ENA","ARB","OP","CAKE","BONK","JUP","WIF","LDO","USDS","SHIB"]); const luma = (hex) => { const n = parseInt(hex.slice(1), 16); return (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; }; function coinHtml(text, color, cdnSym, size) { const fg = luma(color) > 0.62 ? "#0b0b0b" : "#fff"; const img = cdnSym ? `` : ""; return `` + `${text[0]}${img}`; } export function coinIcon(sym, size = 16) { const color = BRAND[sym] || "#6b7280"; const cdn = NO_CDN.has(sym) ? null : (CDN_SYM[sym] || sym.toLowerCase()); return coinHtml(sym, color, cdn, size); } export function chainIcon(chain, size = 16) { const color = CHAIN_BRAND[chain] || "#6b7280"; return coinHtml(chain.toUpperCase(), color, CHAIN_SYM[chain] || null, size); } export const chainLink = (c) => `${chainIcon(c)}${c}`; export const tokenLink = (s) => `${coinIcon(s)}${s}`; // ---------- nav ---------- const NAV = [ ["Overview", "/"], ["Tokens", "/token.html"], ["Chains", "/chain.html"], ["Transfers", "/transfers.html"], ["Whales", "/whales.html"], ["Flows", "/flows.html"], ["API", "/api.html"], ["Status", "/status.html"], ]; export function mountNav(active) { const el = document.createElement("div"); el.className = "topnav"; el.innerHTML = ` `; document.body.prepend(el); j("/v1/tokens").then((t) => { for (const [sym, d] of Object.entries(t)) CATS[sym] = d.category; // natives tracked via chains.yaml aren't in /v1/tokens — they're crypto for (const s of ["ETH", "BTC", "BNB", "SOL", "TRX", "AVAX", "POL"]) if (!(s in CATS)) CATS[s] = "crypto"; }).catch(() => {}); const tip = document.createElement("div"); tip.id = "tooltip"; document.body.appendChild(tip); $("searchForm").addEventListener("submit", async (e) => { e.preventDefault(); const q = $("searchInput").value.trim(); if (!q) return; try { const r = await j(`/v1/search?q=${encodeURIComponent(q)}`); if (r.type === "token") location.href = `/token.html?symbol=${r.symbol}`; else if (r.type === "chain") location.href = `/chain.html?chain=${r.chain}`; else if (r.type === "tx") location.href = `/tx.html?hash=${encodeURIComponent(r.hash)}${r.chain ? `&chain=${r.chain}` : ""}`; else if (r.type === "address") location.href = `/address.html?a=${encodeURIComponent(r.address)}`; else alert("No match — try a tx hash, address, token symbol or chain name."); } catch { alert("Search failed."); } }); const f = document.createElement("footer"); f.innerHTML = "coinexplorer · self-hosted stablecoin explorer · free public RPCs only · " + 'API · metrics · not financial advice'; document.body.appendChild(f); } // ---------- tooltip ---------- export function tipShow(e, html) { const tip = $("tooltip"); tip.innerHTML = html; tip.style.display = "block"; tip.style.left = Math.min(e.clientX + 14, innerWidth - 230) + "px"; tip.style.top = Math.min(e.clientY + 14, innerHeight - 90) + "px"; } export function tipHide() { $("tooltip").style.display = "none"; } export function attachTip(el, html) { el.addEventListener("mousemove", (e) => tipShow(e, typeof html === "function" ? html() : html)); el.addEventListener("mouseleave", tipHide); } // ---------- horizontal bars (single-measure magnitude) ---------- export function hbars(el, entries, fmt = fmtUsd) { el.innerHTML = ""; if (!entries.length) { el.innerHTML = `
no data yet
`; return; } const max = Math.max(...entries.map((e) => e.v), 1e-9); for (const e of entries) { const row = document.createElement("div"); row.className = "bar-row"; row.innerHTML = `${e.link || e.k} ${fmt(e.v)}`; attachTip(row, `${e.k}${fmt(e.v)}${e.extra || ""}`); el.appendChild(row); } } // ---------- SVG helpers ---------- const NS = "http://www.w3.org/2000/svg"; const svgEl = (tag, attrs) => { const n = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); return n; }; const niceTicks = (max, n = 4) => { if (max <= 0) return [0, 1]; const step0 = max / n, mag = 10 ** Math.floor(Math.log10(step0)); const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => max / s <= n) || mag * 10; const out = []; for (let v = 0; v <= max * 1.001; v += step) out.push(v); return out; }; const SLOTS = ["var(--s1)", "var(--s2)", "var(--s3)", "var(--s4)"]; // ---------- multi-series line chart with crosshair ---------- export function lineChart(el, series, { fmtY = fmtUsd, area = true } = {}) { el.innerHTML = ""; series = series.filter((s) => s.points.length); if (!series.length) { el.innerHTML = `
no data yet
`; return; } const W = 820, H = 250, L = 58, R = 10, T = 12, B = 26; const ts = [...new Set(series.flatMap((s) => s.points.map((p) => p[0])))].sort((a, b) => a - b); const t0 = ts[0], t1 = ts[ts.length - 1] || t0 + 1; const span = Math.max(1, t1 - t0); const vmax = Math.max(...series.flatMap((s) => s.points.map((p) => p[1])), 1e-9) * 1.06; const x = (t) => L + ((t - t0) / span) * (W - L - R); const y = (v) => T + (1 - v / vmax) * (H - T - B); const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, role: "img" }); for (const v of niceTicks(vmax)) { // grid + y labels svg.appendChild(svgEl("line", { x1: L, x2: W - R, y1: y(v), y2: y(v), stroke: "var(--grid)", "stroke-width": 1 })); const t = svgEl("text", { x: L - 6, y: y(v) + 4, "text-anchor": "end", "font-size": 10.5, fill: "var(--muted)" }); t.textContent = fmtY(v).replace("$", "$"); svg.appendChild(t); } const nx = Math.min(6, ts.length); // x labels for (let i = 0; i < nx; i++) { const t = t0 + (span * i) / Math.max(1, nx - 1); const lbl = svgEl("text", { x: x(t), y: H - 8, "text-anchor": "middle", "font-size": 10.5, fill: "var(--muted)" }); lbl.textContent = timeLabel(t, span); svg.appendChild(lbl); } svg.appendChild(svgEl("line", { x1: L, x2: W - R, y1: y(0), y2: y(0), stroke: "var(--baseline)", "stroke-width": 1 })); series.forEach((s, i) => { const c = SLOTS[i % SLOTS.length]; const d = s.points.map((p, k) => `${k ? "L" : "M"}${x(p[0]).toFixed(1)},${y(p[1]).toFixed(1)}`).join(""); if (area && series.length === 1) { const a = d + `L${x(s.points.at(-1)[0])},${y(0)}L${x(s.points[0][0])},${y(0)}Z`; svg.appendChild(svgEl("path", { d: a, fill: c, opacity: 0.12 })); } svg.appendChild(svgEl("path", { d, fill: "none", stroke: c, "stroke-width": 2, "stroke-linejoin": "round", "stroke-linecap": "round" })); }); // direct labels at line ends (≤4 series), nudged apart if (series.length > 1 && series.length <= 4) { const ends = series.map((s, i) => ({ name: s.name, i, yy: y(s.points.at(-1)[1]) })) .sort((a, b) => a.yy - b.yy); for (let k = 1; k < ends.length; k++) if (ends[k].yy - ends[k - 1].yy < 12) ends[k].yy = ends[k - 1].yy + 12; for (const e of ends) { const t = svgEl("text", { x: W - R - 2, y: Math.min(e.yy + 3, H - B - 2), "text-anchor": "end", "font-size": 10.5, fill: "var(--ink-2)", "font-weight": 600 }); t.textContent = e.name; svg.appendChild(t); } } // crosshair + tooltip const cross = svgEl("line", { y1: T, y2: H - B, stroke: "var(--baseline)", "stroke-width": 1, "stroke-dasharray": "3,3", visibility: "hidden" }); svg.appendChild(cross); const dots = series.map((_, i) => { const d = svgEl("circle", { r: 4, fill: SLOTS[i % SLOTS.length], stroke: "var(--surface)", "stroke-width": 2, visibility: "hidden" }); svg.appendChild(d); return d; }); const hit = svgEl("rect", { x: L, y: T, width: W - L - R, height: H - T - B, fill: "transparent" }); svg.appendChild(hit); hit.addEventListener("mousemove", (e) => { const r = svg.getBoundingClientRect(); const tx = t0 + ((e.clientX - r.left) * (W / r.width) - L) / (W - L - R) * span; const ti = ts.reduce((b, t) => Math.abs(t - tx) < Math.abs(b - tx) ? t : b, ts[0]); cross.setAttribute("x1", x(ti)); cross.setAttribute("x2", x(ti)); cross.setAttribute("visibility", "visible"); let html = `${timeLabel(ti, span)}`; series.forEach((s, i) => { const p = s.points.find((p) => p[0] === ti); dots[i].setAttribute("visibility", p ? "visible" : "hidden"); if (p) { dots[i].setAttribute("cx", x(ti)); dots[i].setAttribute("cy", y(p[1])); html += `${s.name}: ${fmtY(p[1])}
`; } }); tipShow(e, html); }); hit.addEventListener("mouseleave", () => { cross.setAttribute("visibility", "hidden"); dots.forEach((d) => d.setAttribute("visibility", "hidden")); tipHide(); }); const wrap = document.createElement("div"); wrap.className = "chart"; wrap.appendChild(svg); el.appendChild(wrap); if (series.length > 1) { // legend (≥2 series) const lg = document.createElement("div"); lg.className = "legend"; lg.innerHTML = series.map((s, i) => `${s.name}`).join(""); el.appendChild(lg); } } // ---------- signed column chart (net flows: diverging blue↔red) ---------- export function columns(el, points, { fmtY = fmtUsd } = {}) { el.innerHTML = ""; if (!points.length) { el.innerHTML = `
no data yet
`; return; } const W = 820, H = 250, L = 58, R = 10, T = 12, B = 26; const vmax = Math.max(...points.map((p) => Math.abs(p.v)), 1e-9) * 1.08; const y = (v) => T + (1 - (v + vmax) / (2 * vmax)) * (H - T - B); const bw = Math.max(3, Math.min(40, (W - L - R) / points.length - 2)); const x = (i) => L + (i + 0.5) * ((W - L - R) / points.length); const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, role: "img" }); for (const v of [-vmax, -vmax / 2, 0, vmax / 2, vmax]) { svg.appendChild(svgEl("line", { x1: L, x2: W - R, y1: y(v), y2: y(v), stroke: v === 0 ? "var(--baseline)" : "var(--grid)", "stroke-width": 1 })); const t = svgEl("text", { x: L - 6, y: y(v) + 4, "text-anchor": "end", "font-size": 10.5, fill: "var(--muted)" }); t.textContent = fmtY(v); svg.appendChild(t); } const span = points.at(-1).t - points[0].t || 1; points.forEach((p, i) => { const h = Math.max(1, Math.abs(y(p.v) - y(0))); const rect = svgEl("rect", { x: x(i) - bw / 2, y: p.v >= 0 ? y(p.v) : y(0), width: bw, height: h, rx: 3, fill: p.v >= 0 ? "var(--s1)" : "var(--div-neg)", }); attachTip(rect, () => `${timeLabel(p.t, span)}net ${fmtY(p.v)}
minted ${fmtY(p.minted)} · burned ${fmtY(p.burned)}`); svg.appendChild(rect); if (i % Math.ceil(points.length / 6) === 0) { const t = svgEl("text", { x: x(i), y: H - 8, "text-anchor": "middle", "font-size": 10.5, fill: "var(--muted)" }); t.textContent = timeLabel(p.t, span); svg.appendChild(t); } }); const wrap = document.createElement("div"); wrap.className = "chart"; wrap.appendChild(svg); el.appendChild(wrap); } // ---------- series shaping: top-N chains + Other ---------- export function topSeries(chainsObj, n = 3, key = "volume") { const totals = Object.entries(chainsObj) .map(([c, pts]) => [c, pts.reduce((a, p) => a + (p[key] ?? p.supply ?? 0), 0)]) .sort((a, b) => b[1] - a[1]); const top = totals.slice(0, n).map(([c]) => c); const others = totals.slice(n).map(([c]) => c); const series = top.map((c) => ({ name: c, points: chainsObj[c].map((p) => [p.t, p[key] ?? p.supply ?? 0]), })); if (others.length) { const acc = {}; for (const c of others) for (const p of chainsObj[c]) acc[p.t] = (acc[p.t] || 0) + (p[key] ?? p.supply ?? 0); series.push({ name: "other", points: Object.entries(acc) .map(([t, v]) => [+t, v]).sort((a, b) => a[0] - b[0]) }); } return series; }