Files
brk/website/scripts/panes/explorer.js
2026-04-04 00:59:37 +02:00

319 lines
10 KiB
JavaScript

import { explorerElement } from "../utils/elements.js";
import { brk } from "../client.js";
/** @type {HTMLDivElement} */
let chain;
/** @type {HTMLDivElement} */
let details;
/** @type {HTMLDivElement} */
let sentinel;
/** @type {Map<BlockHash, BlockInfoV1>} */
const blocksByHash = new Map();
let newestHeight = -1;
let oldestHeight = Infinity;
let loading = false;
/** @type {HTMLDivElement | null} */
let selectedCube = null;
/** @type {number | undefined} */
let pollInterval;
function startPolling() {
stopPolling();
loadLatest();
pollInterval = setInterval(loadLatest, 15_000);
}
function stopPolling() {
if (pollInterval !== undefined) {
clearInterval(pollInterval);
pollInterval = undefined;
}
}
export function init() {
chain = window.document.createElement("div");
chain.id = "chain";
explorerElement.append(chain);
const blocks = window.document.createElement("div");
blocks.classList.add("blocks");
chain.append(blocks);
details = window.document.createElement("div");
details.id = "block-details";
explorerElement.append(details);
sentinel = window.document.createElement("div");
sentinel.classList.add("sentinel");
blocks.append(sentinel);
// Infinite scroll: load older blocks when sentinel becomes visible
new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadOlder();
}
}).observe(sentinel);
// Self-contained lifecycle: poll when visible, stop when hidden
new MutationObserver(() => {
if (explorerElement.hidden) {
stopPolling();
} else {
startPolling();
}
}).observe(explorerElement, {
attributes: true,
attributeFilter: ["hidden"],
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden && !explorerElement.hidden) {
loadLatest();
}
});
loadLatest();
}
async function loadLatest() {
if (loading) return;
loading = true;
try {
const blocks = await brk.getBlocksV1();
// First load: insert all blocks before sentinel
if (newestHeight === -1) {
const cubes = blocks.map((b) => createBlockCube(b));
for (const cube of cubes) {
sentinel.after(cube);
}
newestHeight = blocks[0].height;
oldestHeight = blocks[blocks.length - 1].height;
// Select the tip by default
selectCube(cubes[0]);
} else {
// Subsequent polls: prepend only new blocks
const newBlocks = blocks.filter((b) => b.height > newestHeight);
if (newBlocks.length) {
// sentinel.after(createBlockCube(block));
sentinel.after(...newBlocks.map((b) => createBlockCube(b)));
newestHeight = newBlocks[0].height;
}
}
} catch (e) {
console.error("explorer poll:", e);
}
loading = false;
}
async function loadOlder() {
if (loading || oldestHeight <= 0) return;
loading = true;
try {
const blocks = await brk.getBlocksV1FromHeight(oldestHeight - 1);
for (const block of blocks) {
sentinel.after(createBlockCube(block));
}
if (blocks.length) {
oldestHeight = blocks[blocks.length - 1].height;
}
} catch (e) {
console.error("explorer loadOlder:", e);
}
loading = false;
}
/** @param {HTMLDivElement} cube */
function selectCube(cube) {
if (selectedCube) {
selectedCube.classList.remove("selected");
}
selectedCube = cube;
if (cube) {
cube.classList.add("selected");
const hash = cube.dataset.hash;
if (hash) {
renderDetails(blocksByHash.get(hash));
}
}
}
/** @param {BlockInfoV1 | undefined} block */
function renderDetails(block) {
details.innerHTML = "";
if (!block) return;
const title = window.document.createElement("h1");
title.textContent = "Block ";
const titleCode = window.document.createElement("code");
titleCode.append(createHeightElement(block.height));
title.append(titleCode);
details.append(title);
const extras = block.extras;
/** @type {[string, string][]} */
const rows = [
["Hash", block.id],
["Previous Hash", block.previousblockhash],
["Merkle Root", block.merkleRoot],
["Timestamp", new Date(block.timestamp * 1000).toUTCString()],
["Median Time", new Date(block.mediantime * 1000).toUTCString()],
["Version", `0x${block.version.toString(16)}`],
["Bits", block.bits.toString(16)],
["Nonce", block.nonce.toLocaleString()],
["Difficulty", Number(block.difficulty).toLocaleString()],
["Size", `${(block.size / 1_000_000).toFixed(2)} MB`],
["Weight", `${(block.weight / 1_000_000).toFixed(2)} MWU`],
["Transactions", block.txCount.toLocaleString()],
];
if (extras) {
rows.push(
["Price", `$${extras.price.toLocaleString()}`],
["Pool", extras.pool.name],
["Pool ID", extras.pool.id.toString()],
["Pool Slug", extras.pool.slug],
["Miner Names", extras.pool.minerNames || "N/A"],
["Reward", `${(extras.reward / 1e8).toFixed(8)} BTC`],
["Total Fees", `${(extras.totalFees / 1e8).toFixed(8)} BTC`],
["Median Fee Rate", `${formatFeeRate(extras.medianFee)} sat/vB`],
["Avg Fee Rate", `${formatFeeRate(extras.avgFeeRate)} sat/vB`],
["Avg Fee", `${extras.avgFee.toLocaleString()} sat`],
["Median Fee", `${extras.medianFeeAmt.toLocaleString()} sat`],
["Fee Range", extras.feeRange.map((f) => formatFeeRate(f)).join(", ") + " sat/vB"],
["Fee Percentiles", extras.feePercentiles.map((f) => f.toLocaleString()).join(", ") + " sat"],
["Avg Tx Size", `${extras.avgTxSize.toLocaleString()} B`],
["Virtual Size", `${extras.virtualSize.toLocaleString()} vB`],
["Inputs", extras.totalInputs.toLocaleString()],
["Outputs", extras.totalOutputs.toLocaleString()],
["Total Input Amount", `${(extras.totalInputAmt / 1e8).toFixed(8)} BTC`],
["Total Output Amount", `${(extras.totalOutputAmt / 1e8).toFixed(8)} BTC`],
["UTXO Set Change", extras.utxoSetChange.toLocaleString()],
["UTXO Set Size", extras.utxoSetSize.toLocaleString()],
["SegWit Txs", extras.segwitTotalTxs.toLocaleString()],
["SegWit Size", `${extras.segwitTotalSize.toLocaleString()} B`],
["SegWit Weight", `${extras.segwitTotalWeight.toLocaleString()} WU`],
["Coinbase Address", extras.coinbaseAddress || "N/A"],
["Coinbase Addresses", extras.coinbaseAddresses.join(", ") || "N/A"],
["Coinbase Raw", extras.coinbaseRaw],
["Coinbase Signature", extras.coinbaseSignature],
["Coinbase Signature ASCII", extras.coinbaseSignatureAscii],
["Header", extras.header],
);
}
for (const [label, value] of rows) {
const row = window.document.createElement("div");
row.classList.add("row");
const labelElement = window.document.createElement("span");
labelElement.classList.add("label");
labelElement.textContent = label;
const valueElement = window.document.createElement("span");
valueElement.classList.add("value");
valueElement.textContent = value;
row.append(labelElement, valueElement);
details.append(row);
}
}
/** @param {number} rate */
function formatFeeRate(rate) {
if (rate >= 100) return Math.round(rate).toLocaleString();
if (rate >= 10) return rate.toFixed(1);
return rate.toFixed(2);
}
/** @param {number} height */
function createHeightElement(height) {
const container = window.document.createElement("span");
const str = height.toString();
const spanPrefix = window.document.createElement("span");
spanPrefix.style.opacity = "0.5";
spanPrefix.style.userSelect = "none";
spanPrefix.textContent = "#" + "0".repeat(7 - str.length);
const spanHeight = window.document.createElement("span");
spanHeight.textContent = str;
container.append(spanPrefix, spanHeight);
return container;
}
/** @param {BlockInfoV1} block */
function createBlockCube(block) {
const { cubeElement, leftFaceElement, rightFaceElement, topFaceElement } =
createCube();
cubeElement.dataset.hash = block.id;
blocksByHash.set(block.id, block);
cubeElement.addEventListener("click", () => selectCube(cubeElement));
const heightElement = window.document.createElement("p");
heightElement.append(createHeightElement(block.height));
rightFaceElement.append(heightElement);
const feesElement = window.document.createElement("div");
feesElement.classList.add("fees");
leftFaceElement.append(feesElement);
const extras = block.extras;
const medianFee = extras ? extras.medianFee : 0;
const feeRange = extras ? extras.feeRange : [0, 0, 0, 0, 0, 0, 0];
const averageFeeElement = window.document.createElement("p");
feesElement.append(averageFeeElement);
averageFeeElement.innerHTML = `~${formatFeeRate(medianFee)}`;
const feeRangeElement = window.document.createElement("p");
feesElement.append(feeRangeElement);
const minFeeElement = window.document.createElement("span");
minFeeElement.innerHTML = formatFeeRate(feeRange[0]);
feeRangeElement.append(minFeeElement);
const dashElement = window.document.createElement("span");
dashElement.style.opacity = "0.5";
dashElement.innerHTML = `-`;
feeRangeElement.append(dashElement);
const maxFeeElement = window.document.createElement("span");
maxFeeElement.innerHTML = formatFeeRate(feeRange[6]);
feeRangeElement.append(maxFeeElement);
const feeUnitElement = window.document.createElement("p");
feesElement.append(feeUnitElement);
feeUnitElement.style.opacity = "0.5";
feeUnitElement.innerHTML = `sat/vB`;
const spanMiner = window.document.createElement("span");
spanMiner.innerHTML = extras ? extras.pool.name : "Unknown";
topFaceElement.append(spanMiner);
return cubeElement;
}
function createCube() {
const cubeElement = window.document.createElement("div");
cubeElement.classList.add("cube");
const rightFaceElement = window.document.createElement("div");
rightFaceElement.classList.add("face");
rightFaceElement.classList.add("right");
cubeElement.append(rightFaceElement);
const leftFaceElement = window.document.createElement("div");
leftFaceElement.classList.add("face");
leftFaceElement.classList.add("left");
cubeElement.append(leftFaceElement);
const topFaceElement = window.document.createElement("div");
topFaceElement.classList.add("face");
topFaceElement.classList.add("top");
cubeElement.append(topFaceElement);
return {
cubeElement,
leftFaceElement,
rightFaceElement,
topFaceElement,
};
}