global: snapshot

This commit is contained in:
nym21
2026-04-08 01:38:03 +02:00
parent 0c14dfe924
commit 4c4c6fc840
79 changed files with 2040 additions and 1408 deletions

View File

@@ -7,6 +7,7 @@
* @import { Options } from './options/full.js'
*
* @import { PersistedValue } from './utils/persisted.js'
* @import { MapCache } from './utils/cache.js'
*
* @import { SingleValueData, CandlestickData, Series, AnySeries, ISeries, HistogramData, LineData, BaselineData, LineSeriesPartialOptions, BaselineSeriesPartialOptions, HistogramSeriesPartialOptions, CandlestickSeriesPartialOptions, Chart, Legend } from "./utils/chart/index.js"
*
@@ -57,6 +58,9 @@
* @typedef {Brk.BlockHash} BlockHash
* @typedef {Brk.BlockInfoV1} BlockInfoV1
* @typedef {Brk.Transaction} Transaction
* @typedef {Brk.AddrStats} AddrStats
* @typedef {Brk.TxIn} TxIn
* @typedef {Brk.TxOut} TxOut
* ActivePriceRatioPattern: ratio pattern with price (extended)
* @typedef {Brk.BpsPriceRatioPattern} ActivePriceRatioPattern
* PriceRatioPercentilesPattern: price pattern with ratio + percentiles (no SMAs/stdDev)

View File

@@ -0,0 +1,115 @@
import { brk } from "../utils/client.js";
import { createMapCache } from "../utils/cache.js";
import { latestPrice } from "../utils/price.js";
import { formatBtc, renderRows, renderTx, TX_PAGE_SIZE } from "./render.js";
/** @type {MapCache<Transaction[]>} */
const addrTxCache = createMapCache(200);
/**
* @param {string} address
* @param {HTMLDivElement} el
* @param {{ signal: AbortSignal, cache: MapCache<AddrStats> }} options
*/
export async function showAddrDetail(address, el, { signal, cache }) {
el.hidden = false;
el.scrollTop = 0;
el.innerHTML = "";
try {
const cached = cache.get(address);
const stats = cached ?? (await brk.getAddress(address, { signal }));
if (!cached) cache.set(address, stats);
if (signal.aborted) return;
const chain = stats.chainStats;
const title = document.createElement("h1");
title.textContent = "Address";
el.append(title);
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 })}`
: "";
renderRows(
[
["Address", address],
["Confirmed Balance", `${formatBtc(balance)} BTC${fmtUsd(balance)}`],
[
"Pending",
`${pending >= 0 ? "+" : ""}${formatBtc(pending)} BTC${fmtUsd(pending)}`,
],
["Confirmed UTXOs", confirmedUtxos.toLocaleString()],
["Pending UTXOs", pendingUtxos.toLocaleString()],
["Total Received", `${formatBtc(chain.fundedTxoSum)} BTC`],
["Tx Count", chain.txCount.toLocaleString()],
[
"Type",
/** @type {any} */ ((stats).addrType ?? "unknown")
.replace(/^v\d+_/, "")
.toUpperCase(),
],
[
"Avg Cost Basis",
chain.realizedPrice
? `$${Number(chain.realizedPrice).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: "N/A",
],
],
el,
);
const section = document.createElement("div");
section.classList.add("transactions");
const heading = document.createElement("h2");
heading.textContent = "Transactions";
section.append(heading);
el.append(section);
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() {
loading = true;
const key = `${address}:${pageIndex}`;
try {
const cached = addrTxCache.get(key);
const txs = cached ?? await brk.getAddressTxs(address, afterTxid, { signal });
if (!cached) addrTxCache.set(key, txs);
for (const tx of txs) section.append(renderTx(tx));
pageIndex++;
if (txs.length) {
afterTxid = txs[txs.length - 1].txid;
observer.disconnect();
const last = section.lastElementChild;
if (last) observer.observe(last);
}
} catch (e) {
console.error("explorer addr txs:", e);
pageIndex = chain.txCount; // stop loading
}
loading = false;
}
await loadMore();
} catch (e) {
console.error("explorer addr:", e);
el.textContent = "Address not found";
}
}

View File

