// 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 = `
coinexplorer
`;
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;
}