Back to OScope
oscope/public/js/coin.js
Code
477 lines (458 loc) · 25.6 KB
// 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';
};
}