spb/coinexplorer Public MIT
Self-hosted, zero-API-key explorer for stablecoins and major crypto.
Python 60.3%
HTML 23.6%
JavaScript 8.1%
CSS 6.8%
SQL 1%
1// Author: Simon-Pierre Boucher2// Mail: contact@spboucher.ai3// Shared front-end: nav, search, formatting, tooltip, bar rows, SVG line/column charts.45export const $ = (id) => document.getElementById(id);6export const qs = new URLSearchParams(location.search);78export async function j(url) {9 const r = await fetch(url);10 if (!r.ok) throw new Error(`${r.status} ${url}`);11 return r.json();12}1314// ---------- formatting ----------15export const fmtUsd = (n) => {16 const a = Math.abs(n);17 if (a >= 1e12) return "$" + (n / 1e12).toFixed(2) + "T";18 if (a >= 1e9) return "$" + (n / 1e9).toFixed(2) + "B";19 if (a >= 1e6) return "$" + (n / 1e6).toFixed(1) + "M";20 if (a >= 1e3) return "$" + (n / 1e3).toFixed(1) + "K";21 return "$" + n.toFixed(2);22};23export const fmtN = (n) => (n ?? 0).toLocaleString("en-US");24export const fmtQty = (n) => {25 const a = Math.abs(n);26 if (a >= 1e9) return (n / 1e9).toFixed(2) + "B";27 if (a >= 1e6) return (n / 1e6).toFixed(2) + "M";28 if (a >= 1e3) return (n / 1e3).toFixed(1) + "K";29 return n.toFixed(a < 1 ? 4 : 2);30};3132// token categories (stablecoin vs crypto) — loaded once by mountNav33export let CATS = {};34export const isStable = (sym) => (CATS[sym] || "stablecoin") !== "crypto";3536// row amount: stablecoins read as USD directly; crypto shows units + USD37export function fmtAmt(r) {38 const v = parseFloat(r.value);39 if (!isStable(r.symbol) && r.usd != null)40 return `${fmtQty(v)} ${r.symbol} <span style="color:var(--muted)">· ${fmtUsd(r.usd)}</span>`;41 return fmtUsd(r.usd ?? v);42}43export const short = (s) => (s ? s.slice(0, 8) + "…" + s.slice(-4) : "—");44export const ago = (ts) => {45 if (!ts) return "—";46 const d = Math.max(0, Date.now() / 1000 - ts);47 return d < 90 ? Math.round(d) + "s" : d < 5400 ? Math.round(d / 60) + "m"48 : d < 129600 ? Math.round(d / 3600) + "h" : Math.round(d / 86400) + "d";49};50const timeLabel = (t, span) => {51 const d = new Date(t * 1000);52 return span > 3 * 8640053 ? d.toLocaleDateString("en-US", { month: "short", day: "numeric" })54 : d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });55};56export const addrLink = (a) => a57 ? `<a href="/address.html?a=${encodeURIComponent(a)}" class="mono" title="${a}">${short(a)}</a>` : "—";58export const txLink = (chain, h) =>59 `<a href="/tx.html?chain=${chain}&hash=${encodeURIComponent(h)}" class="mono" title="${h}">${short(h)}</a>`;6061// ---------- asset & chain icons ----------62// Real logos from the cryptocurrency-icons CDN where available; everything63// else falls back to a brand-colored coin with the ticker's initial — the64// <img> sits above the fallback and simply removes itself on 404.65const BRAND = {66 BTC:"#f7931a", WBTC:"#f09242", cbBTC:"#0052ff", ETH:"#627eea", WETH:"#627eea",67 USDT:"#26a17b", USDC:"#2775ca", DAI:"#f5ac37", USDS:"#f5ac37", USDe:"#24354f",68 FDUSD:"#01c38d", PYUSD:"#0070e0", RLUSD:"#0085c0", USDG:"#02414c", TUSD:"#1a5aff",69 USDB:"#fcfc03", LINK:"#2a5ada", UNI:"#ff007a", AAVE:"#9391f7", SHIB:"#ffa409",70 PEPE:"#4c9641", LDO:"#f69988", CRV:"#870f9e", ONDO:"#0f1728", MKR:"#1aab9b",71 ENA:"#11314f", ARB:"#12aaff", OP:"#ff0420", BNB:"#f3ba2f", WBNB:"#f3ba2f",72 CAKE:"#d1884f", AVAX:"#e84142", WAVAX:"#e84142", POL:"#8247e5", WPOL:"#8247e5",73 SOL:"#9945ff", TRX:"#ff0013", BONK:"#f9a33d", JUP:"#16bee2", WIF:"#a58b6f",74};75const CHAIN_BRAND = {76 ethereum:"#627eea", bitcoin:"#f7931a", tron:"#ff0013", solana:"#9945ff",77 bsc:"#f3ba2f", polygon:"#8247e5", avalanche:"#e84142", arbitrum:"#12aaff",78 optimism:"#ff0420", base:"#0052ff", celo:"#fcff52", gnosis:"#04795b",79 linea:"#121212", scroll:"#eb7106", mantle:"#141414", zksync:"#8c8dfc",80 blast:"#fcfc03", unichain:"#ff007a", world_chain:"#1e1e1e", sonic:"#0a1e3f",81 sei:"#8c1d18", hyperevm:"#0f3933", kaia:"#5f7f12", ink:"#7132f5",82};83const CDN = "https://cdn.jsdelivr.net/npm/cryptocurrency-icons@0.18.1/svg/color/";84const CDN_SYM = { // package symbol overrides / aliases85 WETH:"eth", cbBTC:"btc", WBNB:"bnb", WAVAX:"avax", WPOL:"matic", POL:"matic",86};87const CHAIN_SYM = { // chain → package symbol (only where a real logo exists)88 ethereum:"eth", bitcoin:"btc", tron:"trx", solana:"sol", bsc:"bnb",89 polygon:"matic", avalanche:"avax",90};91const NO_CDN = new Set(["USDe","FDUSD","PYUSD","RLUSD","USDG","USDB","PEPE","ONDO",92 "ENA","ARB","OP","CAKE","BONK","JUP","WIF","LDO","USDS","SHIB"]);9394const luma = (hex) => {95 const n = parseInt(hex.slice(1), 16);96 return (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255;97};98function coinHtml(text, color, cdnSym, size) {99 const fg = luma(color) > 0.62 ? "#0b0b0b" : "#fff";100 const img = cdnSym101 ? `<img src="${CDN}${cdnSym}.svg" alt="" loading="lazy" onerror="this.remove()">` : "";102 return `<span class="coin" style="--sz:${size}px">` +103 `<i style="background:linear-gradient(135deg,${color},color-mix(in oklab,${color} 65%,#000));color:${fg}">${text[0]}</i>${img}</span>`;104}105export function coinIcon(sym, size = 16) {106 const color = BRAND[sym] || "#6b7280";107 const cdn = NO_CDN.has(sym) ? null : (CDN_SYM[sym] || sym.toLowerCase());108 return coinHtml(sym, color, cdn, size);109}110export function chainIcon(chain, size = 16) {111 const color = CHAIN_BRAND[chain] || "#6b7280";112 return coinHtml(chain.toUpperCase(), color, CHAIN_SYM[chain] || null, size);113}114export const chainLink = (c) =>115 `<a class="asset" href="/chain.html?chain=${c}">${chainIcon(c)}<span>${c}</span></a>`;116export const tokenLink = (s) =>117 `<a class="asset" href="/token.html?symbol=${s}">${coinIcon(s)}<span>${s}</span></a>`;118119// ---------- nav ----------120const NAV = [121 ["Overview", "/"], ["Tokens", "/token.html"], ["Chains", "/chain.html"],122 ["Transfers", "/transfers.html"], ["Whales", "/whales.html"],123 ["Flows", "/flows.html"], ["API", "/api.html"], ["Status", "/status.html"],124];125export function mountNav(active) {126 const el = document.createElement("div");127 el.className = "topnav";128 el.innerHTML = `129 <a class="logo" href="/">coin<span>explorer</span></a>130 <nav>${NAV.map(([n, h]) =>131 `<a href="${h}" class="${n === active ? "active" : ""}">${n}</a>`).join("")}</nav>132 <form class="searchbox" id="searchForm">133 <input id="searchInput" placeholder="tx hash · address · token · chain" aria-label="Search">134 </form>`;135 document.body.prepend(el);136 j("/v1/tokens").then((t) => {137 for (const [sym, d] of Object.entries(t)) CATS[sym] = d.category;138 // natives tracked via chains.yaml aren't in /v1/tokens — they're crypto139 for (const s of ["ETH", "BTC", "BNB", "SOL", "TRX", "AVAX", "POL"])140 if (!(s in CATS)) CATS[s] = "crypto";141 }).catch(() => {});142 const tip = document.createElement("div");143 tip.id = "tooltip";144 document.body.appendChild(tip);145 $("searchForm").addEventListener("submit", async (e) => {146 e.preventDefault();147 const q = $("searchInput").value.trim();148 if (!q) return;149 try {150 const r = await j(`/v1/search?q=${encodeURIComponent(q)}`);151 if (r.type === "token") location.href = `/token.html?symbol=${r.symbol}`;152 else if (r.type === "chain") location.href = `/chain.html?chain=${r.chain}`;153 else if (r.type === "tx") location.href = `/tx.html?hash=${encodeURIComponent(r.hash)}${r.chain ? `&chain=${r.chain}` : ""}`;154 else if (r.type === "address") location.href = `/address.html?a=${encodeURIComponent(r.address)}`;155 else alert("No match — try a tx hash, address, token symbol or chain name.");156 } catch { alert("Search failed."); }157 });158 const f = document.createElement("footer");159 f.innerHTML = "coinexplorer · self-hosted stablecoin explorer · free public RPCs only · " +160 '<a href="/docs">API</a> · <a href="/metrics">metrics</a> · not financial advice';161 document.body.appendChild(f);162}163164// ---------- tooltip ----------165export function tipShow(e, html) {166 const tip = $("tooltip");167 tip.innerHTML = html;168 tip.style.display = "block";169 tip.style.left = Math.min(e.clientX + 14, innerWidth - 230) + "px";170 tip.style.top = Math.min(e.clientY + 14, innerHeight - 90) + "px";171}172export function tipHide() { $("tooltip").style.display = "none"; }173export function attachTip(el, html) {174 el.addEventListener("mousemove", (e) => tipShow(e, typeof html === "function" ? html() : html));175 el.addEventListener("mouseleave", tipHide);176}177178// ---------- horizontal bars (single-measure magnitude) ----------179export function hbars(el, entries, fmt = fmtUsd) {180 el.innerHTML = "";181 if (!entries.length) { el.innerHTML = `<div class="empty">no data yet</div>`; return; }182 const max = Math.max(...entries.map((e) => e.v), 1e-9);183 for (const e of entries) {184 const row = document.createElement("div");185 row.className = "bar-row";186 row.innerHTML = `<span class="bar-label">${e.link || e.k}</span>187 <span class="bar-track"><span class="bar-fill" style="width:${(100 * e.v / max).toFixed(2)}%"></span></span>188 <span class="bar-val">${fmt(e.v)}</span>`;189 attachTip(row, `<b>${e.k}</b>${fmt(e.v)}${e.extra || ""}`);190 el.appendChild(row);191 }192}193194// ---------- SVG helpers ----------195const NS = "http://www.w3.org/2000/svg";196const svgEl = (tag, attrs) => {197 const n = document.createElementNS(NS, tag);198 for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);199 return n;200};201const niceTicks = (max, n = 4) => {202 if (max <= 0) return [0, 1];203 const step0 = max / n, mag = 10 ** Math.floor(Math.log10(step0));204 const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => max / s <= n) || mag * 10;205 const out = [];206 for (let v = 0; v <= max * 1.001; v += step) out.push(v);207 return out;208};209const SLOTS = ["var(--s1)", "var(--s2)", "var(--s3)", "var(--s4)"];210211// ---------- multi-series line chart with crosshair ----------212export function lineChart(el, series, { fmtY = fmtUsd, area = true } = {}) {213 el.innerHTML = "";214 series = series.filter((s) => s.points.length);215 if (!series.length) { el.innerHTML = `<div class="empty">no data yet</div>`; return; }216 const W = 820, H = 250, L = 58, R = 10, T = 12, B = 26;217 const ts = [...new Set(series.flatMap((s) => s.points.map((p) => p[0])))].sort((a, b) => a - b);218 const t0 = ts[0], t1 = ts[ts.length - 1] || t0 + 1;219 const span = Math.max(1, t1 - t0);220 const vmax = Math.max(...series.flatMap((s) => s.points.map((p) => p[1])), 1e-9) * 1.06;221 const x = (t) => L + ((t - t0) / span) * (W - L - R);222 const y = (v) => T + (1 - v / vmax) * (H - T - B);223 const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, role: "img" });224225 for (const v of niceTicks(vmax)) { // grid + y labels226 svg.appendChild(svgEl("line", { x1: L, x2: W - R, y1: y(v), y2: y(v),227 stroke: "var(--grid)", "stroke-width": 1 }));228 const t = svgEl("text", { x: L - 6, y: y(v) + 4, "text-anchor": "end",229 "font-size": 10.5, fill: "var(--muted)" });230 t.textContent = fmtY(v).replace("$", "$");231 svg.appendChild(t);232 }233 const nx = Math.min(6, ts.length); // x labels234 for (let i = 0; i < nx; i++) {235 const t = t0 + (span * i) / Math.max(1, nx - 1);236 const lbl = svgEl("text", { x: x(t), y: H - 8, "text-anchor": "middle",237 "font-size": 10.5, fill: "var(--muted)" });238 lbl.textContent = timeLabel(t, span);239 svg.appendChild(lbl);240 }241 svg.appendChild(svgEl("line", { x1: L, x2: W - R, y1: y(0), y2: y(0),242 stroke: "var(--baseline)", "stroke-width": 1 }));243244 series.forEach((s, i) => {245 const c = SLOTS[i % SLOTS.length];246 const d = s.points.map((p, k) => `${k ? "L" : "M"}${x(p[0]).toFixed(1)},${y(p[1]).toFixed(1)}`).join("");247 if (area && series.length === 1) {248 const a = d + `L${x(s.points.at(-1)[0])},${y(0)}L${x(s.points[0][0])},${y(0)}Z`;249 svg.appendChild(svgEl("path", { d: a, fill: c, opacity: 0.12 }));250 }251 svg.appendChild(svgEl("path", { d, fill: "none", stroke: c, "stroke-width": 2,252 "stroke-linejoin": "round", "stroke-linecap": "round" }));253 });254255 // direct labels at line ends (≤4 series), nudged apart256 if (series.length > 1 && series.length <= 4) {257 const ends = series.map((s, i) => ({ name: s.name, i, yy: y(s.points.at(-1)[1]) }))258 .sort((a, b) => a.yy - b.yy);259 for (let k = 1; k < ends.length; k++)260 if (ends[k].yy - ends[k - 1].yy < 12) ends[k].yy = ends[k - 1].yy + 12;261 for (const e of ends) {262 const t = svgEl("text", { x: W - R - 2, y: Math.min(e.yy + 3, H - B - 2),263 "text-anchor": "end", "font-size": 10.5, fill: "var(--ink-2)", "font-weight": 600 });264 t.textContent = e.name;265 svg.appendChild(t);266 }267 }268269 // crosshair + tooltip270 const cross = svgEl("line", { y1: T, y2: H - B, stroke: "var(--baseline)",271 "stroke-width": 1, "stroke-dasharray": "3,3", visibility: "hidden" });272 svg.appendChild(cross);273 const dots = series.map((_, i) => {274 const d = svgEl("circle", { r: 4, fill: SLOTS[i % SLOTS.length],275 stroke: "var(--surface)", "stroke-width": 2, visibility: "hidden" });276 svg.appendChild(d);277 return d;278 });279 const hit = svgEl("rect", { x: L, y: T, width: W - L - R, height: H - T - B,280 fill: "transparent" });281 svg.appendChild(hit);282 hit.addEventListener("mousemove", (e) => {283 const r = svg.getBoundingClientRect();284 const tx = t0 + ((e.clientX - r.left) * (W / r.width) - L) / (W - L - R) * span;285 const ti = ts.reduce((b, t) => Math.abs(t - tx) < Math.abs(b - tx) ? t : b, ts[0]);286 cross.setAttribute("x1", x(ti)); cross.setAttribute("x2", x(ti));287 cross.setAttribute("visibility", "visible");288 let html = `<b>${timeLabel(ti, span)}</b>`;289 series.forEach((s, i) => {290 const p = s.points.find((p) => p[0] === ti);291 dots[i].setAttribute("visibility", p ? "visible" : "hidden");292 if (p) {293 dots[i].setAttribute("cx", x(ti)); dots[i].setAttribute("cy", y(p[1]));294 html += `<span class="sw" style="background:${SLOTS[i % SLOTS.length]}"></span>${s.name}: ${fmtY(p[1])}<br>`;295 }296 });297 tipShow(e, html);298 });299 hit.addEventListener("mouseleave", () => {300 cross.setAttribute("visibility", "hidden");301 dots.forEach((d) => d.setAttribute("visibility", "hidden"));302 tipHide();303 });304305 const wrap = document.createElement("div");306 wrap.className = "chart";307 wrap.appendChild(svg);308 el.appendChild(wrap);309 if (series.length > 1) { // legend (≥2 series)310 const lg = document.createElement("div");311 lg.className = "legend";312 lg.innerHTML = series.map((s, i) =>313 `<span><span class="sw" style="background:${SLOTS[i % SLOTS.length]}"></span>${s.name}</span>`).join("");314 el.appendChild(lg);315 }316}317318// ---------- signed column chart (net flows: diverging blue↔red) ----------319export function columns(el, points, { fmtY = fmtUsd } = {}) {320 el.innerHTML = "";321 if (!points.length) { el.innerHTML = `<div class="empty">no data yet</div>`; return; }322 const W = 820, H = 250, L = 58, R = 10, T = 12, B = 26;323 const vmax = Math.max(...points.map((p) => Math.abs(p.v)), 1e-9) * 1.08;324 const y = (v) => T + (1 - (v + vmax) / (2 * vmax)) * (H - T - B);325 const bw = Math.max(3, Math.min(40, (W - L - R) / points.length - 2));326 const x = (i) => L + (i + 0.5) * ((W - L - R) / points.length);327 const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, role: "img" });328 for (const v of [-vmax, -vmax / 2, 0, vmax / 2, vmax]) {329 svg.appendChild(svgEl("line", { x1: L, x2: W - R, y1: y(v), y2: y(v),330 stroke: v === 0 ? "var(--baseline)" : "var(--grid)", "stroke-width": 1 }));331 const t = svgEl("text", { x: L - 6, y: y(v) + 4, "text-anchor": "end",332 "font-size": 10.5, fill: "var(--muted)" });333 t.textContent = fmtY(v);334 svg.appendChild(t);335 }336 const span = points.at(-1).t - points[0].t || 1;337 points.forEach((p, i) => {338 const h = Math.max(1, Math.abs(y(p.v) - y(0)));339 const rect = svgEl("rect", {340 x: x(i) - bw / 2, y: p.v >= 0 ? y(p.v) : y(0), width: bw, height: h,341 rx: 3, fill: p.v >= 0 ? "var(--s1)" : "var(--div-neg)",342 });343 attachTip(rect, () =>344 `<b>${timeLabel(p.t, span)}</b>net ${fmtY(p.v)}<br>minted ${fmtY(p.minted)} · burned ${fmtY(p.burned)}`);345 svg.appendChild(rect);346 if (i % Math.ceil(points.length / 6) === 0) {347 const t = svgEl("text", { x: x(i), y: H - 8, "text-anchor": "middle",348 "font-size": 10.5, fill: "var(--muted)" });349 t.textContent = timeLabel(p.t, span);350 svg.appendChild(t);351 }352 });353 const wrap = document.createElement("div");354 wrap.className = "chart";355 wrap.appendChild(svg);356 el.appendChild(wrap);357}358359// ---------- series shaping: top-N chains + Other ----------360export function topSeries(chainsObj, n = 3, key = "volume") {361 const totals = Object.entries(chainsObj)362 .map(([c, pts]) => [c, pts.reduce((a, p) => a + (p[key] ?? p.supply ?? 0), 0)])363 .sort((a, b) => b[1] - a[1]);364 const top = totals.slice(0, n).map(([c]) => c);365 const others = totals.slice(n).map(([c]) => c);366 const series = top.map((c) => ({367 name: c, points: chainsObj[c].map((p) => [p.t, p[key] ?? p.supply ?? 0]),368 }));369 if (others.length) {370 const acc = {};371 for (const c of others)372 for (const p of chainsObj[c]) acc[p.t] = (acc[p.t] || 0) + (p[key] ?? p.supply ?? 0);373 series.push({ name: "other", points: Object.entries(acc)374 .map(([t, v]) => [+t, v]).sort((a, b) => a[0] - b[0]) });375 }376 return series;377}378