| |
| |
| export const $ = (sel, root = document) => root.querySelector(sel); |
| export const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; |
| |
| |
| 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; |
| 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)); |
| |
| |
| 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 {} }, |
| }; |
| |
| |
| 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); }, |
| }; |
| } |