SPB Git

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%
16.9 KB · 378 lines javascript
Raw Blame History
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