@@ -0,0 +1,230 @@
import { brk } from "../utils/client.js";
import { createMapCache } from "../utils/cache.js";
import { createPersistedValue } from "../utils/persisted.js";
import { formatFeeRate, renderTx, TX_PAGE_SIZE } from "./render.js";
/** @typedef {[string, (b: BlockInfoV1) => string | null, ((b: BlockInfoV1) => string | null)?]} RowDef */
/** @param {(x: NonNullable<BlockInfoV1["extras"]>) => string | null} fn @returns {(b: BlockInfoV1) => string | null} */
const ext = (fn) => (b) => (b.extras ? fn(b.extras) : null);
/** @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((f) => formatFeeRate(f)).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: HTMLSpanElement }[]} */ let detailRows;
/** @type {HTMLDivElement} */ let txList;
/** @type {HTMLDivElement} */ let txSection;
/** @type {IntersectionObserver} */ let txObserver;
/** @type {TxNav[]} */ let txNavs = [];
/** @type {BlockInfoV1 | null} */ let txBlock = null;
let txTotalPages = 0;
let txLoading = false;
let txLoaded = false;
const txPageCache = createMapCache(200);
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.style.opacity = "0.5";
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 = document.createElement("div");
row.classList.add("row");
const labelEl = document.createElement("span");
labelEl.classList.add("label");
labelEl.textContent = label;
const valueEl = document.createElement(linkFn ? "a" : "span");
valueEl.classList.add("value");
row.append(labelEl, valueEl);
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) {
show();
el.scrollTop = 0;
const str = block.height.toString();
heightPrefix.textContent = "#" + "0".repeat(7 - str.length);
heightNum.textContent = str;
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() {
el.hidden = false;
}
export function hide() {
el.hidden = true;
}
/** @param {number} page @param {boolean} [pushUrl] */
async function loadTxPage(page, pushUrl = true) {
if (txLoading || !txBlock || page < 0 || page >= txTotalPages) return;
txLoading = true;
txLoaded = true;
if (pushUrl) txPageParam.setImmediate(page);
updateTxNavs(page);
const key = `${txBlock.id}:${page}`;
try {
const cached = txPageCache.get(key);
const txs = cached ?? await brk.getBlockTxsFromIndex(txBlock.id, page * TX_PAGE_SIZE);
if (!cached) txPageCache.set(key, txs);
txList.innerHTML = "";
const ascii = txBlock.extras?.coinbaseSignatureAscii;
for (const tx of txs) txList.append(renderTx(tx, ascii));
} catch (e) {
console.error("explorer txs:", e);
}
txLoading = false;
}

View File

@@ -0,0 +1,240 @@
import { brk } from "../utils/client.js";
import { createHeightElement, formatFeeRate } from "./render.js";
const LOOKAHEAD = 15;
/** @type {HTMLDivElement} */ let chainEl;
/** @type {HTMLDivElement} */ let blocksEl;
/** @type {HTMLDivElement | null} */ let selectedCube = null;
/** @type {IntersectionObserver} */ let olderObserver;
/** @type {(block: BlockInfoV1) => void} */ let onSelect = () => {};
/** @type {(cube: HTMLDivElement) => void} */ let onCubeClick = () => {};
/** @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: HTMLDivElement) => void }} callbacks
*/
export function initChain(parent, callbacks) {
onSelect = callbacks.onSelect;
onCubeClick = callbacks.onCubeClick;
chainEl = document.createElement("div");
chainEl.id = "chain";
parent.append(chainEl);
blocksEl = document.createElement("div");
blocksEl.classList.add("blocks");
chainEl.append(blocksEl);
olderObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadOlder();
},
{ root: chainEl },
);
chainEl.addEventListener(
"scroll",
() => {
const nearStart =
(chainEl.scrollHeight > chainEl.clientHeight &&
chainEl.scrollTop <= 50) ||
(chainEl.scrollWidth > chainEl.clientWidth &&
chainEl.scrollLeft <= 50);
if (nearStart && !reachedTip && !loadingNewer) loadNewer();
},
{ passive: true },
);
}
/** @param {string} hash */
export function getBlock(hash) {
return blocksByHash.get(hash);
}
/** @param {string} hash */
export function findCube(hash) {
return /** @type {HTMLDivElement | null} */ (
blocksEl.querySelector(`[data-hash="${hash}"]`)
);
}
export function lastCube() {
return /** @type {HTMLDivElement | null} */ (blocksEl.lastElementChild);
}
/** @param {HTMLDivElement} cube @param {{ scroll?: boolean }} [opts] */
export function selectCube(cube, { scroll = false } = {}) {
const changed = cube !== selectedCube;
if (changed) {
if (selectedCube) selectedCube.classList.remove("selected");
selectedCube = cube;
cube.classList.add("selected");
}
if (scroll) cube.scrollIntoView({ behavior: "smooth" });
const hash = cube.dataset.hash;
if (hash) {
const block = blocksByHash.get(hash);
if (block) onSelect(block);
}
}
export 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) {
blocksEl.append(createBlockCube(b));
} else {
blocksByHash.set(b.id, b);
}
}
newestHeight = Math.max(newestHeight, blocks[0].height);
if (anchor && anchorRect) {
const r = anchor.getBoundingClientRect();
chainEl.scrollTop += r.top - anchorRect.top;
chainEl.scrollLeft += r.left - anchorRect.left;
}
return true;
}
/** @param {number | null} [height] */
export async function loadInitial(height) {
const blocks =
height != null
? await brk.getBlocksV1FromHeight(height)
: await brk.getBlocksV1();
for (const b of blocks) blocksEl.prepend(createBlockCube(b));
newestHeight = blocks[0].height;
oldestHeight = blocks[blocks.length - 1].height;
reachedTip = height == null;
observeOldestEdge();
if (!reachedTip) await loadNewer();
}
export async function poll() {
if (newestHeight === -1 || !reachedTip) return;
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) blocksEl.prepend(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 blocks = await brk.getBlocksV1FromHeight(newestHeight + LOOKAHEAD);
if (!appendNewerBlocks(blocks)) reachedTip = true;
} catch (e) {
console.error("explorer loadNewer:", e);
}
loadingNewer = false;
}
/** @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", () => onCubeClick(cubeElement));
const heightEl = document.createElement("p");
heightEl.append(createHeightElement(block.height));
rightFaceElement.append(heightEl);
const feesEl = document.createElement("div");
feesEl.classList.add("fees");
leftFaceElement.append(feesEl);
const extras = block.extras;
const medianFee = extras ? extras.medianFee : 0;
const feeRange = extras ? extras.feeRange : [0, 0, 0, 0, 0, 0, 0];
const avg = document.createElement("p");
avg.innerHTML = `~${formatFeeRate(medianFee)}`;
feesEl.append(avg);
const range = document.createElement("p");
const min = document.createElement("span");
min.innerHTML = formatFeeRate(feeRange[0]);
const dash = document.createElement("span");
dash.style.opacity = "0.5";
dash.innerHTML = `-`;
const max = document.createElement("span");
max.innerHTML = formatFeeRate(feeRange[6]);
range.append(min, dash, max);
feesEl.append(range);
const unit = document.createElement("p");
unit.style.opacity = "0.5";
unit.innerHTML = `sat/vB`;
feesEl.append(unit);
const miner = document.createElement("span");
miner.innerHTML = extras ? extras.pool.name : "Unknown";
topFaceElement.append(miner);
return cubeElement;
}
function createCube() {
const cubeElement = document.createElement("div");
cubeElement.classList.add("cube");
const rightFaceElement = document.createElement("div");
rightFaceElement.classList.add("face", "right");
cubeElement.append(rightFaceElement);
const leftFaceElement = document.createElement("div");
leftFaceElement.classList.add("face", "left");
cubeElement.append(leftFaceElement);
const topFaceElement = document.createElement("div");
topFaceElement.classList.add("face", "top");
cubeElement.append(topFaceElement);
return { cubeElement, leftFaceElement, rightFaceElement, topFaceElement };
}

View File

@@ -0,0 +1,241 @@
import { explorerElement } from "../utils/elements.js";
import { brk } from "../utils/client.js";
import { createMapCache } from "../utils/cache.js";
import {
initChain,
loadInitial,
poll,
selectCube,
findCube,
lastCube,
clear as clearChain,
} from "./chain.js";
import {
initBlockDetails,
update as updateBlock,
show as showBlock,
hide as hideBlock,
} from "./block.js";
import { showTxFromData } from "./tx.js";
import { showAddrDetail } from "./address.js";
/** @returns {string[]} */
function pathSegments() {
return window.location.pathname.split("/").filter((v) => v);
}
/** @type {HTMLDivElement} */ let secondaryPanel;
/** @type {number | undefined} */ let pollInterval;
/** @type {Transaction | null} */ let pendingTx = null;
let navController = new AbortController();
const txCache = createMapCache(50);
const addrCache = createMapCache(50);
function navigate() {
navController.abort();
navController = new AbortController();
return navController.signal;
}
function showBlockPanel() {
showBlock();
secondaryPanel.hidden = true;
}
function showSecondaryPanel() {
hideBlock();
secondaryPanel.hidden = false;
}
/** @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();
if (m[1] === "block") {
navigateToBlock(m[2]);
} else if (m[1] === "tx") {
history.pushState(null, "", a.href);
navigateToTx(m[2]);
} else {
history.pushState(null, "", a.href);
navigateToAddr(m[2]);
}
}
export function init() {
initChain(explorerElement, {
onSelect: (block) => {
updateBlock(block);
showBlockPanel();
},
onCubeClick: (cube) => {
const hash = cube.dataset.hash;
if (hash) history.pushState(null, "", `/block/${hash}`);
selectCube(cube);
},
});
initBlockDetails(explorerElement, handleLinkClick);
secondaryPanel = document.createElement("div");
secondaryPanel.id = "tx-details";
secondaryPanel.hidden = true;
explorerElement.append(secondaryPanel);
secondaryPanel.addEventListener("click", handleLinkClick);
new MutationObserver(() => {
if (explorerElement.hidden) stopPolling();
else startPolling();
}).observe(explorerElement, {
attributes: true,
attributeFilter: ["hidden"],
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden && !explorerElement.hidden) poll();
});
window.addEventListener("popstate", () => {
const [kind, value] = pathSegments();
if (kind === "block" && value) navigateToBlock(value, false);
else if (kind === "tx" && value) navigateToTx(value);
else if (kind === "address" && value) navigateToAddr(value);
else showBlockPanel();
});
load();
}
function startPolling() {
stopPolling();
poll();
pollInterval = setInterval(poll, 15_000);
}
function stopPolling() {
if (pollInterval !== undefined) {
clearInterval(pollInterval);
pollInterval = undefined;
}
}
async function load() {
try {
const height = await resolveStartHeight();
await loadInitial(height);
route();
} catch (e) {
console.error("explorer load:", e);
}
}
/** @param {AbortSignal} [signal] @returns {Promise<number | null>} */
async function resolveStartHeight(signal) {
const [kind, value] = pathSegments();
if (!value) return null;
if (kind === "block") {
if (/^\d+$/.test(value)) return Number(value);
return (await brk.getBlockV1(value, { signal })).height;
}
if (kind === "tx") {
const tx = txCache.get(value) ?? (await brk.getTx(value, { signal }));
txCache.set(value, tx);
pendingTx = tx;
return tx.status?.blockHeight ?? null;
}
return null;
}
function route() {
const [kind, value] = pathSegments();
if (pendingTx) {
const hash = pendingTx.status?.blockHash;
const cube = hash ? findCube(hash) : null;
if (cube) selectCube(cube);
showTxFromData(pendingTx, secondaryPanel);
showSecondaryPanel();
pendingTx = null;
} else if (kind === "address" && value) {
const cube = lastCube();
if (cube) selectCube(cube);
navigateToAddr(value);
} else {
const cube = lastCube();
if (cube) selectCube(cube);
}
}
/** @param {string} hash @param {boolean} [pushUrl] */
async function navigateToBlock(hash, pushUrl = true) {
if (pushUrl) history.pushState(null, "", `/block/${hash}`);
const cube = findCube(hash);
if (cube) {
selectCube(cube, { scroll: true });
} else {
const signal = navigate();
try {
clearChain();
await loadInitial(await resolveStartHeight(signal));
if (signal.aborted) return;
route();
} catch (e) {
if (!signal.aborted) console.error("explorer block:", e);
}
}
}
/** @param {string} txid */
async function navigateToTx(txid) {
const cached = txCache.get(txid);
if (cached) {
navigate();
showTxAndSelectBlock(cached);
return;
}
const signal = navigate();
try {
const tx = await brk.getTx(txid, {
signal,
onUpdate: (tx) => {
txCache.set(txid, tx);
if (!signal.aborted) showTxAndSelectBlock(tx);
},
});
txCache.set(txid, tx);
} catch (e) {
if (!signal.aborted) console.error("explorer tx:", e);
}
}
/** @param {Transaction} tx */
function showTxAndSelectBlock(tx) {
if (tx.status?.blockHash) {
const cube = findCube(tx.status.blockHash);
if (cube) {
selectCube(cube, { scroll: true });
showTxFromData(tx, secondaryPanel);
showSecondaryPanel();
return;
}
pendingTx = tx;
clearChain();
loadInitial(tx.status.blockHeight ?? null).then(() => {
if (!navController.signal.aborted) route();
});
return;
}
showTxFromData(tx, secondaryPanel);
showSecondaryPanel();
}
/** @param {string} address */
function navigateToAddr(address) {
const signal = navigate();
showAddrDetail(address, secondaryPanel, { signal, cache: addrCache });
showSecondaryPanel();
}

