mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-15 09:13:36 -07:00
website: redesign part 1
This commit is contained in:
@@ -1,142 +0,0 @@
|
||||
import { brk } from "../utils/client.js";
|
||||
import { latestPrice } from "../utils/price.js";
|
||||
import { createRow, formatBtc, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
|
||||
|
||||
/** @type {HTMLDivElement} */ let el;
|
||||
/** @type {HTMLElement[]} */ let valueEls;
|
||||
/** @type {HTMLDivElement} */ let txSection;
|
||||
/** @type {string} */ let currentAddr = "";
|
||||
|
||||
const ROW_LABELS = [
|
||||
"Address",
|
||||
"Confirmed Balance",
|
||||
"Pending",
|
||||
"Confirmed UTXOs",
|
||||
"Pending UTXOs",
|
||||
"Total Received",
|
||||
"Tx Count",
|
||||
"Type",
|
||||
"Avg Cost Basis",
|
||||
];
|
||||
|
||||
/** @param {HTMLElement} parent @param {(e: MouseEvent) => void} linkHandler */
|
||||
export function initAddrDetails(parent, linkHandler) {
|
||||
el = document.createElement("div");
|
||||
el.id = "addr-details";
|
||||
el.hidden = true;
|
||||
parent.append(el);
|
||||
el.addEventListener("click", linkHandler);
|
||||
|
||||
const title = document.createElement("h1");
|
||||
title.textContent = "Address";
|
||||
el.append(title);
|
||||
|
||||
valueEls = ROW_LABELS.map((label) => {
|
||||
const { row, valueEl } = createRow(label);
|
||||
el.append(row);
|
||||
return valueEl;
|
||||
});
|
||||
|
||||
txSection = document.createElement("div");
|
||||
txSection.classList.add("transactions");
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Transactions";
|
||||
txSection.append(heading);
|
||||
el.append(txSection);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} address
|
||||
* @param {AbortSignal} signal
|
||||
*/
|
||||
export async function update(address, signal) {
|
||||
currentAddr = address;
|
||||
valueEls[0].textContent = address;
|
||||
for (let i = 1; i < valueEls.length; i++) {
|
||||
valueEls[i].textContent = "...";
|
||||
valueEls[i].classList.add("dim");
|
||||
}
|
||||
while (txSection.children.length > 1) txSection.lastChild?.remove();
|
||||
|
||||
try {
|
||||
const stats = await brk.getAddress(address, { signal });
|
||||
if (signal.aborted || currentAddr !== address) return;
|
||||
|
||||
const chain = stats.chainStats;
|
||||
const balance = chain.fundedTxoSum - chain.spentTxoSum;
|
||||
const mempool = stats.mempoolStats;
|
||||
const pending = mempool ? mempool.fundedTxoSum - mempool.spentTxoSum : 0;
|
||||
const pendingUtxos = mempool
|
||||
? mempool.fundedTxoCount - mempool.spentTxoCount
|
||||
: 0;
|
||||
const confirmedUtxos = chain.fundedTxoCount - chain.spentTxoCount;
|
||||
const price = latestPrice();
|
||||
const fmtUsd = (/** @type {number} */ sats) =>
|
||||
price
|
||||
? ` $${((sats / 1e8) * price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
: "";
|
||||
|
||||
const values = [
|
||||
address,
|
||||
`${formatBtc(balance)} BTC${fmtUsd(balance)}`,
|
||||
`${pending >= 0 ? "+" : ""}${formatBtc(pending)} BTC${fmtUsd(pending)}`,
|
||||
confirmedUtxos.toLocaleString(),
|
||||
pendingUtxos.toLocaleString(),
|
||||
`${formatBtc(chain.fundedTxoSum)} BTC`,
|
||||
chain.txCount.toLocaleString(),
|
||||
stats.addrType.replace(/^v\d+_/, "").toUpperCase(),
|
||||
chain.realizedPrice
|
||||
? `$${Number(chain.realizedPrice).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
: "N/A",
|
||||
];
|
||||
|
||||
for (let i = 0; i < valueEls.length; i++) {
|
||||
valueEls[i].textContent = values[i];
|
||||
valueEls[i].classList.remove("dim");
|
||||
}
|
||||
|
||||
let loading = false;
|
||||
let pageIndex = 0;
|
||||
/** @type {string | undefined} */
|
||||
let afterTxid;
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (
|
||||
entries[0].isIntersecting &&
|
||||
!loading &&
|
||||
pageIndex * TX_PAGE_SIZE < chain.txCount
|
||||
)
|
||||
loadMore();
|
||||
});
|
||||
|
||||
async function loadMore() {
|
||||
if (currentAddr !== address) return;
|
||||
loading = true;
|
||||
try {
|
||||
const txs = afterTxid
|
||||
? await brk.getAddressConfirmedTxsAfter(address, afterTxid, { signal })
|
||||
: await brk.getAddressTxs(address, { signal });
|
||||
if (currentAddr !== address) return;
|
||||
for (const tx of txs) txSection.append(renderTx(tx));
|
||||
pageIndex++;
|
||||
if (txs.length) {
|
||||
afterTxid = txs[txs.length - 1].txid;
|
||||
observer.disconnect();
|
||||
const last = txSection.lastElementChild;
|
||||
if (last) observer.observe(last);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!signal.aborted) console.error("explorer addr txs:", e);
|
||||
pageIndex = chain.txCount;
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
await loadMore();
|
||||
} catch (e) {
|
||||
if (!signal.aborted) console.error("explorer addr:", e);
|
||||
}
|
||||
}
|
||||
|
||||
export function show() { showPanel(el); }
|
||||
export function hide() { hidePanel(el); }
|
||||
@@ -1,210 +0,0 @@
|
||||
import { brk } from "../utils/client.js";
|
||||
import { createPersistedValue } from "../utils/persisted.js";
|
||||
import { createRow, formatFeeRate, formatHeightPrefix, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
|
||||
|
||||
/** @typedef {[string, (b: BlockInfoV1) => string | null, ((b: BlockInfoV1) => string | null)?]} RowDef */
|
||||
|
||||
/** @param {(x: BlockInfoV1["extras"]) => string | null} fn @returns {(b: BlockInfoV1) => string | null} */
|
||||
const ext = (fn) => (b) => fn(b.extras);
|
||||
|
||||
/** @type {RowDef[]} */
|
||||
const ROW_DEFS = [
|
||||
["Hash", (b) => b.id, (b) => `/block/${b.id}`],
|
||||
["Previous Hash", (b) => b.previousblockhash, (b) => `/block/${b.previousblockhash}`],
|
||||
["Merkle Root", (b) => b.merkleRoot],
|
||||
["Timestamp", (b) => new Date(b.timestamp * 1000).toUTCString()],
|
||||
["Median Time", (b) => new Date(b.mediantime * 1000).toUTCString()],
|
||||
["Version", (b) => `0x${b.version.toString(16)}`],
|
||||
["Bits", (b) => b.bits.toString(16)],
|
||||
["Nonce", (b) => b.nonce.toLocaleString()],
|
||||
["Difficulty", (b) => Number(b.difficulty).toLocaleString()],
|
||||
["Size", (b) => `${(b.size / 1_000_000).toFixed(2)} MB`],
|
||||
["Weight", (b) => `${(b.weight / 1_000_000).toFixed(2)} MWU`],
|
||||
["Transactions", (b) => b.txCount.toLocaleString()],
|
||||
["Price", ext((x) => `$${x.price.toLocaleString()}`)],
|
||||
["Pool", ext((x) => x.pool.name)],
|
||||
["Pool ID", ext((x) => x.pool.id.toString())],
|
||||
["Pool Slug", ext((x) => x.pool.slug)],
|
||||
["Miner Names", ext((x) => x.pool.minerNames?.join(", ") || null)],
|
||||
["Reward", ext((x) => `${(x.reward / 1e8).toFixed(8)} BTC`)],
|
||||
["Total Fees", ext((x) => `${(x.totalFees / 1e8).toFixed(8)} BTC`)],
|
||||
["Median Fee Rate", ext((x) => `${formatFeeRate(x.medianFee)} sat/vB`)],
|
||||
["Avg Fee Rate", ext((x) => `${formatFeeRate(x.avgFeeRate)} sat/vB`)],
|
||||
["Avg Fee", ext((x) => `${x.avgFee.toLocaleString()} sat`)],
|
||||
["Median Fee", ext((x) => `${x.medianFeeAmt.toLocaleString()} sat`)],
|
||||
["Fee Range", ext((x) => x.feeRange.map(formatFeeRate).join(", ") + " sat/vB")],
|
||||
["Fee Percentiles", ext((x) => x.feePercentiles.map((f) => f.toLocaleString()).join(", ") + " sat")],
|
||||
["Avg Tx Size", ext((x) => `${x.avgTxSize.toLocaleString()} B`)],
|
||||
["Virtual Size", ext((x) => `${x.virtualSize.toLocaleString()} vB`)],
|
||||
["Inputs", ext((x) => x.totalInputs.toLocaleString())],
|
||||
["Outputs", ext((x) => x.totalOutputs.toLocaleString())],
|
||||
["Total Input Amount", ext((x) => `${(x.totalInputAmt / 1e8).toFixed(8)} BTC`)],
|
||||
["Total Output Amount", ext((x) => `${(x.totalOutputAmt / 1e8).toFixed(8)} BTC`)],
|
||||
["UTXO Set Change", ext((x) => x.utxoSetChange.toLocaleString())],
|
||||
["UTXO Set Size", ext((x) => x.utxoSetSize.toLocaleString())],
|
||||
["SegWit Txs", ext((x) => x.segwitTotalTxs.toLocaleString())],
|
||||
["SegWit Size", ext((x) => `${x.segwitTotalSize.toLocaleString()} B`)],
|
||||
["SegWit Weight", ext((x) => `${x.segwitTotalWeight.toLocaleString()} WU`)],
|
||||
["Coinbase Address", ext((x) => x.coinbaseAddress || null)],
|
||||
["Coinbase Addresses", ext((x) => x.coinbaseAddresses.join(", ") || null)],
|
||||
["Coinbase Raw", ext((x) => x.coinbaseRaw)],
|
||||
["Coinbase Signature", ext((x) => x.coinbaseSignature)],
|
||||
["Coinbase Signature ASCII", ext((x) => x.coinbaseSignatureAscii)],
|
||||
["Header", ext((x) => x.header)],
|
||||
];
|
||||
|
||||
/** @typedef {{ first: HTMLButtonElement, prev: HTMLButtonElement, label: HTMLSpanElement, next: HTMLButtonElement, last: HTMLButtonElement }} TxNav */
|
||||
|
||||
/** @type {HTMLDivElement} */ let el;
|
||||
/** @type {HTMLSpanElement} */ let heightPrefix;
|
||||
/** @type {HTMLSpanElement} */ let heightNum;
|
||||
/** @type {{ row: HTMLDivElement, valueEl: HTMLElement }[]} */ let detailRows;
|
||||
/** @type {HTMLDivElement} */ let txList;
|
||||
/** @type {HTMLDivElement} */ let txSection;
|
||||
/** @type {IntersectionObserver} */ let txObserver;
|
||||
/** @type {TxNav[]} */ const txNavs = [];
|
||||
/** @type {BlockInfoV1 | null} */ let txBlock = null;
|
||||
let txTotalPages = 0;
|
||||
let txLoading = false;
|
||||
let txLoaded = false;
|
||||
|
||||
const txPageParam = createPersistedValue({
|
||||
defaultValue: 0,
|
||||
urlKey: "page",
|
||||
serialize: (v) => String(v + 1),
|
||||
deserialize: (s) => Math.max(0, Number(s) - 1),
|
||||
});
|
||||
|
||||
/** @param {HTMLElement} parent @param {(e: MouseEvent) => void} linkHandler */
|
||||
export function initBlockDetails(parent, linkHandler) {
|
||||
el = document.createElement("div");
|
||||
el.id = "block-details";
|
||||
parent.append(el);
|
||||
|
||||
const title = document.createElement("h1");
|
||||
title.textContent = "Block ";
|
||||
const code = document.createElement("code");
|
||||
const container = document.createElement("span");
|
||||
heightPrefix = document.createElement("span");
|
||||
heightPrefix.classList.add("dim");
|
||||
heightPrefix.style.userSelect = "none";
|
||||
heightNum = document.createElement("span");
|
||||
container.append(heightPrefix, heightNum);
|
||||
code.append(container);
|
||||
title.append(code);
|
||||
el.append(title);
|
||||
|
||||
el.addEventListener("click", linkHandler);
|
||||
|
||||
detailRows = ROW_DEFS.map(([label, , linkFn]) => {
|
||||
const { row, valueEl } = createRow(label, Boolean(linkFn));
|
||||
el.append(row);
|
||||
return { row, valueEl };
|
||||
});
|
||||
|
||||
txSection = document.createElement("div");
|
||||
txSection.classList.add("transactions");
|
||||
el.append(txSection);
|
||||
|
||||
const txHeader = document.createElement("div");
|
||||
txHeader.classList.add("tx-header");
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Transactions";
|
||||
txHeader.append(heading, createTxNav());
|
||||
txSection.append(txHeader);
|
||||
|
||||
txList = document.createElement("div");
|
||||
txList.classList.add("tx-list");
|
||||
txSection.append(txList, createTxNav());
|
||||
|
||||
txObserver = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && !txLoaded) {
|
||||
loadTxPage(txPageParam.value, false);
|
||||
}
|
||||
});
|
||||
txObserver.observe(txSection);
|
||||
}
|
||||
|
||||
function createTxNav() {
|
||||
const nav = document.createElement("div");
|
||||
nav.classList.add("pagination");
|
||||
const first = document.createElement("button");
|
||||
first.textContent = "\u00AB";
|
||||
const prev = document.createElement("button");
|
||||
prev.textContent = "\u2190";
|
||||
const label = document.createElement("span");
|
||||
const next = document.createElement("button");
|
||||
next.textContent = "\u2192";
|
||||
const last = document.createElement("button");
|
||||
last.textContent = "\u00BB";
|
||||
nav.append(first, prev, label, next, last);
|
||||
first.addEventListener("click", () => loadTxPage(0));
|
||||
prev.addEventListener("click", () => loadTxPage(txPageParam.value - 1));
|
||||
next.addEventListener("click", () => loadTxPage(txPageParam.value + 1));
|
||||
last.addEventListener("click", () => loadTxPage(txTotalPages - 1));
|
||||
txNavs.push({ first, prev, label, next, last });
|
||||
return nav;
|
||||
}
|
||||
|
||||
/** @param {number} page */
|
||||
function updateTxNavs(page) {
|
||||
const atFirst = page <= 0;
|
||||
const atLast = page >= txTotalPages - 1;
|
||||
for (const n of txNavs) {
|
||||
n.label.textContent = `${page + 1} / ${txTotalPages}`;
|
||||
n.first.disabled = atFirst;
|
||||
n.prev.disabled = atFirst;
|
||||
n.next.disabled = atLast;
|
||||
n.last.disabled = atLast;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {BlockInfoV1} block */
|
||||
export function update(block) {
|
||||
heightPrefix.textContent = formatHeightPrefix(block.height);
|
||||
heightNum.textContent = block.height.toString();
|
||||
|
||||
ROW_DEFS.forEach(([, getter, linkFn], i) => {
|
||||
const value = getter(block);
|
||||
const { row, valueEl } = detailRows[i];
|
||||
if (value !== null) {
|
||||
valueEl.textContent = value;
|
||||
if (linkFn)
|
||||
/** @type {HTMLAnchorElement} */ (valueEl).href = linkFn(block) ?? "";
|
||||
row.hidden = false;
|
||||
} else {
|
||||
row.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
txBlock = block;
|
||||
txTotalPages = Math.ceil(block.txCount / TX_PAGE_SIZE);
|
||||
if (txLoaded) txPageParam.setImmediate(0);
|
||||
txLoaded = false;
|
||||
updateTxNavs(txPageParam.value);
|
||||
txList.innerHTML = "";
|
||||
txObserver.disconnect();
|
||||
txObserver.observe(txSection);
|
||||
}
|
||||
|
||||
export function show() { showPanel(el); }
|
||||
export function hide() { hidePanel(el); }
|
||||
|
||||
/** @param {number} page @param {boolean} [pushUrl] */
|
||||
async function loadTxPage(page, pushUrl = true) {
|
||||
const block = txBlock;
|
||||
if (txLoading || !block || page < 0 || page >= txTotalPages) return;
|
||||
txLoading = true;
|
||||
txLoaded = true;
|
||||
if (pushUrl) txPageParam.setImmediate(page);
|
||||
updateTxNavs(page);
|
||||
try {
|
||||
const txs = await brk.getBlockTxsFromIndex(block.id, page * TX_PAGE_SIZE);
|
||||
txList.innerHTML = "";
|
||||
const ascii = block.extras.coinbaseSignatureAscii;
|
||||
for (const tx of txs) txList.append(renderTx(tx, ascii));
|
||||
} catch (e) {
|
||||
console.error("explorer txs:", e);
|
||||
}
|
||||
txLoading = false;
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
import { brk } from "../utils/client.js";
|
||||
import { onPlainClick } from "../utils/dom.js";
|
||||
import { createCube } from "./cube.js";
|
||||
import { initMempool, renderMempool } from "./mempool.js";
|
||||
import { createHeightElement, formatFeeRate } from "./render.js";
|
||||
|
||||
const LOOKAHEAD = 15;
|
||||
|
||||
/** @type {HTMLDivElement} */ let chainEl;
|
||||
/** @type {HTMLDivElement} */ let scrollEl;
|
||||
/** @type {HTMLDivElement} */ let blocksEl;
|
||||
/** @type {HTMLAnchorElement | null} */ let selectedCube = null;
|
||||
/** @type {IntersectionObserver} */ let olderObserver;
|
||||
/** @type {(block: BlockInfoV1) => void} */ let onSelect = () => {};
|
||||
/** @type {(cube: HTMLAnchorElement) => void} */ let onCubeClick = () => {};
|
||||
/** @type {() => void} */ let onTip = () => {};
|
||||
/** @type {() => void} */ let onGenesis = () => {};
|
||||
|
||||
/** @type {Map<BlockHash, BlockInfoV1>} */
|
||||
const blocksByHash = new Map();
|
||||
|
||||
let newestHeight = -1;
|
||||
let oldestHeight = Infinity;
|
||||
let loadingOlder = false;
|
||||
let loadingNewer = false;
|
||||
let reachedTip = false;
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} parent
|
||||
* @param {{
|
||||
* onSelect: (block: BlockInfoV1) => void,
|
||||
* onCubeClick: (cube: HTMLAnchorElement) => void,
|
||||
* onTip: () => void,
|
||||
* onGenesis: () => void,
|
||||
* }} callbacks
|
||||
*/
|
||||
export function initChain(parent, callbacks) {
|
||||
onSelect = callbacks.onSelect;
|
||||
onCubeClick = callbacks.onCubeClick;
|
||||
onTip = callbacks.onTip;
|
||||
onGenesis = callbacks.onGenesis;
|
||||
|
||||
chainEl = document.createElement("div");
|
||||
chainEl.id = "chain";
|
||||
parent.append(chainEl);
|
||||
|
||||
chainEl.append(
|
||||
createControlLink("tip", "/block/tip", "Jump to chain tip", onTip),
|
||||
);
|
||||
|
||||
chainEl.append(
|
||||
createControlLink("gen", "/block/0", "Jump to genesis block", onGenesis),
|
||||
);
|
||||
|
||||
scrollEl = document.createElement("div");
|
||||
scrollEl.classList.add("chain-scroll");
|
||||
chainEl.append(scrollEl);
|
||||
|
||||
blocksEl = document.createElement("div");
|
||||
blocksEl.classList.add("blocks");
|
||||
scrollEl.append(blocksEl);
|
||||
|
||||
initMempool(scrollEl);
|
||||
|
||||
olderObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) loadOlder();
|
||||
},
|
||||
{ root: scrollEl },
|
||||
);
|
||||
|
||||
scrollEl.addEventListener(
|
||||
"scroll",
|
||||
() => {
|
||||
if (reachedTip || loadingNewer) return;
|
||||
if (scrollEl.scrollTop <= 50 && scrollEl.scrollLeft <= 50) loadNewer();
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {BlockHash | Height | null} [hashOrHeight] */
|
||||
function findCube(hashOrHeight) {
|
||||
if (hashOrHeight == null) {
|
||||
return reachedTip && newestHeight >= 0
|
||||
? /** @type {HTMLAnchorElement | null} */ (blocksEl.lastElementChild)
|
||||
: null;
|
||||
}
|
||||
const attr = typeof hashOrHeight === "number" ? "height" : "hash";
|
||||
return /** @type {HTMLAnchorElement | null} */ (
|
||||
blocksEl.querySelector(`[data-${attr}="${hashOrHeight}"]`)
|
||||
);
|
||||
}
|
||||
|
||||
export function deselectCube() {
|
||||
if (selectedCube) selectedCube.classList.remove("selected");
|
||||
selectedCube = null;
|
||||
}
|
||||
|
||||
/** @param {HTMLAnchorElement} cube @param {{ scroll?: "smooth" | "instant", silent?: boolean }} [opts] */
|
||||
export function selectCube(cube, { scroll, silent } = {}) {
|
||||
const changed = cube !== selectedCube;
|
||||
if (changed) {
|
||||
if (selectedCube) selectedCube.classList.remove("selected");
|
||||
selectedCube = cube;
|
||||
cube.classList.add("selected");
|
||||
}
|
||||
if (scroll) {
|
||||
cube.scrollIntoView({ behavior: scroll, block: "center", inline: "center" });
|
||||
}
|
||||
if (!silent) {
|
||||
const hash = cube.dataset.hash;
|
||||
if (hash) {
|
||||
const block = blocksByHash.get(hash);
|
||||
if (block) onSelect(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
newestHeight = -1;
|
||||
oldestHeight = Infinity;
|
||||
loadingOlder = false;
|
||||
loadingNewer = false;
|
||||
reachedTip = false;
|
||||
selectedCube = null;
|
||||
blocksEl.innerHTML = "";
|
||||
olderObserver.disconnect();
|
||||
}
|
||||
|
||||
function observeOldestEdge() {
|
||||
olderObserver.disconnect();
|
||||
const oldest = blocksEl.firstElementChild;
|
||||
if (oldest) olderObserver.observe(oldest);
|
||||
}
|
||||
|
||||
/** @param {BlockInfoV1[]} blocks */
|
||||
function appendNewerBlocks(blocks) {
|
||||
if (!blocks.length) return false;
|
||||
const anchor = blocksEl.lastElementChild;
|
||||
const anchorRect = anchor?.getBoundingClientRect();
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
const b = blocks[i];
|
||||
if (b.height > newestHeight) {
|
||||
appendCube(createBlockCube(b));
|
||||
} else {
|
||||
blocksByHash.set(b.id, b);
|
||||
}
|
||||
}
|
||||
newestHeight = Math.max(newestHeight, blocks[0].height);
|
||||
|
||||
if (anchor && anchorRect) {
|
||||
const r = anchor.getBoundingClientRect();
|
||||
scrollEl.scrollTop += r.top - anchorRect.top;
|
||||
scrollEl.scrollLeft += r.left - anchorRect.left;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param {number | null} [height] @returns {Promise<BlockHash>} */
|
||||
async function loadInitial(height) {
|
||||
const blocks =
|
||||
height != null
|
||||
? await brk.getBlocksV1FromHeight(height)
|
||||
: await brk.getBlocksV1();
|
||||
|
||||
clear();
|
||||
for (const b of blocks) prependCube(createBlockCube(b));
|
||||
newestHeight = blocks[0].height;
|
||||
oldestHeight = blocks[blocks.length - 1].height;
|
||||
reachedTip = height == null;
|
||||
observeOldestEdge();
|
||||
|
||||
if (!reachedTip) await loadNewer();
|
||||
return blocks[0].id;
|
||||
}
|
||||
|
||||
/** @param {BlockHash | Height | null} [hashOrHeight] @returns {Promise<Height | null>} */
|
||||
async function resolveHeight(hashOrHeight) {
|
||||
if (typeof hashOrHeight === "number") return hashOrHeight;
|
||||
if (typeof hashOrHeight === "string") {
|
||||
const cached = blocksByHash.get(hashOrHeight);
|
||||
if (cached) return cached.height;
|
||||
const block = await brk.getBlockV1(hashOrHeight);
|
||||
blocksByHash.set(hashOrHeight, block);
|
||||
return block.height;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param {BlockHash | Height | string | null} [hashOrHeight] @param {{ silent?: boolean }} [options] */
|
||||
export async function goToCube(hashOrHeight, { silent } = {}) {
|
||||
if (hashOrHeight === "tip") hashOrHeight = null;
|
||||
if (typeof hashOrHeight === "string" && /^\d+$/.test(hashOrHeight)) {
|
||||
hashOrHeight = Number(hashOrHeight);
|
||||
}
|
||||
let cube = findCube(hashOrHeight);
|
||||
if (cube) {
|
||||
selectCube(cube, { scroll: "smooth", silent });
|
||||
return;
|
||||
}
|
||||
for (const cube of blocksEl.children) cube.classList.add("skeleton");
|
||||
let startHash;
|
||||
try {
|
||||
const height = await resolveHeight(hashOrHeight);
|
||||
startHash = await loadInitial(height);
|
||||
} catch (e) {
|
||||
try { startHash = await loadInitial(null); } catch (_) { return; }
|
||||
}
|
||||
selectCube(/** @type {HTMLAnchorElement} */ (findCube(startHash)), { scroll: "instant", silent });
|
||||
}
|
||||
|
||||
export async function poll() {
|
||||
if (!reachedTip) return;
|
||||
brk.getMempoolBlocks()
|
||||
.then(renderMempool)
|
||||
.catch((e) => console.error("mempool poll:", e));
|
||||
try {
|
||||
const blocks = await brk.getBlocksV1();
|
||||
appendNewerBlocks(blocks);
|
||||
} catch (e) {
|
||||
console.error("explorer poll:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
if (loadingOlder || oldestHeight <= 0) return;
|
||||
loadingOlder = true;
|
||||
try {
|
||||
const blocks = await brk.getBlocksV1FromHeight(oldestHeight - 1);
|
||||
for (const block of blocks) prependCube(createBlockCube(block));
|
||||
if (blocks.length) {
|
||||
oldestHeight = blocks[blocks.length - 1].height;
|
||||
observeOldestEdge();
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error("explorer loadOlder:", e);
|
||||
}
|
||||
loadingOlder = false;
|
||||
}
|
||||
|
||||
async function loadNewer() {
|
||||
if (loadingNewer || newestHeight === -1 || reachedTip) return;
|
||||
loadingNewer = true;
|
||||
try {
|
||||
const prevNewest = newestHeight;
|
||||
const blocks = await brk.getBlocksV1FromHeight(newestHeight + LOOKAHEAD);
|
||||
if (!appendNewerBlocks(blocks) || newestHeight === prevNewest) {
|
||||
reachedTip = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("explorer loadNewer:", e);
|
||||
}
|
||||
loadingNewer = false;
|
||||
}
|
||||
|
||||
/** @param {string} name */
|
||||
const poolSlug = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
|
||||
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
|
||||
/** @param {number} unixSec */
|
||||
function formatShortDate(unixSec) {
|
||||
const d = new Date(unixSec * 1000);
|
||||
return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
|
||||
}
|
||||
|
||||
/** @param {number} unixSec */
|
||||
function formatHHMM(unixSec) {
|
||||
const d = new Date(unixSec * 1000);
|
||||
return [String(d.getHours()).padStart(2, "0"), String(d.getMinutes()).padStart(2, "0")];
|
||||
}
|
||||
|
||||
/** @param {string} text @param {string} [cls] */
|
||||
function span(text, cls) {
|
||||
const s = document.createElement("span");
|
||||
if (cls) s.classList.add(cls);
|
||||
s.textContent = text;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** @param {BlockInfoV1} block */
|
||||
function createBlockCube(block) {
|
||||
const cubeElement = document.createElement("a");
|
||||
cubeElement.classList.add("cube");
|
||||
cubeElement.href = `/block/${block.id}`;
|
||||
cubeElement.dataset.hash = block.id;
|
||||
cubeElement.dataset.height = String(block.height);
|
||||
cubeElement.dataset.timestamp = String(block.timestamp);
|
||||
|
||||
const { pool, medianFee, feeRange, virtualSize } = block.extras;
|
||||
const fill = Math.min(1, virtualSize / 1_000_000);
|
||||
const { topFace, rightFace, leftFace } = createCube(cubeElement, fill);
|
||||
blocksByHash.set(block.id, block);
|
||||
onPlainClick(cubeElement, () => onCubeClick(cubeElement));
|
||||
|
||||
const minerName = pool.name;
|
||||
|
||||
// Top: short date / HH:MM (colon dimmed).
|
||||
const dateP = document.createElement("p");
|
||||
dateP.textContent = formatShortDate(block.timestamp);
|
||||
const [hh, mm] = formatHHMM(block.timestamp);
|
||||
const timeP = document.createElement("p");
|
||||
timeP.append(hh, span(":", "dim"), mm);
|
||||
topFace.append(dateP, timeP);
|
||||
|
||||
// Right: block height / raw pool-logo + miner name.
|
||||
const heightP = document.createElement("p");
|
||||
heightP.classList.add("height");
|
||||
heightP.append(createHeightElement(block.height));
|
||||
const poolDiv = document.createElement("div");
|
||||
poolDiv.classList.add("pool");
|
||||
const logo = document.createElement("img");
|
||||
logo.src = `/assets/pools/${poolSlug(minerName)}.svg`;
|
||||
logo.alt = "";
|
||||
logo.onerror = () => {
|
||||
logo.onerror = null;
|
||||
logo.src = "/assets/pools/default.svg";
|
||||
};
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.textContent = minerName.replace(/\s+(Pool|USA)$/i, "").trim();
|
||||
poolDiv.append(logo, nameSpan);
|
||||
rightFace.append(heightP, poolDiv);
|
||||
|
||||
// Left: ~median / min-max / sat/vB fees stack.
|
||||
const feesEl = document.createElement("div");
|
||||
feesEl.classList.add("fees");
|
||||
const avg = document.createElement("p");
|
||||
avg.append(span("~", "dim"), formatFeeRate(medianFee));
|
||||
const range = document.createElement("p");
|
||||
range.append(
|
||||
formatFeeRate(feeRange[0]),
|
||||
span("-", "dim"),
|
||||
formatFeeRate(feeRange[6]),
|
||||
);
|
||||
const unit = document.createElement("p");
|
||||
unit.classList.add("dim");
|
||||
unit.textContent = "sat/vB";
|
||||
feesEl.append(avg, range, unit);
|
||||
leftFace.append(feesEl);
|
||||
|
||||
return cubeElement;
|
||||
}
|
||||
|
||||
/** @param {HTMLElement} cube */
|
||||
function setGap(cube) {
|
||||
const prev = /** @type {HTMLElement | null} */ (cube.previousElementSibling);
|
||||
if (!prev) return;
|
||||
const dt = Math.max(0, Number(cube.dataset.timestamp) - Number(prev.dataset.timestamp));
|
||||
cube.style.setProperty("--dt", String(dt));
|
||||
}
|
||||
|
||||
/** @param {HTMLAnchorElement} cube */
|
||||
function prependCube(cube) {
|
||||
const next = /** @type {HTMLElement | null} */ (blocksEl.firstElementChild);
|
||||
blocksEl.prepend(cube);
|
||||
if (next) setGap(next);
|
||||
}
|
||||
|
||||
/** @param {HTMLAnchorElement} cube */
|
||||
function appendCube(cube) {
|
||||
blocksEl.append(cube);
|
||||
setGap(cube);
|
||||
}
|
||||
|
||||
/** @param {"tip" | "gen"} label @param {string} href @param {string} title @param {() => void} handler */
|
||||
function createControlLink(label, href, title, handler) {
|
||||
const a = document.createElement("a");
|
||||
a.classList.add("chain-edge", label);
|
||||
a.href = href;
|
||||
a.title = title;
|
||||
a.textContent = label;
|
||||
onPlainClick(a, handler);
|
||||
return a;
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* HTML cube generator. Populates a .cube element with 15 face divs
|
||||
* styled in explorer.css. Uses pure CSS transforms (no SVG); the
|
||||
* earlier SVG-based implementation broke in Safari due to its
|
||||
* long-standing bugs around SVG transforms on <foreignObject>.
|
||||
*
|
||||
* Face order = z-order:
|
||||
* 3× .glass rear — translucent glass back faces
|
||||
* 3× .liquid rear — opaque liquid backing (hidden at fill 0)
|
||||
* 3× .liquid front — opaque liquid front (the visible 3 faces)
|
||||
* 3× .glass front — translucent glass front
|
||||
* 3× .face-text — text overlays (top / right / left)
|
||||
*
|
||||
* @param {HTMLElement} cube
|
||||
* @param {number} [fill]
|
||||
* @returns {{ topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }}
|
||||
*/
|
||||
export function createCube(cube, fill = 1) {
|
||||
cube.style.setProperty("--fill", String(fill));
|
||||
|
||||
/** @param {...string} cls */
|
||||
const face = (...cls) => {
|
||||
const d = document.createElement("div");
|
||||
d.className = `face ${cls.join(" ")}`;
|
||||
return /** @type {HTMLDivElement} */ (d);
|
||||
};
|
||||
|
||||
const topFace = face("face-text", "top");
|
||||
const rightFace = face("face-text", "right");
|
||||
const leftFace = face("face-text", "left");
|
||||
|
||||
cube.append(
|
||||
face("glass", "bottom"),
|
||||
face("glass", "rear-right"),
|
||||
face("glass", "rear-left"),
|
||||
face("liquid", "bottom"),
|
||||
face("liquid", "rear-right"),
|
||||
face("liquid", "rear-left"),
|
||||
face("liquid", "right"),
|
||||
face("liquid", "left"),
|
||||
face("liquid", "top"),
|
||||
face("glass", "right"),
|
||||
face("glass", "left"),
|
||||
face("glass", "top"),
|
||||
rightFace,
|
||||
leftFace,
|
||||
topFace,
|
||||
);
|
||||
|
||||
return { topFace, rightFace, leftFace };
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { explorerElement } from "../utils/elements.js";
|
||||
import { brk } from "../utils/client.js";
|
||||
import {
|
||||
initChain,
|
||||
goToCube,
|
||||
poll,
|
||||
selectCube,
|
||||
deselectCube,
|
||||
} from "../../src/explorer/chain/index.js";
|
||||
import {
|
||||
initBlockDetails,
|
||||
update as updateBlock,
|
||||
show as showBlock,
|
||||
hide as hideBlock,
|
||||
} from "./block.js";
|
||||
import {
|
||||
initTxDetails,
|
||||
update as updateTx,
|
||||
clear as clearTx,
|
||||
show as showTx,
|
||||
hide as hideTx,
|
||||
} from "./tx.js";
|
||||
import {
|
||||
initAddrDetails,
|
||||
update as updateAddr,
|
||||
show as showAddr,
|
||||
hide as hideAddr,
|
||||
} from "./address.js";
|
||||
|
||||
/** @type {number | undefined} */ let pollInterval;
|
||||
let navController = new AbortController();
|
||||
let lastLoadedUrl = "";
|
||||
|
||||
function navigate() {
|
||||
navController.abort();
|
||||
navController = new AbortController();
|
||||
lastLoadedUrl = window.location.pathname;
|
||||
return navController.signal;
|
||||
}
|
||||
|
||||
function showPanel(/** @type {"block" | "tx" | "addr"} */ which) {
|
||||
which === "block" ? showBlock() : hideBlock();
|
||||
which === "tx" ? showTx() : hideTx();
|
||||
which === "addr" ? showAddr() : hideAddr();
|
||||
}
|
||||
|
||||
/** @param {MouseEvent} e */
|
||||
function handleLinkClick(e) {
|
||||
const a = /** @type {HTMLAnchorElement | null} */ (
|
||||
/** @type {HTMLElement} */ (e.target).closest("a[href]")
|
||||
);
|
||||
if (!a) return;
|
||||
const m = a.pathname.match(/^\/(block|tx|address)\/(.+)/);
|
||||
if (!m) return;
|
||||
e.preventDefault();
|
||||
history.pushState(null, "", a.href);
|
||||
if (m[1] === "block") {
|
||||
navigateToBlock(m[2]);
|
||||
} else if (m[1] === "tx") {
|
||||
navigateToTx(m[2]);
|
||||
} else {
|
||||
navigateToAddr(m[2]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {{ onChange: (cb: (option: Option) => void) => void }} selected */
|
||||
export function init(selected) {
|
||||
initChain(explorerElement, {
|
||||
onSelect: (block) => {
|
||||
updateBlock(block);
|
||||
showPanel("block");
|
||||
},
|
||||
onCubeClick: (cube) => {
|
||||
history.pushState(null, "", cube.href);
|
||||
navigate();
|
||||
selectCube(cube);
|
||||
},
|
||||
onTip: () => {
|
||||
history.pushState(null, "", "/block/tip");
|
||||
navigate();
|
||||
goToCube(null);
|
||||
},
|
||||
onGenesis: () => {
|
||||
history.pushState(null, "", "/block/0");
|
||||
navigate();
|
||||
goToCube(0);
|
||||
},
|
||||
});
|
||||
|
||||
initBlockDetails(explorerElement, handleLinkClick);
|
||||
initTxDetails(explorerElement, handleLinkClick);
|
||||
initAddrDetails(explorerElement, handleLinkClick);
|
||||
|
||||
new MutationObserver(() => {
|
||||
if (explorerElement.hidden) stopPolling();
|
||||
else startPolling();
|
||||
}).observe(explorerElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["hidden"],
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && !explorerElement.hidden) poll();
|
||||
});
|
||||
|
||||
selected.onChange((option) => {
|
||||
if (option.kind === "explorer") {
|
||||
const url = window.location.pathname;
|
||||
if (url !== lastLoadedUrl) load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
poll();
|
||||
pollInterval = setInterval(poll, 1_000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval !== undefined) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const signal = navigate();
|
||||
try {
|
||||
const [kind, value] = window.location.pathname.split("/").filter(Boolean);
|
||||
|
||||
if (kind === "tx" && value) {
|
||||
const txid = await resolveTxid(value, { signal });
|
||||
if (signal.aborted) return;
|
||||
const tx = await brk.getTx(txid, { signal });
|
||||
if (signal.aborted) return;
|
||||
await goToCube(tx.status?.blockHash ?? tx.status?.blockHeight ?? null, { silent: true });
|
||||
updateTx(tx);
|
||||
showPanel("tx");
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === "address" && value) {
|
||||
await goToCube(null, { silent: true });
|
||||
navigateToAddr(value);
|
||||
return;
|
||||
}
|
||||
|
||||
await goToCube(kind === "block" ? value : null);
|
||||
} catch (e) {
|
||||
if (signal.aborted) return;
|
||||
console.error("explorer load:", e);
|
||||
await goToCube();
|
||||
showPanel("block");
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} hashOrHeight */
|
||||
async function navigateToBlock(hashOrHeight) {
|
||||
navigate();
|
||||
await goToCube(hashOrHeight);
|
||||
}
|
||||
|
||||
/** @param {Txid | TxIndex} value @param {{ signal?: AbortSignal }} [options] */
|
||||
async function resolveTxid(value, { signal } = {}) {
|
||||
return typeof value === "number" || /^\d+$/.test(value)
|
||||
? await brk.getTxByIndex(Number(value), { signal })
|
||||
: value;
|
||||
}
|
||||
|
||||
/** @param {Txid | TxIndex} txidOrIndex */
|
||||
async function navigateToTx(txidOrIndex) {
|
||||
const signal = navigate();
|
||||
clearTx();
|
||||
showPanel("tx");
|
||||
try {
|
||||
const txid = await resolveTxid(txidOrIndex, { signal });
|
||||
if (signal.aborted) return;
|
||||
const tx = await brk.getTx(txid, { signal });
|
||||
if (signal.aborted) return;
|
||||
await goToCube(tx.status?.blockHash ?? tx.status?.blockHeight ?? null, { silent: true });
|
||||
updateTx(tx);
|
||||
} catch (e) {
|
||||
if (!signal.aborted) {
|
||||
console.error("explorer tx:", e);
|
||||
await goToCube();
|
||||
showPanel("block");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} address */
|
||||
function navigateToAddr(address) {
|
||||
const signal = navigate();
|
||||
deselectCube();
|
||||
updateAddr(address, signal);
|
||||
showPanel("addr");
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { createCube } from "./cube.js";
|
||||
import { formatFeeRate } from "./render.js";
|
||||
import { createSpan } from "../utils/dom.js";
|
||||
|
||||
const NUM_BLOCKS = 8;
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* el: HTMLElement,
|
||||
* topFace: HTMLDivElement,
|
||||
* rightFace: HTMLDivElement,
|
||||
* leftFace: HTMLDivElement,
|
||||
* }} Cube
|
||||
*/
|
||||
|
||||
/** @type {HTMLDivElement | null} */ let mempoolBlocksEl = null;
|
||||
/** @type {Cube[]} */ const cubes = [];
|
||||
|
||||
/** @param {HTMLElement} parent the `.chain-scroll` element */
|
||||
export function initMempool(parent) {
|
||||
mempoolBlocksEl = document.createElement("div");
|
||||
mempoolBlocksEl.classList.add("mempool-blocks");
|
||||
mempoolBlocksEl.hidden = true;
|
||||
parent.prepend(mempoolBlocksEl);
|
||||
}
|
||||
|
||||
/** @param {MempoolBlock[]} blocks */
|
||||
export function renderMempool(blocks) {
|
||||
if (!mempoolBlocksEl) return;
|
||||
mempoolBlocksEl.hidden = blocks.length === 0;
|
||||
const want = Math.min(blocks.length, NUM_BLOCKS);
|
||||
while (cubes.length > want) {
|
||||
const last = cubes.pop();
|
||||
if (last) last.el.remove();
|
||||
}
|
||||
while (cubes.length < want) {
|
||||
const cube = createMempoolCube(cubes.length);
|
||||
cubes.push(cube);
|
||||
mempoolBlocksEl.append(cube.el);
|
||||
}
|
||||
for (let i = 0; i < want; i++) updateMempoolCube(cubes[i], blocks[i], i);
|
||||
}
|
||||
|
||||
/** @param {number} position @returns {Cube} */
|
||||
function createMempoolCube(position) {
|
||||
const el = document.createElement("div");
|
||||
el.classList.add("cube", "projected");
|
||||
if (position === 0) el.classList.add("next");
|
||||
const { topFace, rightFace, leftFace } = createCube(el, 0);
|
||||
return { el, topFace, rightFace, leftFace };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Cube} cube
|
||||
* @param {MempoolBlock} block
|
||||
* @param {number} position
|
||||
*/
|
||||
function updateMempoolCube(cube, block, position) {
|
||||
const fill = Math.min(1, block.blockVSize / 1_000_000);
|
||||
cube.el.style.setProperty("--fill", String(fill));
|
||||
|
||||
cube.topFace.textContent = "";
|
||||
const label = document.createElement("p");
|
||||
label.textContent = position === 0 ? "next" : `+${position}`;
|
||||
cube.topFace.append(label);
|
||||
|
||||
cube.rightFace.textContent = "";
|
||||
const txs = document.createElement("p");
|
||||
txs.textContent = block.nTx.toLocaleString();
|
||||
const txsUnit = document.createElement("p");
|
||||
txsUnit.classList.add("dim");
|
||||
txsUnit.textContent = block.nTx === 1 ? "tx" : "txs";
|
||||
cube.rightFace.append(txs, txsUnit);
|
||||
|
||||
cube.leftFace.textContent = "";
|
||||
const median = document.createElement("p");
|
||||
const tilde = createSpan("~");
|
||||
tilde.classList.add("dim");
|
||||
median.append(tilde, formatFeeRate(block.medianFee));
|
||||
const range = document.createElement("p");
|
||||
const dash = createSpan("-");
|
||||
dash.classList.add("dim");
|
||||
range.append(formatFeeRate(block.feeRange[0]), dash, formatFeeRate(block.feeRange[6]));
|
||||
const unit = document.createElement("p");
|
||||
unit.classList.add("dim");
|
||||
unit.textContent = "sat/vB";
|
||||
cube.leftFace.append(median, range, unit);
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
export const TX_PAGE_SIZE = 25;
|
||||
|
||||
/** @param {HTMLElement} el */
|
||||
export function showPanel(el) {
|
||||
el.hidden = false;
|
||||
el.scrollTop = 0;
|
||||
}
|
||||
|
||||
/** @param {HTMLElement} el */
|
||||
export function hidePanel(el) {
|
||||
el.hidden = true;
|
||||
}
|
||||
|
||||
/** @param {number} sats */
|
||||
export function formatBtc(sats) {
|
||||
return (sats / 1e8).toFixed(8);
|
||||
}
|
||||
|
||||
/** @param {number} rate */
|
||||
export function formatFeeRate(rate) {
|
||||
if (rate >= 1_000_000) return `${(rate / 1_000_000).toFixed(1)}M`;
|
||||
if (rate >= 100_000) return `${Math.round(rate / 1_000)}k`;
|
||||
if (rate >= 1_000) return `${(rate / 1_000).toFixed(1)}k`;
|
||||
if (rate >= 100) return Math.round(rate).toLocaleString();
|
||||
if (rate >= 10) return rate.toFixed(1);
|
||||
return rate.toFixed(2);
|
||||
}
|
||||
|
||||
/** @param {string} text @param {HTMLElement} el */
|
||||
export function setAddrContent(text, el) {
|
||||
el.textContent = "";
|
||||
if (text.length <= 6) {
|
||||
el.textContent = text;
|
||||
return;
|
||||
}
|
||||
const head = document.createElement("span");
|
||||
head.classList.add("addr-head");
|
||||
head.textContent = text.slice(0, -6);
|
||||
const tail = document.createElement("span");
|
||||
tail.classList.add("addr-tail");
|
||||
tail.textContent = text.slice(-6);
|
||||
el.append(head, tail);
|
||||
}
|
||||
|
||||
/** @param {number} height */
|
||||
export function formatHeightPrefix(height) {
|
||||
return "#" + "0".repeat(Math.max(0, 7 - String(height).length));
|
||||
}
|
||||
|
||||
/** @param {number} height */
|
||||
export function createHeightElement(height) {
|
||||
const container = document.createElement("span");
|
||||
const str = height.toString();
|
||||
const prefix = document.createElement("span");
|
||||
prefix.classList.add("dim");
|
||||
prefix.style.userSelect = "none";
|
||||
prefix.textContent = formatHeightPrefix(height);
|
||||
const num = document.createElement("span");
|
||||
num.textContent = str;
|
||||
container.append(prefix, num);
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {boolean} [isLink]
|
||||
* @returns {{ row: HTMLDivElement, valueEl: HTMLElement }}
|
||||
*/
|
||||
export function createRow(label, isLink = false) {
|
||||
const row = document.createElement("div");
|
||||
row.classList.add("row");
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.classList.add("label");
|
||||
labelEl.textContent = label;
|
||||
const valueEl = document.createElement(isLink ? "a" : "span");
|
||||
valueEl.classList.add("value");
|
||||
row.append(labelEl, valueEl);
|
||||
return { row, valueEl };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {[string, string, (string | null)?][]} rows
|
||||
* @param {HTMLElement} parent
|
||||
*/
|
||||
export function renderRows(rows, parent) {
|
||||
for (const [label, value, href] of rows) {
|
||||
const { row, valueEl } = createRow(label, Boolean(href));
|
||||
valueEl.textContent = value;
|
||||
if (href) /** @type {HTMLAnchorElement} */ (valueEl).href = href;
|
||||
parent.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TxIn} vin
|
||||
* @param {string} [coinbaseAscii]
|
||||
*/
|
||||
function renderInput(vin, coinbaseAscii) {
|
||||
const row = document.createElement("div");
|
||||
row.classList.add("tx-io");
|
||||
const addr = document.createElement("span");
|
||||
addr.classList.add("addr");
|
||||
if (vin.isCoinbase) {
|
||||
addr.textContent = "Coinbase";
|
||||
addr.classList.add("coinbase");
|
||||
if (coinbaseAscii) {
|
||||
const sig = document.createElement("div");
|
||||
sig.classList.add("coinbase-sig");
|
||||
sig.textContent = coinbaseAscii;
|
||||
row.append(sig);
|
||||
}
|
||||
} else {
|
||||
const addrStr = /** @type {string | undefined} */ (
|
||||
/** @type {any} */ (vin.prevout)?.scriptpubkey_address
|
||||
);
|
||||
if (addrStr) {
|
||||
const link = document.createElement("a");
|
||||
link.href = `/address/${addrStr}`;
|
||||
setAddrContent(addrStr, link);
|
||||
addr.append(link);
|
||||
} else {
|
||||
addr.textContent = "Unknown";
|
||||
}
|
||||
}
|
||||
const amt = document.createElement("span");
|
||||
amt.classList.add("amount");
|
||||
amt.textContent = vin.prevout ? `${formatBtc(vin.prevout.value)} BTC` : "";
|
||||
row.append(addr, amt);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** @param {TxOut} vout */
|
||||
function renderOutput(vout) {
|
||||
const row = document.createElement("div");
|
||||
row.classList.add("tx-io");
|
||||
const addr = document.createElement("span");
|
||||
addr.classList.add("addr");
|
||||
const type = /** @type {string | undefined} */ (
|
||||
/** @type {any} */ (vout).scriptpubkey_type
|
||||
);
|
||||
const a = /** @type {string | undefined} */ (
|
||||
/** @type {any} */ (vout).scriptpubkey_address
|
||||
);
|
||||
if (type === "op_return") {
|
||||
addr.textContent = "OP_RETURN";
|
||||
addr.classList.add("op-return");
|
||||
} else if (a) {
|
||||
const link = document.createElement("a");
|
||||
link.href = `/address/${a}`;
|
||||
setAddrContent(a, link);
|
||||
addr.append(link);
|
||||
} else {
|
||||
setAddrContent(vout.scriptpubkey, addr);
|
||||
}
|
||||
const amt = document.createElement("span");
|
||||
amt.classList.add("amount");
|
||||
amt.textContent = `${formatBtc(vout.value)} BTC`;
|
||||
row.append(addr, amt);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} items
|
||||
* @param {(item: T) => HTMLElement} render
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
function renderCapped(items, render, container, max = 10) {
|
||||
const limit = Math.min(items.length, max);
|
||||
for (let i = 0; i < limit; i++) container.append(render(items[i]));
|
||||
if (items.length > max) {
|
||||
const btn = document.createElement("button");
|
||||
btn.classList.add("show-more");
|
||||
btn.textContent = `Show ${items.length - max} more`;
|
||||
btn.addEventListener("click", () => {
|
||||
btn.remove();
|
||||
for (let i = max; i < items.length; i++) container.append(render(items[i]));
|
||||
});
|
||||
container.append(btn);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Transaction} tx @param {string} [coinbaseAscii] */
|
||||
export function renderTx(tx, coinbaseAscii) {
|
||||
const el = document.createElement("div");
|
||||
el.classList.add("tx");
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.classList.add("tx-head");
|
||||
const txidEl = document.createElement("a");
|
||||
txidEl.classList.add("txid");
|
||||
txidEl.textContent = tx.txid;
|
||||
txidEl.href = `/tx/${tx.txid}`;
|
||||
head.append(txidEl);
|
||||
if (tx.status?.blockTime) {
|
||||
const time = document.createElement("span");
|
||||
time.classList.add("tx-time");
|
||||
time.textContent = new Date(tx.status.blockTime * 1000).toLocaleString();
|
||||
head.append(time);
|
||||
}
|
||||
el.append(head);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.classList.add("tx-body");
|
||||
|
||||
const inputs = document.createElement("div");
|
||||
inputs.classList.add("tx-inputs");
|
||||
renderCapped(tx.vin, (vin) => renderInput(vin, coinbaseAscii), inputs);
|
||||
|
||||
const outputs = document.createElement("div");
|
||||
outputs.classList.add("tx-outputs");
|
||||
renderCapped(tx.vout, renderOutput, outputs);
|
||||
|
||||
const totalOut = tx.vout.reduce((s, v) => s + v.value, 0);
|
||||
|
||||
body.append(inputs, outputs);
|
||||
el.append(body);
|
||||
|
||||
const foot = document.createElement("div");
|
||||
foot.classList.add("tx-foot");
|
||||
const feeInfo = document.createElement("span");
|
||||
const vsize = Math.ceil(tx.weight / 4);
|
||||
const feeRate = vsize > 0 ? tx.fee / vsize : 0;
|
||||
feeInfo.textContent = `${formatFeeRate(feeRate)} sat/vB \u2013 ${tx.fee.toLocaleString()} sats`;
|
||||
const total = document.createElement("span");
|
||||
total.classList.add("amount", "total");
|
||||
total.textContent = `${formatBtc(totalOut)} BTC`;
|
||||
foot.append(feeInfo, total);
|
||||
el.append(foot);
|
||||
|
||||
return el;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { formatBtc, formatFeeRate, renderRows, renderTx, showPanel, hidePanel } from "./render.js";
|
||||
|
||||
/** @type {HTMLDivElement} */ let el;
|
||||
|
||||
/** @param {HTMLElement} parent @param {(e: MouseEvent) => void} linkHandler */
|
||||
export function initTxDetails(parent, linkHandler) {
|
||||
el = document.createElement("div");
|
||||
el.id = "tx-details";
|
||||
el.hidden = true;
|
||||
parent.append(el);
|
||||
el.addEventListener("click", linkHandler);
|
||||
}
|
||||
|
||||
export function show() { showPanel(el); }
|
||||
export function hide() { hidePanel(el); }
|
||||
|
||||
export function clear() {
|
||||
if (el.children.length) {
|
||||
el.querySelector(".transactions")?.remove();
|
||||
for (const v of el.querySelectorAll(".row .value")) {
|
||||
v.classList.add("dim");
|
||||
}
|
||||
} else {
|
||||
const title = document.createElement("h1");
|
||||
title.textContent = "Transaction";
|
||||
el.append(title);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Transaction} tx */
|
||||
export function update(tx) {
|
||||
el.innerHTML = "";
|
||||
|
||||
const title = document.createElement("h1");
|
||||
title.textContent = "Transaction";
|
||||
el.append(title);
|
||||
|
||||
const vsize = Math.ceil(tx.weight / 4);
|
||||
const feeRate = vsize > 0 ? tx.fee / vsize : 0;
|
||||
const totalIn = tx.vin.reduce((s, v) => s + (v.prevout?.value ?? 0), 0);
|
||||
const totalOut = tx.vout.reduce((s, v) => s + v.value, 0);
|
||||
|
||||
renderRows(
|
||||
[
|
||||
["TXID", tx.txid],
|
||||
[
|
||||
"Status",
|
||||
tx.status?.confirmed
|
||||
? `Confirmed (block ${tx.status.blockHeight?.toLocaleString()})`
|
||||
: "Unconfirmed",
|
||||
tx.status?.blockHash ? `/block/${tx.status.blockHash}` : null,
|
||||
],
|
||||
[
|
||||
"Timestamp",
|
||||
tx.status?.blockTime
|
||||
? new Date(tx.status.blockTime * 1000).toUTCString()
|
||||
: "Pending",
|
||||
],
|
||||
["Size", `${tx.size.toLocaleString()} B`],
|
||||
["Virtual Size", `${vsize.toLocaleString()} vB`],
|
||||
["Weight", `${tx.weight.toLocaleString()} WU`],
|
||||
["Fee", `${tx.fee.toLocaleString()} sat`],
|
||||
["Fee Rate", `${formatFeeRate(feeRate)} sat/vB`],
|
||||
["Inputs", `${tx.vin.length}`],
|
||||
["Outputs", `${tx.vout.length}`],
|
||||
["Total Input", `${formatBtc(totalIn)} BTC`],
|
||||
["Total Output", `${formatBtc(totalOut)} BTC`],
|
||||
["Version", `${tx.version}`],
|
||||
["Locktime", `${tx.locktime}`],
|
||||
],
|
||||
el,
|
||||
);
|
||||
|
||||
const section = document.createElement("div");
|
||||
section.classList.add("transactions");
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Inputs & Outputs";
|
||||
section.append(heading, renderTx(tx));
|
||||
el.append(section);
|
||||
}
|
||||
Reference in New Issue
Block a user