| |
| 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; |
| 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); |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| 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))); |
| |
| |
| ui.side = h('div.side'); |
| page.append(ui.side); |
| buildSide(); |
| |
| |
| 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 ''; |
| 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(); |
| |
| 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)); |
| } |
| |
| 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); } |
| |
| |
| 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(); |
| |
| 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))); |
| |
| ui.pos = h('div.pos'); |
| ui.side.append(h('div.card', null, h('div.card-h', null, 'Your position'), ui.pos)); |
| |
| 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)); |
| |
| 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'))); |
| |
| 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.')); |
| } |
| |
| |
| 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 {} |
| } |
| |
| |
| 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'; |
| }; |
| } |