View File

@@ -0,0 +1,209 @@
export const TX_PAGE_SIZE = 25;
/** @param {number} sats */
export function formatBtc(sats) {
return (sats / 1e8).toFixed(8);
}
/** @param {number} rate */
export function formatFeeRate(rate) {
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 createHeightElement(height) {
const container = document.createElement("span");
const str = height.toString();
const prefix = document.createElement("span");
prefix.style.opacity = "0.5";
prefix.style.userSelect = "none";
prefix.textContent = "#" + "0".repeat(7 - str.length);
const num = document.createElement("span");
num.textContent = str;
container.append(prefix, num);
return container;
}
/**
* @param {[string, string, (string | null)?][]} rows
* @param {HTMLElement} parent
*/
export function renderRows(rows, parent) {
for (const [label, value, href] of rows) {
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(href ? "a" : "span");
valueEl.classList.add("value");
valueEl.textContent = value;
if (href) /** @type {HTMLAnchorElement} */ (valueEl).href = href;
row.append(labelEl, valueEl);
parent.append(row);
}
}
/**
* @param {Transaction} tx
* @param {string} [coinbaseAscii]
*/
const IO_LIMIT = 10;
/**
* @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) {
const limit = Math.min(items.length, IO_LIMIT);
for (let i = 0; i < limit; i++) container.append(render(items[i]));
if (items.length > IO_LIMIT) {
const btn = document.createElement("button");
btn.classList.add("show-more");
btn.textContent = `Show ${items.length - IO_LIMIT} more`;
btn.addEventListener("click", () => {
btn.remove();
for (let i = IO_LIMIT; 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;
}

View File

@@ -0,0 +1,58 @@
import { formatBtc, formatFeeRate, renderRows, renderTx } from "./render.js";
/**
* @param {Transaction} tx
* @param {HTMLDivElement} el
*/
export function showTxFromData(tx, el) {
el.hidden = false;
el.scrollTop = 0;
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);
}

View File

@@ -1,12 +1,12 @@
import { initPrice, onPrice } from "./utils/price.js";
import { brk } from "./client.js";
import { brk } from "./utils/client.js";
import { onFirstIntersection, getElementById, isHidden } from "./utils/dom.js";
import { initOptions } from "./options/full.js";
import {
init as initChart,
setOption as setChartOption,
} from "./panes/chart.js";
import { init as initExplorer } from "./panes/explorer.js";
import { init as initExplorer } from "./explorer/index.js";
import { init as initSearch } from "./panes/search.js";
import { idle } from "./utils/timing.js";
import { readStored, removeStored, writeToStorage } from "./utils/storage.js";

View File

