// Small helpers shared by every view. export const $ = (sel, root = document) => root.querySelector(sel); export const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; // h('div.row.active', { onclick, title }, child, 'text', ...) export function h(tag, attrs, ...kids) { const [name, ...cls] = tag.split('.'); const el = document.createElement(name || 'div'); if (cls.length) el.className = cls.join(' '); if (attrs) { for (const [k, v] of Object.entries(attrs)) { if (v == null || v === false) continue; if (k.startsWith('on')) el.addEventListener(k.slice(2), v); else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v); else if (k === 'html') el.innerHTML = v; // only ever used with our own markup (icons) else if (k in el && k !== 'list' && k !== 'type') el[k] = v; else el.setAttribute(k, v === true ? '' : v); } } for (const k of kids.flat()) if (k != null && k !== false) el.append(k instanceof Node ? k : document.createTextNode(String(k))); return el; } export const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); export function compact(v, digits = 1) { if (!Number.isFinite(v)) return '–'; const a = Math.abs(v); if (a >= 1e12) return (v / 1e12).toFixed(digits) + 'T'; if (a >= 1e9) return (v / 1e9).toFixed(digits) + 'B'; if (a >= 1e6) return (v / 1e6).toFixed(digits) + 'M'; if (a >= 1e3) return (v / 1e3).toFixed(digits) + 'K'; if (a >= 100) return v.toFixed(0); if (a >= 1) return v.toFixed(a >= 10 ? 1 : 2); if (a === 0) return '0'; return v.toFixed(a >= 0.01 ? 2 : a >= 0.001 ? 3 : 4); } export const count = n => (Number.isFinite(n) ? (Math.abs(n) < 1000 ? String(Math.round(n)) : compact(n, Math.abs(n) < 1e4 ? 1 : 0).replace('.0K', 'K')) : '–'); export const usd = v => (v < 0 ? '-$' : '$') + compact(Math.abs(v)); export function sol(v, d) { if (!Number.isFinite(v)) return '–'; const a = Math.abs(v); const digits = d ?? (a >= 1000 ? 0 : a >= 100 ? 1 : a >= 1 ? 2 : a >= 0.01 ? 3 : 4); return a >= 10000 ? compact(v) : v.toFixed(digits); } export const pct = (v, d = 1) => (Number.isFinite(v) ? (+v).toFixed(v !== 0 && Math.abs(v) < 1 && d < 2 ? 2 : d) + '%' : '–'); export const signed = (v, f) => (v > 0 ? '+' : v < 0 ? '−' : '') + f(Math.abs(v)); // price of one token in dollars: many zeros are written as 0.0₅123 export function tinyUsd(v) { if (!Number.isFinite(v) || v <= 0) return '–'; if (v >= 1) return '$' + v.toFixed(v >= 100 ? 2 : 4); const s = v.toFixed(20).slice(2); const zeros = s.match(/^0*/)[0].length; if (zeros < 4) return '$' + v.toFixed(zeros + 4); const sub = String(zeros).split('').map(d => '₀₁₂₃₄₅₆₇₈₉'[d]).join(''); return '$0.0' + sub + s.slice(zeros, zeros + 4).replace(/0+$/, ''); } export function age(ts, nowMs = Date.now()) { if (!ts) return '–'; const s = Math.max(0, Math.floor((nowMs - ts) / 1000)); if (s < 60) return s + 's'; const m = Math.floor(s / 60); if (m < 60) return m + 'm'; const hh = Math.floor(m / 60); if (hh < 24) return hh + 'h'; const d = Math.floor(hh / 24); return d < 30 ? d + 'd' : Math.floor(d / 30) + 'mo'; } export const short = (a, n = 4) => (a ? a.slice(0, n) + '…' + a.slice(-n) : ''); export function copy(text, label = 'Copied') { navigator.clipboard?.writeText(text).then(() => toast(label, 'info', 1400)).catch(() => {}); } let toastRoot; export function toast(text, kind = 'info', ms = 3000, node) { toastRoot ||= document.getElementById('toasts'); const t = h('div.toast.' + kind, null, node || h('span', null, text)); toastRoot.prepend(t); while (toastRoot.children.length > 5) toastRoot.lastChild.remove(); requestAnimationFrame(() => t.classList.add('in')); setTimeout(() => { t.classList.remove('in'); setTimeout(() => t.remove(), 250); }, ms); return t; } export function safeHref(u) { try { const x = new URL(u); return x.protocol === 'https:' || x.protocol === 'http:' ? x.href : null; } catch { return null; } } export const store = { get(k, d) { try { const v = localStorage.getItem('oscope.' + k); return v == null ? d : JSON.parse(v); } catch { return d; } }, set(k, v) { try { localStorage.setItem('oscope.' + k, JSON.stringify(v)); } catch {} }, }; // a deterministic colour for a coin with no picture export function hue(str) { let x = 0; for (let i = 0; i < str.length; i++) x = (x * 31 + str.charCodeAt(i)) >>> 0; return x % 360; } export function emitter() { const m = new Map(); return { on(e, f) { if (!m.has(e)) m.set(e, new Set()); m.get(e).add(f); return () => m.get(e).delete(f); }, emit(e, ...a) { for (const f of m.get(e) || []) f(...a); }, }; }