website: redesign part 1

This commit is contained in:
nym21
2026-06-03 12:34:05 +02:00
parent 5f5563fece
commit 90e8741fb7
209 changed files with 23945 additions and 176 deletions
-142
View File
@@ -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); }
-210
View File
@@ -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;
}
-377
View File
@@ -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;
}
-51
View File
@@ -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 };
}
-198
View File
@@ -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");
}
-88
View File
@@ -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);
}
-232
View File
@@ -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;
}
-80
View File
@@ -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);
}