@@ -1,5 +1,5 @@
import { colors } from "../utils/colors.js";
import { brk } from "../client.js";
import { brk } from "../utils/client.js";
import { Unit } from "../utils/units.js";
import {
dots,

View File

@@ -1,7 +1,7 @@
/** Constant helpers for creating price lines and reference lines */
import { colors } from "../utils/colors.js";
import { brk } from "../client.js";
import { brk } from "../utils/client.js";
import { line } from "./series.js";
/**
@@ -13,9 +13,7 @@ import { line } from "./series.js";
*/
export function getConstant(constants, num) {
const key =
num >= 0
? `_${String(num).replace(".", "")}`
: `minus${Math.abs(num)}`;
num >= 0 ? `_${String(num).replace(".", "")}` : `minus${Math.abs(num)}`;
const constant = /** @type {AnySeriesPattern | undefined} */ (
/** @type {Record<string, AnySeriesPattern>} */ (constants)[key]
);

View File

@@ -1,6 +1,6 @@
import { colors } from "../../utils/colors.js";
import { entries } from "../../utils/array.js";
import { brk } from "../../client.js";
import { brk } from "../../utils/client.js";
/** @type {readonly AddressableType[]} */
const ADDRESSABLE_TYPES = [

View File

@@ -8,7 +8,7 @@ import { setQr } from "../panes/share.js";
import { getConstant } from "./constants.js";
import { colors } from "../utils/colors.js";
import { Unit } from "../utils/units.js";
import { brk } from "../client.js";
import { brk } from "../utils/client.js";
export function initOptions() {
const LS_SELECTED_KEY = `selected_path`;
@@ -435,9 +435,11 @@ export function initOptions() {
} else if (!("tree" in match)) {
selected.set(match);
return;
} else {
break;
}
}
selected.set(list[0]);
selected.set(!segments.length && savedOption ? savedOption : list[0]);
}
resolveUrl();

View File

@@ -1,7 +1,7 @@
/** Investing section - Investment strategy tools and analysis */
import { colors } from "../utils/colors.js";
import { brk } from "../client.js";
import { brk } from "../utils/client.js";
import { percentRatioBaseline, price } from "./series.js";
import { satsBtcUsd } from "./shared.js";
import { periodIdToName } from "../utils/time.js";

View File

@@ -2,7 +2,7 @@
import { colors } from "../utils/colors.js";
import { periodIdToName } from "../utils/time.js";
import { brk } from "../client.js";
import { brk } from "../utils/client.js";
import { includes } from "../utils/array.js";
import { Unit } from "../utils/units.js";
import { priceLine, priceLines } from "./constants.js";

View File

@@ -21,7 +21,7 @@ import {
revenueRollingBtcSatsUsd,
formatCohortTitle,
} from "./shared.js";
import { brk } from "../client.js";
import { brk } from "../utils/client.js";
/** Major pools to show in Compare section (by current hashrate dominance) */
const MAJOR_POOL_IDS = /** @type {const} */ ([
@@ -90,20 +90,37 @@ export function createMiningSection() {
title: title(metric),
bottom: [
...ROLLING_WINDOWS.flatMap((w) =>
percentRatio({ pattern: dominance[w.key], name: w.name, color: w.color, defaultActive: w.key !== "_24h" }),
percentRatio({
pattern: dominance[w.key],
name: w.name,
color: w.color,
defaultActive: w.key !== "_24h",
}),
),
...percentRatio({ pattern: dominance, name: "All Time", color: colors.time.all }),
...percentRatio({
pattern: dominance,
name: "All Time",
color: colors.time.all,
}),
],
},
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${metric}`),
bottom: percentRatio({ pattern: dominance[w.key], name: "Dominance", color: w.color }),
bottom: percentRatio({
pattern: dominance[w.key],
name: "Dominance",
color: w.color,
}),
})),
{
name: "All Time",
title: title(`All Time ${metric}`),
bottom: percentRatio({ pattern: dominance, name: "Dominance", color: colors.time.all }),
bottom: percentRatio({
pattern: dominance,
name: "Dominance",
color: colors.time.all,
}),
},
],
});
@@ -151,7 +168,11 @@ export function createMiningSection() {
{
name: "Dominance",
title: title("Dominance"),
bottom: percentRatio({ pattern: pool.dominance, name: "All Time", color: colors.time.all }),
bottom: percentRatio({
pattern: pool.dominance,
name: "All Time",
color: colors.time.all,
}),
},
{
name: "Blocks Mined",
@@ -205,7 +226,6 @@ export function createMiningSection() {
],
});
return {
name: "Mining",
tree: [
@@ -342,7 +362,9 @@ export function createMiningSection() {
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: `${w.title} Fee Revenue per Block Distribution`,
bottom: distributionBtcSatsUsd(statsAtWindow(mining.rewards.fees, w.key)),
bottom: distributionBtcSatsUsd(
statsAtWindow(mining.rewards.fees, w.key),
),
})),
},
],
@@ -354,16 +376,32 @@ export function createMiningSection() {
name: w.name,
title: `${w.title} Mining Revenue Dominance`,
bottom: [
...percentRatio({ pattern: mining.rewards.subsidy.dominance[w.key], name: "Subsidy", color: colors.mining.subsidy }),
...percentRatio({ pattern: mining.rewards.fees.dominance[w.key], name: "Fees", color: colors.mining.fee }),
...percentRatio({
pattern: mining.rewards.subsidy.dominance[w.key],
name: "Subsidy",
color: colors.mining.subsidy,
}),
...percentRatio({
pattern: mining.rewards.fees.dominance[w.key],
name: "Fees",
color: colors.mining.fee,
}),
],
})),
{
name: "All Time",
title: "All Time Mining Revenue Dominance",
bottom: [
...percentRatio({ pattern: mining.rewards.subsidy.dominance, name: "Subsidy", color: colors.mining.subsidy }),
...percentRatio({ pattern: mining.rewards.fees.dominance, name: "Fees", color: colors.mining.fee }),
...percentRatio({
pattern: mining.rewards.subsidy.dominance,
name: "Subsidy",
color: colors.mining.subsidy,
}),
...percentRatio({
pattern: mining.rewards.fees.dominance,
name: "Fees",
color: colors.mining.fee,
}),
],
},
],
@@ -373,7 +411,14 @@ export function createMiningSection() {
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: `${w.title} Fee-to-Subsidy Ratio`,
bottom: [line({ series: mining.rewards.fees.toSubsidyRatio[w.key].ratio, name: "Ratio", color: colors.mining.fee, unit: Unit.ratio })],
bottom: [
line({
series: mining.rewards.fees.toSubsidyRatio[w.key].ratio,
name: "Ratio",
color: colors.mining.fee,
unit: Unit.ratio,
}),
],
})),
},
{
@@ -395,28 +440,76 @@ export function createMiningSection() {
name: "Hash Price",
title: "Hash Price",
bottom: [
line({ series: mining.hashrate.price.ths, name: "per TH/s", color: colors.usd, unit: Unit.usdPerThsPerDay }),
line({ series: mining.hashrate.price.phs, name: "per PH/s", color: colors.usd, unit: Unit.usdPerPhsPerDay }),
dotted({ series: mining.hashrate.price.thsMin, name: "per TH/s ATL", color: colors.stat.min, unit: Unit.usdPerThsPerDay }),
dotted({ series: mining.hashrate.price.phsMin, name: "per PH/s ATL", color: colors.stat.min, unit: Unit.usdPerPhsPerDay }),
line({
series: mining.hashrate.price.ths,
name: "per TH/s",
color: colors.usd,
unit: Unit.usdPerThsPerDay,
}),
line({
series: mining.hashrate.price.phs,
name: "per PH/s",
color: colors.usd,
unit: Unit.usdPerPhsPerDay,
}),
dotted({
series: mining.hashrate.price.thsMin,
name: "per TH/s ATL",
color: colors.stat.min,
unit: Unit.usdPerThsPerDay,
}),
dotted({
series: mining.hashrate.price.phsMin,
name: "per PH/s ATL",
color: colors.stat.min,
unit: Unit.usdPerPhsPerDay,
}),
],
},
{
name: "Hash Value",
title: "Hash Value",
bottom: [
line({ series: mining.hashrate.value.ths, name: "per TH/s", color: colors.bitcoin, unit: Unit.satsPerThsPerDay }),
line({ series: mining.hashrate.value.phs, name: "per PH/s", color: colors.bitcoin, unit: Unit.satsPerPhsPerDay }),
dotted({ series: mining.hashrate.value.thsMin, name: "per TH/s ATL", color: colors.stat.min, unit: Unit.satsPerThsPerDay }),
dotted({ series: mining.hashrate.value.phsMin, name: "per PH/s ATL", color: colors.stat.min, unit: Unit.satsPerPhsPerDay }),
line({
series: mining.hashrate.value.ths,
name: "per TH/s",
color: colors.bitcoin,
unit: Unit.satsPerThsPerDay,
}),
line({
series: mining.hashrate.value.phs,
name: "per PH/s",
color: colors.bitcoin,
unit: Unit.satsPerPhsPerDay,
}),
dotted({
series: mining.hashrate.value.thsMin,
name: "per TH/s ATL",
color: colors.stat.min,
unit: Unit.satsPerThsPerDay,
}),
dotted({
series: mining.hashrate.value.phsMin,
name: "per PH/s ATL",
color: colors.stat.min,
unit: Unit.satsPerPhsPerDay,
}),
],
},
{
name: "Recovery",
title: "Hash Price & Value Recovery",
bottom: [
...percentRatio({ pattern: mining.hashrate.price.rebound, name: "Hash Price", color: colors.usd }),
...percentRatio({ pattern: mining.hashrate.value.rebound, name: "Hash Value", color: colors.bitcoin }),
...percentRatio({
pattern: mining.hashrate.price.rebound,
name: "Hash Price",
color: colors.usd,
}),
...percentRatio({
pattern: mining.hashrate.value.rebound,
name: "Hash Value",
color: colors.bitcoin,
}),
],
},
],
@@ -429,14 +522,28 @@ export function createMiningSection() {
name: "Countdown",
title: "Next Halving",
bottom: [
line({ series: blocks.halving.blocksToHalving, name: "Blocks", unit: Unit.blocks }),
line({ series: blocks.halving.daysToHalving, name: "Days", unit: Unit.days }),
line({
series: blocks.halving.blocksToHalving,
name: "Blocks",
unit: Unit.blocks,
}),
line({
series: blocks.halving.daysToHalving,
name: "Days",
unit: Unit.days,
}),
],
},
{
name: "Epoch",
title: "Halving Epoch",
bottom: [line({ series: blocks.halving.epoch, name: "Epoch", unit: Unit.epoch })],
bottom: [
line({
series: blocks.halving.epoch,
name: "Epoch",
unit: Unit.epoch,
}),
],
},
],
},
@@ -447,25 +554,48 @@ export function createMiningSection() {
{
name: "Current",
title: "Mining Difficulty",
bottom: [line({ series: blocks.difficulty.value, name: "Difficulty", unit: Unit.difficulty })],
bottom: [
line({
series: blocks.difficulty.value,
name: "Difficulty",
unit: Unit.difficulty,
}),
],
},
{
name: "Adjustment",
title: "Difficulty Adjustment",
bottom: percentRatioBaseline({ pattern: blocks.difficulty.adjustment, name: "Change" }),
bottom: percentRatioBaseline({
pattern: blocks.difficulty.adjustment,
name: "Change",
}),
},
{
name: "Countdown",
title: "Next Difficulty Adjustment",
bottom: [
line({ series: blocks.difficulty.blocksToRetarget, name: "Blocks", unit: Unit.blocks }),
line({ series: blocks.difficulty.daysToRetarget, name: "Days", unit: Unit.days }),
line({
series: blocks.difficulty.blocksToRetarget,
name: "Blocks",
unit: Unit.blocks,
}),
line({
series: blocks.difficulty.daysToRetarget,
name: "Days",
unit: Unit.days,
}),
],
},
{
name: "Epoch",
title: "Difficulty Epoch",
bottom: [line({ series: blocks.difficulty.epoch, name: "Epoch", unit: Unit.epoch })],
bottom: [
line({
series: blocks.difficulty.epoch,
name: "Epoch",
unit: Unit.epoch,
}),
],
},
],
},

View File

@@ -1,7 +1,7 @@
/** Network section - On-chain activity and health */
import { colors } from "../utils/colors.js";
import { brk } from "../client.js";
import { brk } from "../utils/client.js";
import { Unit } from "../utils/units.js";
import { entries } from "../utils/array.js";
import {
@@ -19,7 +19,12 @@ import {
multiSeriesTree,
percentRatioDots,
} from "./series.js";
import { satsBtcUsd, satsBtcUsdFrom, satsBtcUsdFullTree, formatCohortTitle } from "./shared.js";
import {
satsBtcUsd,
satsBtcUsdFrom,
satsBtcUsdFullTree,
formatCohortTitle,
} from "./shared.js";
/**
* Create Network section
@@ -119,75 +124,79 @@ export function createNetworkSection() {
const createAddressSeriesTree = (key, typeName) => {
const title = formatCohortTitle(typeName);
return [
{
name: "Count",
tree: [
{
name: "Compare",
title: title("Address Count"),
bottom: countMetrics.map((m) =>
line({
series: addrs[m.key][key],
name: m.name,
color: m.color,
unit: Unit.count,
}),
),
},
...countMetrics.map((m) => ({
name: m.name,
title: title(`${m.name} Addresses`),
bottom: [
line({ series: addrs[m.key][key], name: m.name, unit: Unit.count }),
],
})),
],
},
...simpleDeltaTree({
delta: addrs.delta[key],
title,
metric: "Address Count",
unit: Unit.count,
}),
{
name: "New",
tree: chartsFromCount({
pattern: addrs.new[key],
title,
metric: "New Addresses",
unit: Unit.count,
}),
},
{
name: "Activity",
tree: [
{
name: "Compare",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Active Addresses`),
bottom: activityTypes.map((t, i) =>
{
name: "Count",
tree: [
{
name: "Compare",
title: title("Address Count"),
bottom: countMetrics.map((m) =>
line({
series: addrs.activity[key][t.key][w.key],
name: t.name,
color: colors.at(i, activityTypes.length),
series: addrs[m.key][key],
name: m.name,
color: m.color,
unit: Unit.count,
}),
),
},
...countMetrics.map((m) => ({
name: m.name,
title: title(`${m.name} Addresses`),
bottom: [
line({
series: addrs[m.key][key],
name: m.name,
unit: Unit.count,
}),
],
})),
},
...activityTypes.map((t) => ({
name: t.name,
tree: averagesArray({
windows: addrs.activity[key][t.key],
title,
metric: `${t.name} Addresses`,
unit: Unit.count,
}),
})),
],
},
];
],
},
...simpleDeltaTree({
delta: addrs.delta[key],
title,
metric: "Address Count",
unit: Unit.count,
}),
{
name: "New",
tree: chartsFromCount({
pattern: addrs.new[key],
title,
metric: "New Addresses",
unit: Unit.count,
}),
},
{
name: "Activity",
tree: [
{
name: "Compare",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Active Addresses`),
bottom: activityTypes.map((t, i) =>
line({
series: addrs.activity[key][t.key][w.key],
name: t.name,
color: colors.at(i, activityTypes.length),
unit: Unit.count,
}),
),
})),
},
...activityTypes.map((t) => ({
name: t.name,
tree: averagesArray({
windows: addrs.activity[key][t.key],
title,
metric: `${t.name} Addresses`,
unit: Unit.count,
}),
})),
],
},
];
};
/** @type {Record<string, typeof scriptTypes[number]>} */

View File

@@ -5,7 +5,7 @@ import { Unit } from "../utils/units.js";
import { createChart } from "../utils/chart/index.js";
import { colors } from "../utils/colors.js";
import { latestPrice, onPrice } from "../utils/price.js";
import { brk } from "../client.js";
import { brk } from "../utils/client.js";
const ONE_BTC_IN_SATS = 100_000_000;

View File

@@ -1,959 +0,0 @@
import { explorerElement } from "../utils/elements.js";
import { brk } from "../client.js";
import { createPersistedValue } from "../utils/persisted.js";
const LOOKAHEAD = 15;
const TX_PAGE_SIZE = 25;
/** @type {HTMLDivElement} */ let chain;
/** @type {HTMLDivElement} */ let blocksEl;
/** @type {HTMLDivElement} */ let blockDetails;
/** @type {HTMLDivElement} */ let txDetails;
/** @type {HTMLDivElement | null} */ let selectedCube = null;
/** @type {number | undefined} */ let pollInterval;
/** @type {IntersectionObserver} */ let olderObserver;
/** @type {Map<BlockHash, BlockInfoV1>} */
const blocksByHash = new Map();
let newestHeight = -1;
let oldestHeight = Infinity;
let loadingLatest = false;
let loadingOlder = false;
let loadingNewer = false;
let reachedTip = false;
/** @type {HTMLSpanElement} */ let heightPrefix;
/** @type {HTMLSpanElement} */ let heightNum;
/** @type {{ row: HTMLDivElement, valueEl: HTMLSpanElement }[]} */ let detailRows;
/** @type {HTMLDivElement} */ let txList;
/** @type {HTMLDivElement} */ let txSection;
/** @type {IntersectionObserver} */ let txObserver;
/** @typedef {{ first: HTMLButtonElement, prev: HTMLButtonElement, label: HTMLSpanElement, next: HTMLButtonElement, last: HTMLButtonElement }} TxNav */
/** @type {TxNav[]} */ let 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),
});
/** @returns {string[]} */
function pathSegments() {
return window.location.pathname.split("/").filter((v) => v);
}
export function init() {
chain = document.createElement("div");
chain.id = "chain";
explorerElement.append(chain);
blocksEl = document.createElement("div");
blocksEl.classList.add("blocks");
chain.append(blocksEl);
blockDetails = document.createElement("div");
blockDetails.id = "block-details";
explorerElement.append(blockDetails);
txDetails = document.createElement("div");
txDetails.id = "tx-details";
txDetails.hidden = true;
explorerElement.append(txDetails);
initBlockDetails();
initTxDetails();
olderObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadOlder();
},
{ root: chain },
);
chain.addEventListener(
"scroll",
() => {
const nearStart =
(chain.scrollHeight > chain.clientHeight && chain.scrollTop <= 50) ||
(chain.scrollWidth > chain.clientWidth && chain.scrollLeft <= 50);
if (nearStart && !reachedTip && !loadingNewer) loadNewer();
},
{ passive: true },
);
new MutationObserver(() => {
if (explorerElement.hidden) stopPolling();
else startPolling();
}).observe(explorerElement, {
attributes: true,
attributeFilter: ["hidden"],
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden && !explorerElement.hidden) loadLatest();
});
window.addEventListener("popstate", () => {
const [kind, value] = pathSegments();
if (kind === "block" && value) navigateToBlock(value, false);
else if (kind === "tx" && value) showTxDetail(value);
else if (kind === "address" && value) showAddrDetail(value);
else {
blockDetails.hidden = false;
txDetails.hidden = true;
}
});
loadLatest();
}
function startPolling() {
stopPolling();
loadLatest();
pollInterval = setInterval(loadLatest, 15_000);
}
function stopPolling() {
if (pollInterval !== undefined) {
clearInterval(pollInterval);
pollInterval = undefined;
}
}
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 (const b of [...blocks].reverse()) {
if (b.height > newestHeight) {
blocksEl.append(createBlockCube(b));
} else {
blocksByHash.set(b.id, b);
}
}
newestHeight = Math.max(newestHeight, blocks[0].height);
if (anchor && anchorRect) {
const r = anchor.getBoundingClientRect();
chain.scrollTop += r.top - anchorRect.top;
chain.scrollLeft += r.left - anchorRect.left;
}
return true;
}
/** @param {string} hash @param {boolean} [pushUrl] */
function navigateToBlock(hash, pushUrl = true) {
if (pushUrl) history.pushState(null, "", `/block/${hash}`);
const cube = /** @type {HTMLDivElement | null} */ (
blocksEl.querySelector(`[data-hash="${hash}"]`)
);
if (cube) {
selectCube(cube, { scroll: true });
} else {
resetExplorer();
}
}
function resetExplorer() {
newestHeight = -1;
oldestHeight = Infinity;
loadingLatest = false;
loadingOlder = false;
loadingNewer = false;
reachedTip = false;
selectedCube = null;
blocksEl.innerHTML = "";
olderObserver.disconnect();
loadLatest();
}
/** @returns {Promise<number | null>} */
/** @type {Transaction | null} */
let pendingTx = null;
async function getStartHeight() {
if (pendingTx) return pendingTx.status?.blockHeight ?? null;
const [kind, value] = pathSegments();
if (!value) return null;
if (kind === "block") {
if (/^\d+$/.test(value)) return Number(value);
return (await brk.getBlockV1(value)).height;
}
if (kind === "tx") {
pendingTx = await brk.getTx(value);
return pendingTx.status?.blockHeight ?? null;
}
return null;
}
async function loadLatest() {
if (loadingLatest) return;
if (newestHeight !== -1 && !reachedTip) return;
loadingLatest = true;
try {
const startHeight = newestHeight === -1 ? await getStartHeight() : null;
const blocks =
startHeight !== null
? await brk.getBlocksV1FromHeight(startHeight)
: await brk.getBlocksV1();
if (newestHeight === -1) {
for (const b of blocks) blocksEl.prepend(createBlockCube(b));
newestHeight = blocks[0].height;
oldestHeight = blocks[blocks.length - 1].height;
if (startHeight === null) reachedTip = true;
const [kind, value] = pathSegments();
if (pendingTx) {
const hash = pendingTx.status?.blockHash;
const cube = /** @type {HTMLDivElement | null} */ (
hash ? blocksEl.querySelector(`[data-hash="${hash}"]`) : null
);
if (cube) selectCube(cube);
showTxFromData(pendingTx);
pendingTx = null;
} else if (kind === "address" && value) {
selectCube(/** @type {HTMLDivElement} */ (blocksEl.lastElementChild));
showAddrDetail(value);
} else {
selectCube(/** @type {HTMLDivElement} */ (blocksEl.lastElementChild));
}
loadingLatest = false;
observeOldestEdge();
if (!reachedTip) await loadNewer();
return;
}
appendNewerBlocks(blocks);
reachedTip = true;
} catch (e) {
console.error("explorer poll:", e);
}
loadingLatest = false;
}
async function loadOlder() {
if (loadingOlder || oldestHeight <= 0) return;
loadingOlder = true;
try {
const blocks = await brk.getBlocksV1FromHeight(oldestHeight - 1);
for (const block of blocks) blocksEl.prepend(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 blocks = await brk.getBlocksV1FromHeight(newestHeight + LOOKAHEAD);
if (!appendNewerBlocks(blocks)) {
reachedTip = true;
}
} catch (e) {
console.error("explorer loadNewer:", e);
}
loadingNewer = false;
}
/** @param {HTMLDivElement} cube @param {{ pushUrl?: boolean, scroll?: boolean }} [opts] */
function selectCube(cube, { pushUrl = false, scroll = false } = {}) {
if (cube === selectedCube) return;
if (selectedCube) selectedCube.classList.remove("selected");
selectedCube = cube;
if (cube) {
cube.classList.add("selected");
if (scroll) cube.scrollIntoView({ behavior: "smooth" });
const hash = cube.dataset.hash;
if (hash) {
updateDetails(blocksByHash.get(hash));
if (pushUrl) history.pushState(null, "", `/block/${hash}`);
}
}
}
/** @typedef {[string, (b: BlockInfoV1) => string | null, ((b: BlockInfoV1) => string | null)?]} RowDef */
/** @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", (b) => (b.extras ? `$${b.extras.price.toLocaleString()}` : null)],
["Pool", (b) => b.extras?.pool.name ?? null],
["Pool ID", (b) => b.extras?.pool.id.toString() ?? null],
["Pool Slug", (b) => b.extras?.pool.slug ?? null],
["Miner Names", (b) => b.extras?.pool.minerNames?.join(", ") || null],
[
"Reward",
(b) => (b.extras ? `${(b.extras.reward / 1e8).toFixed(8)} BTC` : null),
],
[
"Total Fees",
(b) => (b.extras ? `${(b.extras.totalFees / 1e8).toFixed(8)} BTC` : null),
],
[
"Median Fee Rate",
(b) => (b.extras ? `${formatFeeRate(b.extras.medianFee)} sat/vB` : null),
],
[
"Avg Fee Rate",
(b) => (b.extras ? `${formatFeeRate(b.extras.avgFeeRate)} sat/vB` : null),
],
[
"Avg Fee",
(b) => (b.extras ? `${b.extras.avgFee.toLocaleString()} sat` : null),
],
[
"Median Fee",
(b) => (b.extras ? `${b.extras.medianFeeAmt.toLocaleString()} sat` : null),
],
[
"Fee Range",
(b) =>
b.extras
? b.extras.feeRange.map((f) => formatFeeRate(f)).join(", ") + " sat/vB"
: null,
],
[
"Fee Percentiles",
(b) =>
b.extras
? b.extras.feePercentiles.map((f) => f.toLocaleString()).join(", ") +
" sat"
: null,
],
[
"Avg Tx Size",
(b) => (b.extras ? `${b.extras.avgTxSize.toLocaleString()} B` : null),
],
[
"Virtual Size",
(b) => (b.extras ? `${b.extras.virtualSize.toLocaleString()} vB` : null),
],
["Inputs", (b) => b.extras?.totalInputs.toLocaleString() ?? null],
["Outputs", (b) => b.extras?.totalOutputs.toLocaleString() ?? null],
[
"Total Input Amount",
(b) =>
b.extras ? `${(b.extras.totalInputAmt / 1e8).toFixed(8)} BTC` : null,
],
[
"Total Output Amount",
(b) =>
b.extras ? `${(b.extras.totalOutputAmt / 1e8).toFixed(8)} BTC` : null,
],
["UTXO Set Change", (b) => b.extras?.utxoSetChange.toLocaleString() ?? null],
["UTXO Set Size", (b) => b.extras?.utxoSetSize.toLocaleString() ?? null],
["SegWit Txs", (b) => b.extras?.segwitTotalTxs.toLocaleString() ?? null],
[
"SegWit Size",
(b) => (b.extras ? `${b.extras.segwitTotalSize.toLocaleString()} B` : null),
],
[
"SegWit Weight",
(b) =>
b.extras ? `${b.extras.segwitTotalWeight.toLocaleString()} WU` : null,
],
["Coinbase Address", (b) => b.extras?.coinbaseAddress || null],
["Coinbase Addresses", (b) => b.extras?.coinbaseAddresses.join(", ") || null],
["Coinbase Raw", (b) => b.extras?.coinbaseRaw ?? null],
["Coinbase Signature", (b) => b.extras?.coinbaseSignature ?? null],
["Coinbase Signature ASCII", (b) => b.extras?.coinbaseSignatureAscii ?? null],
["Header", (b) => b.extras?.header ?? null],
];
/** @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();
if (m[1] === "block") {
navigateToBlock(m[2]);
} else if (m[1] === "tx") {
history.pushState(null, "", a.href);
showTxDetail(m[2]);
} else {
history.pushState(null, "", a.href);
showAddrDetail(m[2]);
}
}
function initBlockDetails() {
const title = document.createElement("h1");
title.textContent = "Block ";
const code = document.createElement("code");
const container = document.createElement("span");
heightPrefix = document.createElement("span");
heightPrefix.style.opacity = "0.5";
heightPrefix.style.userSelect = "none";
heightNum = document.createElement("span");
container.append(heightPrefix, heightNum);
code.append(container);
title.append(code);
blockDetails.append(title);
blockDetails.addEventListener("click", handleLinkClick);
detailRows = ROW_DEFS.map(([label, , linkFn]) => {
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(linkFn ? "a" : "span");
valueEl.classList.add("value");
row.append(labelEl, valueEl);
blockDetails.append(row);
return { row, valueEl };
});
txSection = document.createElement("div");
txSection.classList.add("transactions");
blockDetails.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);
}
/** @returns {HTMLDivElement} */
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 | undefined} block */
function updateDetails(block) {
if (!block) return;
blockDetails.hidden = false;
txDetails.hidden = true;
blockDetails.scrollTop = 0;
const str = block.height.toString();
heightPrefix.textContent = "#" + "0".repeat(7 - str.length);
heightNum.textContent = str;
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);
}
function initTxDetails() {
txDetails.addEventListener("click", handleLinkClick);
}
/** @param {string} txid */
async function showTxDetail(txid) {
try {
const tx = await brk.getTx(txid);
if (tx.status?.blockHash) {
const cube = /** @type {HTMLDivElement | null} */ (
blocksEl.querySelector(`[data-hash="${tx.status.blockHash}"]`)
);
if (cube) {
selectCube(cube, { scroll: true });
showTxFromData(tx);
return;
}
pendingTx = tx;
resetExplorer();
return;
}
showTxFromData(tx);
} catch (e) {
console.error("explorer tx:", e);
}
}
/** @param {Transaction} tx */
function showTxFromData(tx) {
blockDetails.hidden = true;
txDetails.hidden = false;
txDetails.scrollTop = 0;
txDetails.innerHTML = "";
const title = document.createElement("h1");
title.textContent = "Transaction";
txDetails.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);
/** @type {[string, string, (string | null)?][]} */
const rows = [
["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}`],
];
for (const [label, value, href] of rows) {
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(href ? "a" : "span");
valueEl.classList.add("value");
valueEl.textContent = value;
if (href) /** @type {HTMLAnchorElement} */ (valueEl).href = href;
row.append(labelEl, valueEl);
txDetails.append(row);
}
const section = document.createElement("div");
section.classList.add("transactions");
const heading = document.createElement("h2");
heading.textContent = "Inputs & Outputs";
section.append(heading);
section.append(renderTx(tx));
txDetails.append(section);
}
/** @param {string} address */
async function showAddrDetail(address) {
blockDetails.hidden = true;
txDetails.hidden = false;
txDetails.scrollTop = 0;
txDetails.innerHTML = "";
try {
const stats = await brk.getAddress(address);
const chain = stats.chainStats;
const title = document.createElement("h1");
title.textContent = "Address";
txDetails.append(title);
const addrEl = document.createElement("div");
addrEl.classList.add("row");
const addrLabel = document.createElement("span");
addrLabel.classList.add("label");
addrLabel.textContent = "Address";
const addrValue = document.createElement("span");
addrValue.classList.add("value");
addrValue.textContent = address;
addrEl.append(addrLabel, addrValue);
txDetails.append(addrEl);
const balance = chain.fundedTxoSum - chain.spentTxoSum;
/** @type {[string, string][]} */
const rows = [
["Balance", `${formatBtc(balance)} BTC`],
["Total Received", `${formatBtc(chain.fundedTxoSum)} BTC`],
["Total Sent", `${formatBtc(chain.spentTxoSum)} BTC`],
["Tx Count", chain.txCount.toLocaleString()],
["Funded Outputs", chain.fundedTxoCount.toLocaleString()],
["Spent Outputs", chain.spentTxoCount.toLocaleString()],
];
for (const [label, value] of rows) {
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("span");
valueEl.classList.add("value");
valueEl.textContent = value;
row.append(labelEl, valueEl);
txDetails.append(row);
}
const section = document.createElement("div");
section.classList.add("transactions");
const heading = document.createElement("h2");
heading.textContent = "Transactions";
section.append(heading);
txDetails.append(section);
let loadingAddr = false;
let addrTxCount = 0;
/** @type {string | undefined} */
let afterTxid;
const addrTxObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !loadingAddr && addrTxCount < chain.txCount)
loadMore();
});
async function loadMore() {
loadingAddr = true;
try {
const txs = await brk.getAddressTxs(address, afterTxid);
for (const tx of txs) section.append(renderTx(tx));
addrTxCount += txs.length;
if (txs.length) {
afterTxid = txs[txs.length - 1].txid;
addrTxObserver.disconnect();
const last = section.lastElementChild;
if (last) addrTxObserver.observe(last);
}
} catch (e) {
console.error("explorer addr txs:", e);
addrTxCount = chain.txCount;
}
loadingAddr = false;
}
await loadMore();
} catch (e) {
console.error("explorer addr:", e);
txDetails.textContent = "Address not found";
}
}
/** @param {number} page @param {boolean} [pushUrl] */
async function loadTxPage(page, pushUrl = true) {
if (txLoading || !txBlock || page < 0 || page >= txTotalPages) return;
txLoading = true;
txLoaded = true;
if (pushUrl) txPageParam.setImmediate(page);
updateTxNavs(page);
try {
const txs = await brk.getBlockTxsFromIndex(txBlock.id, page * TX_PAGE_SIZE);
txList.innerHTML = "";
for (const tx of txs) txList.append(renderTx(tx));
} catch (e) {
console.error("explorer txs:", e);
}
txLoading = false;
}
/** @param {Transaction} tx */
function renderTx(tx) {
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");
for (const vin of tx.vin) {
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");
const ascii = txBlock?.extras?.coinbaseSignatureAscii;
if (ascii) {
const sig = document.createElement("span");
sig.classList.add("coinbase-sig");
sig.textContent = ascii;
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);
inputs.append(row);
}
const outputs = document.createElement("div");
outputs.classList.add("tx-outputs");
let totalOut = 0;
for (const vout of tx.vout) {
totalOut += vout.value;
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);
outputs.append(row);
}
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;
}
/** @param {number} sats */
function formatBtc(sats) {
return (sats / 1e8).toFixed(8);
}
/** @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 {string} text @param {HTMLElement} el */
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 */
function createHeightElement(height) {
const container = document.createElement("span");
const str = height.toString();
const prefix = document.createElement("span");
prefix.style.opacity = "0.5";
prefix.style.userSelect = "none";
prefix.textContent = "#" + "0".repeat(7 - str.length);
const num = document.createElement("span");
num.textContent = str;
container.append(prefix, num);
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, { pushUrl: true }),
);
const heightEl = document.createElement("p");
heightEl.append(createHeightElement(block.height));
rightFaceElement.append(heightEl);
const feesEl = document.createElement("div");
feesEl.classList.add("fees");
leftFaceElement.append(feesEl);
const extras = block.extras;
const medianFee = extras ? extras.medianFee : 0;
const feeRange = extras ? extras.feeRange : [0, 0, 0, 0, 0, 0, 0];
const avg = document.createElement("p");
avg.innerHTML = `~${formatFeeRate(medianFee)}`;
feesEl.append(avg);
const range = document.createElement("p");
const min = document.createElement("span");
min.innerHTML = formatFeeRate(feeRange[0]);
const dash = document.createElement("span");
dash.style.opacity = "0.5";
dash.innerHTML = `-`;
const max = document.createElement("span");
max.innerHTML = formatFeeRate(feeRange[6]);
range.append(min, dash, max);
feesEl.append(range);
const unit = document.createElement("p");
unit.style.opacity = "0.5";
unit.innerHTML = `sat/vB`;
feesEl.append(unit);
const miner = document.createElement("span");
miner.innerHTML = extras ? extras.pool.name : "Unknown";
topFaceElement.append(miner);
return cubeElement;
}
function createCube() {
const cubeElement = document.createElement("div");
cubeElement.classList.add("cube");
const rightFaceElement = document.createElement("div");
rightFaceElement.classList.add("face", "right");
cubeElement.append(rightFaceElement);
const leftFaceElement = document.createElement("div");
leftFaceElement.classList.add("face", "left");
cubeElement.append(leftFaceElement);
const topFaceElement = document.createElement("div");
topFaceElement.classList.add("face", "top");
cubeElement.append(topFaceElement);
return { cubeElement, leftFaceElement, rightFaceElement, topFaceElement };
}

