mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-24 22:59:58 -07:00
329 lines
10 KiB
JavaScript
329 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?.join(", ") || "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,
|
|
};
|
|
}
|