// Coin page: chart, live trades, holders, top traders, the dev's other coins, and the paper trade panel. import { createChart, CandlestickSeries, HistogramSeries, createSeriesMarkers, CrosshairMode } from '/vendor/lightweight-charts.mjs'; import { h, $, age, usd, sol as fsol, compact, count, pct, tinyUsd, short, copy, store, signed } from './util.js'; import { icon, iconEl } from './icons.js'; import { live, net } from './net.js'; import { paper } from './paper.js'; import { trackers } from './trackers.js'; import { avatar, setImg, socials, level, emptyState } from './parts.js'; import { settings } from './settings.js'; const TFS = [['1s', 1], ['5s', 5], ['15s', 15], ['30s', 30], ['1m', 60], ['5m', 300], ['15m', 900], ['1h', 3600], ['4h', 14400]]; const SOLSCAN = 'https://solscan.io'; export function coinView(root, mint) { let d = null; // coin detail let alive = true; const ui = {}; const pref = store.get('chart', { tf: 15, unit: 'mc', cur: 'usd' }); let candles = [], trades = [], tab = 'trades', side = 'b', amount = '', sellFrac = 0.5, holdersData = null, devList = null; const page = h('div.coin.page-in'); root.append(page); // ---------- band ---------- ui.av = avatar({ m: mint, s: '' }, 48); ui.nm = h('span.nm', null, '…'); ui.sy = h('span.sy'); ui.age = h('span.agev'); ui.soc = h('div.soc'); ui.mcv = h('span.v', null, '–'); ui.stats = h('div.band-stats'); const band = h('div.band', null, ui.av, h('div.band-id', null, h('div.l1', null, ui.nm, ui.sy), h('div.l2', null, ui.age, h('span.addr', { title: 'Copy address', onclick: () => copy(mint, 'Address copied') }, short(mint, 5), iconEl('copy', 12)), ui.soc)), h('div.hero', null, h('span.k', null, 'Market cap'), ui.mcv), ui.stats, h('div.band-r', null, h('a.btn.sm', { href: `https://pump.fun/coin/${mint}`, target: '_blank', rel: 'noopener noreferrer', html: 'pump.fun ' + icon('ext', 12) }), h('a.btn.sm', { href: `${SOLSCAN}/token/${mint}`, target: '_blank', rel: 'noopener noreferrer', html: 'Solscan ' + icon('ext', 12) }))); page.append(band); // ---------- chart ---------- const tfSeg = h('div.seg'); for (const [label, s] of TFS) tfSeg.append(h('button' + (pref.tf === s ? '.on' : ''), { onclick: e => { pref.tf = s; savePref(); mark(tfSeg, e.currentTarget); loadCandles(); } }, label)); const unitSeg = h('div.seg', null, h('button' + (pref.unit === 'mc' ? '.on' : ''), { onclick: e => { pref.unit = 'mc'; savePref(); mark(unitSeg, e.currentTarget); drawAll(); } }, 'MC'), h('button' + (pref.unit === 'price' ? '.on' : ''), { onclick: e => { pref.unit = 'price'; savePref(); mark(unitSeg, e.currentTarget); drawAll(); } }, 'Price')); const curSeg = h('div.seg', null, h('button' + (pref.cur === 'usd' ? '.on' : ''), { onclick: e => { pref.cur = 'usd'; savePref(); mark(curSeg, e.currentTarget); drawAll(); } }, 'USD'), h('button' + (pref.cur === 'sol' ? '.on' : ''), { onclick: e => { pref.cur = 'sol'; savePref(); mark(curSeg, e.currentTarget); drawAll(); } }, 'SOL')); ui.ohlc = h('div.ohlc'); ui.chartBox = h('div.chart'); ui.chartNote = h('div.chart-note'); ui.chartBox.append(ui.chartNote); const chartWrap = h('div.chart-wrap', null, h('div.chart-bar', null, tfSeg, unitSeg, curSeg, ui.ohlc), ui.chartBox); // ---------- tabs ---------- ui.tabsH = h('div.tabs-h'); ui.tabBody = h('div.tab-body'); const TABS = [['trades', 'Trades'], ['holders', 'Holders'], ['traders', 'Top traders'], ['dev', 'Dev tokens'], ['mine', 'Your trades']]; ui.tabBtn = {}; for (const [k, label] of TABS) { const cnt = h('span.count'); const b = h('button' + (k === tab ? '.on' : ''), { onclick: () => { tab = k; for (const x of Object.values(ui.tabBtn)) x.classList.remove('on'); b.classList.add('on'); drawTab(); } }, label, cnt); b._cnt = cnt; ui.tabBtn[k] = b; ui.tabsH.append(b); } page.append(h('div.main', null, chartWrap, h('div.tabs', null, ui.tabsH, ui.tabBody))); // ---------- side ---------- ui.side = h('div.side'); page.append(ui.side); buildSide(); // ---------- chart setup ---------- const chart = createChart(ui.chartBox, { autoSize: true, layout: { background: { color: '#06070a' }, textColor: '#737b8c', fontFamily: 'Geist, system-ui, sans-serif', fontSize: 11, attributionLogo: true }, grid: { vertLines: { color: 'rgba(255,255,255,.025)' }, horzLines: { color: 'rgba(255,255,255,.035)' } }, rightPriceScale: { borderColor: '#171a22', scaleMargins: { top: 0.12, bottom: 0.2 } }, timeScale: { borderColor: '#171a22', timeVisible: true, secondsVisible: true, rightOffset: 6, barSpacing: 8 }, crosshair: { mode: CrosshairMode.Normal, vertLine: { color: '#2e3442', labelBackgroundColor: '#1b1f2a' }, horzLine: { color: '#2e3442', labelBackgroundColor: '#1b1f2a' } }, localization: { priceFormatter: v => fmtAxis(v) }, }); const series = chart.addSeries(CandlestickSeries, { upColor: '#2fe39b', downColor: '#ff5d7b', borderVisible: false, wickUpColor: '#2fe39b', wickDownColor: '#ff5d7b', priceFormat: { type: 'custom', formatter: v => fmtAxis(v), minMove: 1e-12 }, priceLineColor: '#4d8dff', priceLineStyle: 2, }); const volSeries = chart.addSeries(HistogramSeries, { priceScaleId: 'vol', priceFormat: { type: 'volume' }, lastValueVisible: false, priceLineVisible: false }); chart.priceScale('vol').applyOptions({ scaleMargins: { top: 0.84, bottom: 0 } }); const markers = createSeriesMarkers(series, []); chart.subscribeCrosshairMove(p => { const c = p && p.time ? p.seriesData.get(series) : null; showOhlc(c || null); }); function fmtAxis(v) { if (v < 0) return ''; // the axis margin below a coin near zero is not a price if (pref.unit === 'price') return pref.cur === 'usd' ? tinyUsd(v) : (v < 1e-3 ? v.toExponential(2) : v.toPrecision(4)); return pref.cur === 'usd' ? '$' + compact(v, 2) : compact(v, 2); } function conv(mcSol) { const supply = d?.supply || 1e9; let v = mcSol; if (pref.unit === 'price') v /= supply; if (pref.cur === 'usd') v *= live.sol || 0; return v; } function bar(c) { return { time: c[0], open: conv(c[1]), high: conv(c[2]), low: conv(c[3]), close: conv(c[4]) }; } function vbar(c) { return { time: c[0], value: c[5] * (pref.cur === 'usd' ? live.sol || 0 : 1), color: c[4] >= c[1] ? 'rgba(47,227,155,.28)' : 'rgba(255,93,123,.28)' }; } function showOhlc(c) { const last = c || (candles.length ? bar(candles[candles.length - 1]) : null); if (!last) { ui.ohlc.textContent = ''; return; } const ch = last.open ? (last.close / last.open - 1) * 100 : 0; ui.ohlc.innerHTML = ''; ui.ohlc.append( h('span', null, 'O ', h('b', null, fmtAxis(last.open))), h('span', null, 'H ', h('b', null, fmtAxis(last.high))), h('span', null, 'L ', h('b', null, fmtAxis(last.low))), h('span', null, 'C ', h('b', null, fmtAxis(last.close))), h('span.' + (ch >= 0 ? 'up' : 'down'), null, signed(ch, x => x.toFixed(2) + '%'))); } function drawAll() { series.applyOptions({ priceFormat: { type: 'custom', formatter: v => fmtAxis(v), minMove: pref.unit === 'price' ? 1e-14 : 1e-6 } }); series.setData(candles.map(bar)); volSeries.setData(candles.map(vbar)); drawMarkers(); showOhlc(null); ui.chartNote.replaceChildren(candles.length ? '' : (d ? 'No trades in this window yet' : '')); } async function loadCandles() { try { candles = await net.get(`/api/candles/${mint}?tf=${pref.tf}`); } catch { candles = []; } if (!alive) return; drawAll(); fitView(); } function fitView() { const ts = chart.timeScale(); // a young chart starts at the left and grows right; a long one shows its last 90 bars if (candles.length < 60) ts.setVisibleLogicalRange({ from: -1, to: 64 }); else ts.setVisibleLogicalRange({ from: candles.length - 90, to: candles.length + 4 }); } function liveCandle(tr) { const tf = pref.tf, ts = tr[0], mc = tr[5], v = tr[2]; if (!(mc > 0)) return; const t = Math.floor(ts / 1000 / tf) * tf; const last = candles[candles.length - 1]; if (last && last[0] === t) { last[2] = Math.max(last[2], mc); last[3] = Math.min(last[3], mc); last[4] = mc; last[5] += v; } else if (!last || t > last[0]) { const o = last ? last[4] : mc; candles.push([t, o, Math.max(o, mc), Math.min(o, mc), mc, v]); } else return; const c = candles[candles.length - 1]; series.update(bar(c)); volSeries.update(vbar(c)); if (candles.length === 1) ui.chartNote.replaceChildren(''); } function drawMarkers() { const out = []; const tf = pref.tf; const track = trackers.byW(); const first = candles.length ? candles[0][0] : Infinity; for (const tr of trades) { const w = tr[6]; const isDev = d && w === d.dev; const tk = track.get(w); if (!isDev && !tk) continue; const t = Math.floor(tr[0] / 1000 / tf) * tf; if (t < first) continue; out.push({ time: t, position: tr[1] ? 'belowBar' : 'aboveBar', shape: 'circle', color: isDev ? '#ffd479' : '#8fe3b8', text: (isDev ? 'Dev ' : (tk.emoji || tk.name.slice(0, 8)) + ' ') + (tr[1] ? 'B' : 'S'), size: 0.8 }); } for (const x of paper.s.hist) { if (x.m !== mint) continue; const t = Math.floor(x.ts / 1000 / tf) * tf; if (t < first) continue; out.push({ time: t, position: x.buy ? 'belowBar' : 'aboveBar', shape: x.buy ? 'arrowUp' : 'arrowDown', color: '#4d8dff', text: x.buy ? 'You B' : 'You S', size: 1 }); } out.sort((a, b) => a.time - b.time); markers.setMarkers(out.slice(-300)); } // a price with many zeros: $0.0₄123 drawn with a real small figure (the text face has no subscript digits) function priceHtml(v) { const t = tinyUsd(v), m = t.match(/^\$0\.0([₀-₉]+)(\d+)$/); if (!m) return t; const n = [...m[1]].map(ch => '₀₁₂₃₄₅₆₇₈₉'.indexOf(ch)).join(''); return h('span', null, '$0.0', h('sub.z', null, n), m[2]); } function savePref() { store.set('chart', pref); } function mark(seg, btn) { for (const b of seg.children) b.classList.toggle('on', b === btn); } // ---------- band + side numbers ---------- function drawBand() { if (!d) return; const sol = live.sol || 0; ui.nm.textContent = d.n || d.s || short(mint); ui.sy.textContent = d.s; document.title = `${d.s || 'Coin'} ${usd(d.mc * sol)} · OScope`; if (d.img) setImg(ui.av, d.img); const ph = ui.av.querySelector('.ph'); if (ph && d.s) ph.textContent = d.s.replace(/^\$/, '').slice(0, 2).toUpperCase(); const ts = d.t || 0; ui.age.dataset.t = ts; ui.age.textContent = ts ? age(ts) : ''; if (!ui.soc._k || ui.soc._k !== d.tw + d.web + d.tg) { const s = socials(d, 15); s._k = d.tw + d.web + d.tg; ui.soc.replaceWith(s); ui.soc = s; } ui.mcv.textContent = usd(d.mc * sol); const kv = (k, v, cls) => h('div.kv', null, h('span.k', null, k), h('span.v' + (cls ? '.' + cls : ''), null, v)); ui.stats.replaceChildren( kv('Price', priceHtml(d.price * sol)), kv('Liquidity', usd(d.liq * sol)), kv('Volume', usd(d.vol * sol)), kv('Holders', d.seen ? count(d.h) : '–'), kv('Buys / sells', `${count(d.buys)} / ${count(d.sells)}`), kv('ATH', usd(d.ath * sol))); } function buildSide() { ui.side.replaceChildren(); // trade panel const bsB = h('button.b', { onclick: () => { side = 'b'; buildSide(); } }, 'Buy'); const bsS = h('button.s', { onclick: () => { side = 's'; buildSide(); } }, 'Sell'); (side === 'b' ? bsB : bsS).classList.add('on'); const s = settings.get(); ui.quote = h('div.quote'); ui.bal = h('span.mut'); let body; if (side === 'b') { if (amount === '') amount = String(s.buyPresets[1] ?? 0.5); const input = h('input', { value: amount, inputMode: 'decimal', oninput: e => { amount = e.target.value; markPresets(); drawQuote(); } }); const presets = h('div.presets'); const markPresets = () => { for (const b of presets.children) b.classList.toggle('on', +b.dataset.v === +amount); }; for (const v of s.buyPresets) presets.append(h('button', { 'data-v': v, onclick: () => { amount = String(v); input.value = amount; markPresets(); drawQuote(); } }, String(v))); markPresets(); ui.goBtn = h('button.go.b', { onclick: () => { learn(); paper.buy(mint, parseFloat(amount), { n: d?.n, s: d?.s, img: d?.img }); } }); body = [presets, h('label.amt', null, input, h('span', null, 'SOL')), ui.quote, ui.goBtn]; } else { const presets = h('div.presets'); for (const v of s.sellPresets) presets.append(h('button' + (Math.abs(v / 100 - sellFrac) < 1e-6 ? '.on' : ''), { onclick: e => { sellFrac = v / 100; for (const b of presets.children) b.classList.toggle('on', b === e.currentTarget); drawQuote(); } }, v + '%')); ui.goBtn = h('button.go.s', { onclick: () => { learn(); paper.sell(mint, sellFrac); } }); body = [presets, ui.quote, ui.goBtn]; } ui.side.append(h('div.card', null, h('div.bs', null, bsB, bsS), ...body, h('div.quote', null, h('span', null, 'Paper wallet'), ui.bal))); // position ui.pos = h('div.pos'); ui.side.append(h('div.card', null, h('div.card-h', null, 'Your position'), ui.pos)); // token info ui.info = h('div.grid4'); ui.prog = h('div.prog'); ui.side.append(h('div.card', null, h('div.card-h', null, 'Token info'), ui.info, ui.prog)); // dev + about ui.dev = h('div.card'); ui.about = h('div.card'); ui.side.append(ui.dev, ui.about); drawSide(); } function learn() { if (d) paper.learn(mint, { rq: d.rq, rt: d.rt, fee: d.fee, price: d.price, mc: d.mc, pool: d.pool, n: d.n, s: d.s, img: d.img }); } function drawQuote() { const sol = live.sol || 0; ui.bal.textContent = fsol(paper.s.sol) + ' SOL'; if (!d) { ui.quote.textContent = ''; ui.goBtn.textContent = side === 'b' ? 'Buy' : 'Sell'; return; } const q = { rq: d.rq, rt: d.rt, fee: d.fee }; if (side === 'b') { const a = parseFloat(amount); ui.goBtn.textContent = a > 0 ? `Buy ${a} SOL` : 'Buy'; if (a > 0 && q.rq > 0) { const tok = paper.quoteBuy(q, a); const avg = a / tok, spot = q.rq / q.rt; ui.quote.replaceChildren(h('span', null, 'You get ', h('b', null, compact(tok, 2) + ' ' + (d.s || ''))), h('span', null, 'Impact ', h('b', null, pct((avg / spot - 1) * 100, 2)))); } else ui.quote.replaceChildren(); } else { const p = paper.position(mint); const tok = p ? p.tok * sellFrac : 0; ui.goBtn.textContent = `Sell ${Math.round(sellFrac * 100)}%`; if (tok > 0 && q.rq > 0) { const got = paper.quoteSell(q, tok); ui.quote.replaceChildren(h('span', null, 'You get ', h('b', null, fsol(got) + ' SOL')), h('span', null, h('b', null, usd(got * sol)))); } else ui.quote.replaceChildren(h('span', null, 'No tokens to sell')); } } function drawSide() { drawQuote(); const sol = live.sol || 0; const p = paper.position(mint); const cell = (k, v, cls) => h('div', null, h('span.k', null, k), h('span.v' + (cls ? '.' + cls : ''), null, v)); if (p && (p.tok > 0 || p.spent > 0)) { const value = d ? p.tok * d.price : 0; const pnl = value + p.got - p.spent; const pp = p.spent ? pnl / p.spent * 100 : 0; ui.pos.replaceChildren( cell('Holding', `${usd(value * sol)}`), cell('Tokens', compact(p.tok, 2)), cell('Bought', fsol(p.spent) + ' SOL'), cell('Sold', fsol(p.got) + ' SOL'), cell('PnL', signed(pnl, x => fsol(x)) + ' SOL', pnl >= 0 ? 'up' : 'down'), cell('PnL %', signed(pp, x => x.toFixed(1) + '%'), pnl >= 0 ? 'up' : 'down')); } else ui.pos.replaceChildren(h('div', { style: { gridColumn: '1 / -1' } }, h('span.k', null, 'No position'), h('span.mut', null, 'Paper buys show here with live PnL.'))); if (!d) return; const c = (k, ic, v, cls) => h('div.cell' + (cls ? '.' + cls : ''), null, h('span.v', null, v), h('span.k', { html: icon(ic, 11) + k })); const known = d.top10 != null; ui.info.replaceChildren( c('Top 10', 'crown', known ? pct(d.top10) : '–', level(d.top10, 20, 40)), c('Dev', 'chef', !known ? '–' : d.ds ? 'Sold' : pct(d.dh), d.ds ? 'lv-m' : level(d.dh, 5, 15)), c('Snipers', 'target', known ? pct(d.sn) : '–', level(d.sn, 3, 15)), c('Bundle', 'boxes', known ? pct(d.bu) : '–', level(d.bu, 5, 20)), c('Holders', 'users', d.seen ? count(d.h) : '–'), c('Fees paid', 'bolt', fsol(d.fees) + ' SOL')); if (d.pool === 'pump' && !d.mig) { ui.prog.replaceChildren( h('div.prog-h', null, h('span', null, 'Bonding curve'), h('b', null, d.pr.toFixed(1) + '%')), h('div.prog-bar', null, h('i', { style: { width: Math.min(100, d.pr) + '%' } }))); } else ui.prog.replaceChildren(h('div.prog-h', null, h('span', null, d.mig ? 'Migrated ' + age(d.mig) + ' ago' : 'Trading on PumpSwap'), h('span.tag.mig', null, 'PumpSwap'))); // dev if (d.dev) { const t = trackers.name(d.dev); ui.dev.replaceChildren( h('div.card-h', null, 'Dev', h('a.btn.sm.ghost', { href: `/wallet/${d.dev}`, 'data-link': '', html: 'Wallet ' + icon('ext', 12) })), h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' } }, h('span.addr', { onclick: () => copy(d.dev, 'Dev address copied') }, short(d.dev, 6), iconEl('copy', 12)), t ? h('span.tag.tracked', null, t.name) : h('button.btn.sm', { onclick: () => { trackers.add(d.dev, 'Dev of ' + (d.s || short(mint))); drawSide(); } }, iconEl('plus', 12), 'Track')), h('div.quote', null, h('span', null, 'Coins seen from this dev'), h('b', null, `${d.dc} launched · ${d.dm} migrated`))); } else ui.dev.replaceChildren(h('div.card-h', null, 'Dev'), h('span.mut', null, 'Launched before OScope started watching.')); ui.about.replaceChildren(h('div.card-h', null, 'About'), d.desc ? h('div.desc', null, d.desc) : h('span.mut', null, 'No description.')); } // ---------- tabs ---------- function tradeRow(tr, fresh) { const sol = live.sol || 0; const [ts, buy, s, tok, , mc, w, sig] = tr; const isDev = d && w === d.dev; const tk = trackers.name(w); const tg = isDev ? h('span.tag.dev', null, 'Dev') : tk ? h('span.tag.tracked', null, tk.name) : d && holdersTag(w); const row = h('tr' + (fresh ? '.fresh' : ''), null, h('td.mut', { 'data-t': ts }, age(ts)), h('td', null, h('b.' + (buy ? 'up' : 'down'), null, buy ? 'Buy' : 'Sell')), h('td', null, usd(mc * sol)), h('td.r', null, compact(tok, 2)), h('td.r.' + (buy ? 'up' : 'down'), null, fsol(s)), h('td.r', null, usd(s * sol)), h('td', null, h('a.w', { href: `/wallet/${w}`, 'data-link': '' }, short(w, 4)), tg ? ' ' : '', tg || ''), h('td.r', null, sig ? h('a.ext', { href: `${SOLSCAN}/tx/${sig}`, target: '_blank', rel: 'noopener noreferrer', html: icon('ext', 13) }) : '')); return row; } function holdersTag(w) { if (!holdersData) return null; const x = holdersData.holders.find(y => y.w === w) || holdersData.traders.find(y => y.w === w); return x && x.tag ? h('span.tag.' + x.tag, null, x.tag) : null; } function tradesTable() { ui.tbody = h('tbody'); for (const tr of trades.slice(0, 200)) ui.tbody.append(tradeRow(tr)); return h('table.t', null, h('thead', null, h('tr', null, h('th', null, 'Age'), h('th', null, 'Type'), h('th', null, 'MC'), h('th.r', null, 'Amount'), h('th.r', null, 'SOL'), h('th.r', null, 'USD'), h('th', null, 'Trader'), h('th'))), ui.tbody); } function holdersTable(list, traders) { const sol = live.sol || 0; const tb = h('tbody'); list.forEach((x, i) => { const tk = trackers.name(x.w); const pnl = traders ? x.pnl : x.ss - x.bs + x.bal * (d?.price || 0); tb.append(h('tr', null, h('td.mut', null, i + 1), h('td', null, h('a.w', { href: `/wallet/${x.w}`, 'data-link': '' }, short(x.w, 5)), ' ', x.tag ? h('span.tag.' + x.tag, null, x.tag) : '', tk ? h('span.tag.tracked', null, tk.name) : ''), h('td.r', null, pct(x.pct, 2)), h('td.r', null, compact(x.bal, 2)), h('td.r.up', null, fsol(x.bs)), h('td.r.down', null, fsol(x.ss)), h('td.r.' + (pnl >= 0 ? 'up' : 'down'), null, signed(pnl * sol, v => usd(v))))); }); if (!list.length) return emptyState('No holders seen yet', 'Wallets that trade this coin while OScope runs are listed here.'); return h('table.t', null, h('thead', null, h('tr', null, h('th', null, '#'), h('th', null, 'Wallet'), h('th.r', null, 'Holds'), h('th.r', null, 'Amount'), h('th.r', null, 'Bought'), h('th.r', null, 'Sold'), h('th.r', null, 'PnL'))), tb); } async function drawTab() { const body = ui.tabBody; ui.tbody = null; if (tab === 'trades') body.replaceChildren(trades.length ? tradesTable() : emptyState('No trades yet', 'Trades stream in live.')); else if (tab === 'holders' || tab === 'traders') { if (!holdersData) body.replaceChildren(emptyState('Loading holders')); await loadHolders(); if (tab === 'holders' || tab === 'traders') body.replaceChildren(holdersTable(tab === 'holders' ? holdersData?.holders || [] : holdersData?.traders || [], tab === 'traders')); } else if (tab === 'dev') { if (!d?.dev) { body.replaceChildren(emptyState('Unknown dev', 'This coin launched before OScope started watching.')); return; } try { devList = await net.get(`/api/dev/${d.dev}`); } catch { devList = []; } if (tab !== 'dev') return; const sol = live.sol || 0; body.replaceChildren(devList.length ? h('div.dev-cards', null, devList.map(x => h('a.dc', { href: `/coin/${x.m}`, 'data-link': '' }, avatar({ m: x.m, s: x.s, img: x.img }, 36), h('div', null, h('b', null, x.n || x.s), ' ', h('span.mut', null, x.s), x.m === mint ? h('span.tag.you', { style: { marginLeft: '6px' } }, 'This coin') : '', h('div.mut', { style: { fontSize: '12px' } }, age(x.t) + ' ago')), x.mig ? h('span.tag.mig', null, 'Migrated') : h('span'), h('div.r', null, h('span', null, 'MC ', h('b', null, x.live ? usd(x.mc * sol) : '–')), h('span.mut', null, 'ATH ' + usd(x.ath * sol)))))) : emptyState('No other coins seen', 'Coins this wallet launches while OScope runs are listed here.')); } else if (tab === 'mine') { const mine = paper.s.hist.filter(x => x.m === mint); const sol = live.sol || 0; body.replaceChildren(mine.length ? h('table.t', null, h('thead', null, h('tr', null, h('th', null, 'Age'), h('th', null, 'Type'), h('th', null, 'MC'), h('th.r', null, 'Amount'), h('th.r', null, 'SOL'))), h('tbody', null, mine.map(x => h('tr.me', null, h('td.mut', null, age(x.ts)), h('td', null, h('b.' + (x.buy ? 'up' : 'down'), null, x.buy ? 'Buy' : 'Sell')), h('td', null, usd(x.mc * sol)), h('td.r', null, compact(x.tok, 2)), h('td.r', null, fsol(x.sol)))))) : emptyState('No paper trades on this coin', 'Use the panel on the right.')); } countTabs(); } function countTabs() { ui.tabBtn.trades._cnt.textContent = trades.length ? count(trades.length) : ''; ui.tabBtn.holders._cnt.textContent = d?.h ? count(d.h) : ''; ui.tabBtn.dev._cnt.textContent = d?.dc ? d.dc : ''; const mine = paper.s.hist.filter(x => x.m === mint).length; ui.tabBtn.mine._cnt.textContent = mine || ''; } let holdersAt = 0; async function loadHolders(force) { if (!force && Date.now() - holdersAt < 4000 && holdersData) return; holdersAt = Date.now(); try { holdersData = await net.get(`/api/holders/${mint}`); } catch {} } // ---------- data ---------- async function load() { try { d = await net.get(`/api/coin/${mint}`); } catch (e) { d = null; ui.nm.textContent = short(mint, 6); ui.chartNote.replaceChildren(emptyState('Not seen yet', 'OScope shows this coin the moment it trades.')); } if (!alive) return; if (d) { learn(); drawBand(); drawSide(); } try { trades = await net.get(`/api/trades/${mint}?limit=400`); } catch { trades = []; } await loadCandles(); if (!alive) return; drawTab(); loadHolders(true).then(() => { if (alive && tab === 'trades') drawTab(); }); } const offC = live.on('c', m => { if (m.d.m !== mint) return; const was = d; d = m.d; learn(); drawBand(); drawSide(); countTabs(); if (!was) loadCandles(); }); const offT = live.on('trs', m => { if (m.m !== mint) return; for (const tr of m.list) { trades.unshift(tr); liveCandle(tr); if (ui.tbody && tab === 'trades') { ui.tbody.prepend(tradeRow(tr, true)); if (ui.tbody.children.length > 300) ui.tbody.lastChild.remove(); } } if (trades.length > 2000) trades.length = 2000; if (tab === 'trades' && !ui.tbody && trades.length) drawTab(); if (m.list.some(tr => (d && tr[6] === d.dev) || trackers.name(tr[6]))) drawMarkers(); countTabs(); }); const offPaper = paper.on('trade', x => { if (x.m === mint) { drawSide(); drawMarkers(); if (tab === 'mine') drawTab(); countTabs(); } }); const offPx = paper.on('change', () => drawQuote()); const ageTick = setInterval(() => { const t = Date.now(); if (ui.age.dataset.t > 0) ui.age.textContent = age(+ui.age.dataset.t, t); if (ui.tbody) for (const td of ui.tbody.querySelectorAll('td[data-t]')) td.textContent = age(+td.dataset.t, t); if (tab === 'holders' || tab === 'traders') { loadHolders().then(() => { if (alive && (tab === 'holders' || tab === 'traders') && !ui.tabBody.matches(':hover')) ui.tabBody.replaceChildren(holdersTable(tab === 'holders' ? holdersData?.holders || [] : holdersData?.traders || [], tab === 'traders')); }); } }, 1000); const offSol = live.on('status', () => { if (d) { drawBand(); } }); net.coin(mint); load(); return () => { alive = false; net.coin(null); offC(); offT(); offPaper(); offPx(); offSol(); clearInterval(ageTick); chart.remove(); document.title = 'OScope'; }; }