View File

@@ -0,0 +1,32 @@
/**
* @template V
* @param {number} [maxSize]
*/
export function createMapCache(maxSize = 100) {
/** @type {Map<string, V>} */
const map = new Map();
return {
/** @param {string} key @returns {V | undefined} */
get(key) {
return map.get(key);
},
/** @param {string} key @returns {boolean} */
has(key) {
return map.has(key);
},
/** @param {string} key @param {V} value */
set(key, value) {
if (map.size >= maxSize && !map.has(key)) {
const first = map.keys().next().value;
if (first !== undefined) map.delete(first);
}
map.set(key, value);
},
};
}
/**
* @template V
* @typedef {{ get: (key: string) => V | undefined, has: (key: string) => boolean, set: (key: string, value: V) => void }} MapCache
*/

View File

@@ -10,6 +10,7 @@ import { capture } from "./capture.js";
import { colors } from "../colors.js";
import { createRadios, createSelect, getElementById } from "../dom.js";
import { createPersistedValue } from "../persisted.js";
import { createMapCache } from "../cache.js";
import { onChange as onThemeChange } from "../theme.js";
import { throttle, debounce } from "../timing.js";
import { serdeBool, INDEX_FROM_LABEL } from "../serde.js";
@@ -190,9 +191,7 @@ export function createChart({ parent, brk, fitContent }) {
},
};
// Memory cache for instant index switching
/** @type {Map<string, AnySeriesData>} */
const cache = new Map();
const cache = createMapCache(Infinity);
// Range state: localStorage stores all ranges per-index, URL stores current range only
/** @typedef {{ from: number, to: number }} Range */

View File

@@ -1,4 +1,4 @@
import { BrkClient } from "./modules/brk-client/index.js";
import { BrkClient } from "../modules/brk-client/index.js";
// const brk = new BrkClient("https://bitview.space");
const brk = new BrkClient("/");

View File

@@ -40,7 +40,7 @@ export const Unit = /** @type {const} */ ({
epoch: { id: "epoch", name: "Epoch" },
// Fees
feeRate: { id: "feerate", name: "Sats/vByte" },
feeRate: { id: "feerate", name: "Sat/vByte" },
// Rates
perSec: { id: "per-sec", name: "Per Second" },