global: snapshot

This commit is contained in:
nym21
2026-04-08 12:09:35 +02:00
parent 0a4cb0601f
commit 3a7887348c
36 changed files with 5220 additions and 1585 deletions

View File

@@ -1,32 +1,81 @@
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";
import { formatBtc, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
/** @type {MapCache<Transaction[]>} */
const addrTxCache = createMapCache(200);
/** @type {HTMLDivElement} */ let el;
/** @type {HTMLSpanElement[]} */ let valueEls;
/** @type {HTMLDivElement} */ let txSection;
/** @type {string} */ let currentAddr = "";
const statsCache = createMapCache(50);
const txCache = createMapCache(200);
const ROW_LABELS = [
"Address",
"Confirmed Balance",
"Pending",
"Confirmed UTXOs",
"Pending UTXOs",
"Total Received",
"Tx Count",
"Type",
"Avg Cost Basis",
];
/** @param {HTMLElement} parent @param {(e: MouseEvent) => void} linkHandler */
export function initAddrDetails(parent, linkHandler) {
el = document.createElement("div");
el.id = "addr-details";
el.hidden = true;
parent.append(el);
el.addEventListener("click", linkHandler);
const title = document.createElement("h1");
title.textContent = "Address";
el.append(title);
valueEls = ROW_LABELS.map((label) => {
const row = 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");
row.append(labelEl, valueEl);
el.append(row);
return valueEl;
});
txSection = document.createElement("div");
txSection.classList.add("transactions");
const heading = document.createElement("h2");
heading.textContent = "Transactions";
txSection.append(heading);
el.append(txSection);
}
/**
* @param {string} address
* @param {HTMLDivElement} el
* @param {{ signal: AbortSignal, cache: MapCache<AddrStats> }} options
* @param {AbortSignal} signal
*/
export async function showAddrDetail(address, el, { signal, cache }) {
el.hidden = false;
el.scrollTop = 0;
el.innerHTML = "";
export async function update(address, signal) {
currentAddr = address;
valueEls[0].textContent = address;
for (let i = 1; i < valueEls.length; i++) {
valueEls[i].textContent = "...";
valueEls[i].classList.add("dim");
}
while (txSection.children.length > 1) txSection.lastChild?.remove();
try {
const cached = cache.get(address);
const cached = statsCache.get(address);
const stats = cached ?? (await brk.getAddress(address, { signal }));
if (!cached) cache.set(address, stats);
if (signal.aborted) return;
if (!cached) statsCache.set(address, stats);
if (signal.aborted || currentAddr !== address) 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;
@@ -40,40 +89,26 @@ export async function showAddrDetail(address, el, { signal, cache }) {
? ` $${((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 values = [
address,
`${formatBtc(balance)} BTC${fmtUsd(balance)}`,
`${pending >= 0 ? "+" : ""}${formatBtc(pending)} BTC${fmtUsd(pending)}`,
confirmedUtxos.toLocaleString(),
pendingUtxos.toLocaleString(),
`${formatBtc(chain.fundedTxoSum)} BTC`,
chain.txCount.toLocaleString(),
(/** @type {any} */ (stats).addrType ?? "unknown")
.replace(/^v\d+_/, "")
.toUpperCase(),
chain.realizedPrice
? `$${Number(chain.realizedPrice).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: "N/A",
];
const section = document.createElement("div");
section.classList.add("transactions");
const heading = document.createElement("h2");
heading.textContent = "Transactions";
section.append(heading);
el.append(section);
for (let i = 0; i < valueEls.length; i++) {
valueEls[i].textContent = values[i];
valueEls[i].classList.remove("dim");
}
let loading = false;
let pageIndex = 0;
@@ -81,35 +116,44 @@ export async function showAddrDetail(address, el, { signal, cache }) {
let afterTxid;
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !loading && pageIndex * TX_PAGE_SIZE < chain.txCount)
if (
entries[0].isIntersecting &&
!loading &&
pageIndex * TX_PAGE_SIZE < chain.txCount
)
loadMore();
});
async function loadMore() {
if (currentAddr !== address) return;
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));
const cached = txCache.get(key);
const txs =
cached ?? (await brk.getAddressTxs(address, afterTxid, { signal }));
if (!cached) txCache.set(key, txs);
if (currentAddr !== address) return;
for (const tx of txs) txSection.append(renderTx(tx));
pageIndex++;
if (txs.length) {
afterTxid = txs[txs.length - 1].txid;
observer.disconnect();
const last = section.lastElementChild;
const last = txSection.lastElementChild;
if (last) observer.observe(last);
}
} catch (e) {
console.error("explorer addr txs:", e);
pageIndex = chain.txCount; // stop loading
if (!signal.aborted) console.error("explorer addr txs:", e);
pageIndex = chain.txCount;
}
loading = false;
}
await loadMore();
} catch (e) {
console.error("explorer addr:", e);
el.textContent = "Address not found";
if (!signal.aborted) console.error("explorer addr:", e);
}
}
export function show() { showPanel(el); }
export function hide() { hidePanel(el); }

View File

@@ -1,7 +1,7 @@
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";
import { formatFeeRate, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
/** @typedef {[string, (b: BlockInfoV1) => string | null, ((b: BlockInfoV1) => string | null)?]} RowDef */
@@ -88,7 +88,7 @@ export function initBlockDetails(parent, linkHandler) {
const code = document.createElement("code");
const container = document.createElement("span");
heightPrefix = document.createElement("span");
heightPrefix.style.opacity = "0.5";
heightPrefix.classList.add("dim");
heightPrefix.style.userSelect = "none";
heightNum = document.createElement("span");
container.append(heightPrefix, heightNum);
@@ -170,9 +170,6 @@ function updateTxNavs(page) {
/** @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;
@@ -200,13 +197,8 @@ export function update(block) {
txObserver.observe(txSection);
}
export function show() {
el.hidden = false;
}
export function hide() {
el.hidden = true;
}
export function show() { showPanel(el); }
export function hide() { hidePanel(el); }
/** @param {number} page @param {boolean} [pushUrl] */
async function loadTxPage(page, pushUrl = true) {

View File

@@ -56,11 +56,6 @@ export function initChain(parent, callbacks) {
);
}
/** @param {string} hash */
export function getBlock(hash) {
return blocksByHash.get(hash);
}
/** @param {string} hash */
export function findCube(hash) {
return /** @type {HTMLDivElement | null} */ (
@@ -68,12 +63,13 @@ export function findCube(hash) {
);
}
export function lastCube() {
return /** @type {HTMLDivElement | null} */ (blocksEl.lastElementChild);
export function deselectCube() {
if (selectedCube) selectedCube.classList.remove("selected");
selectedCube = null;
}
/** @param {HTMLDivElement} cube @param {{ scroll?: "smooth" | "instant" }} [opts] */
export function selectCube(cube, { scroll } = {}) {
/** @param {HTMLDivElement} cube @param {{ scroll?: "smooth" | "instant", silent?: boolean }} [opts] */
export function selectCube(cube, { scroll, silent } = {}) {
const changed = cube !== selectedCube;
if (changed) {
if (selectedCube) selectedCube.classList.remove("selected");
@@ -81,10 +77,12 @@ export function selectCube(cube, { scroll } = {}) {
cube.classList.add("selected");
}
if (scroll) cube.scrollIntoView({ behavior: scroll });
const hash = cube.dataset.hash;
if (hash) {
const block = blocksByHash.get(hash);
if (block) onSelect(block);
if (!silent) {
const hash = cube.dataset.hash;
if (hash) {
const block = blocksByHash.get(hash);
if (block) onSelect(block);
}
}
}
@@ -127,7 +125,7 @@ function appendNewerBlocks(blocks) {
return true;
}
/** @param {number | null} [height] */
/** @param {number | null} [height] @returns {Promise<BlockHash>} */
export async function loadInitial(height) {
const blocks =
height != null
@@ -140,6 +138,7 @@ export async function loadInitial(height) {
reachedTip = height == null;
observeOldestEdge();
if (!reachedTip) await loadNewer();
return blocks[0].id;
}
export async function poll() {
@@ -206,14 +205,14 @@ function createBlockCube(block) {
const min = document.createElement("span");
min.innerHTML = formatFeeRate(feeRange[0]);
const dash = document.createElement("span");
dash.style.opacity = "0.5";
dash.classList.add("dim");
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.classList.add("dim");
unit.innerHTML = `sat/vB`;
feesEl.append(unit);

View File

@@ -6,8 +6,8 @@ import {
loadInitial,
poll,
selectCube,
deselectCube,
findCube,
lastCube,
clear as clearChain,
} from "./chain.js";
import {
@@ -16,20 +16,28 @@ import {
show as showBlock,
hide as hideBlock,
} from "./block.js";
import { showTxFromData } from "./tx.js";
import { showAddrDetail } from "./address.js";
import {
initTxDetails,
update as updateTx,
clear as clearTx,
show as showTx,
hide as hideTx,
} from "./tx.js";
import {
initAddrDetails,
update as updateAddr,
show as showAddr,
hide as hideAddr,
} from "./address.js";
/** @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();
@@ -37,14 +45,10 @@ function navigate() {
return navController.signal;
}
function showBlockPanel() {
showBlock();
secondaryPanel.hidden = true;
}
function showSecondaryPanel() {
hideBlock();
secondaryPanel.hidden = false;
function showPanel(/** @type {"block" | "tx" | "addr"} */ which) {
which === "block" ? showBlock() : hideBlock();
which === "tx" ? showTx() : hideTx();
which === "addr" ? showAddr() : hideAddr();
}
/** @param {MouseEvent} e */
@@ -71,9 +75,10 @@ export function init() {
initChain(explorerElement, {
onSelect: (block) => {
updateBlock(block);
showBlockPanel();
showPanel("block");
},
onCubeClick: (cube) => {
navigate();
const hash = cube.dataset.hash;
if (hash) history.pushState(null, "", `/block/${hash}`);
selectCube(cube);
@@ -81,12 +86,8 @@ export function init() {
});
initBlockDetails(explorerElement, handleLinkClick);
secondaryPanel = document.createElement("div");
secondaryPanel.id = "tx-details";
secondaryPanel.hidden = true;
explorerElement.append(secondaryPanel);
secondaryPanel.addEventListener("click", handleLinkClick);
initTxDetails(explorerElement, handleLinkClick);
initAddrDetails(explorerElement, handleLinkClick);
new MutationObserver(() => {
if (explorerElement.hidden) stopPolling();
@@ -105,7 +106,7 @@ export function init() {
if (kind === "block" && value) navigateToBlock(value, false);
else if (kind === "tx" && value) navigateToTx(value);
else if (kind === "address" && value) navigateToAddr(value);
else showBlockPanel();
else showPanel("block");
});
load();
@@ -126,116 +127,97 @@ function stopPolling() {
async function load() {
try {
const height = await resolveStartHeight();
await loadInitial(height);
route();
const [kind, value] = pathSegments();
if (kind === "tx" && value) {
const tx = txCache.get(value) ?? (await brk.getTx(value));
txCache.set(value, tx);
const startHash = await loadInitial(tx.status?.blockHeight ?? null);
const cube = tx.status?.blockHash ? findCube(tx.status.blockHash) : findCube(startHash);
if (cube) selectCube(cube, { silent: true });
updateTx(tx);
showPanel("tx");
return;
}
if (kind === "address" && value) {
const startHash = await loadInitial(null);
const cube = findCube(startHash);
if (cube) selectCube(cube, { silent: true });
navigateToAddr(value);
return;
}
const height =
kind === "block" && value
? /^\d+$/.test(value)
? Number(value)
: (await brk.getBlockV1(value)).height
: null;
const startHash = await loadInitial(height);
const cube = findCube(startHash);
if (cube) selectCube(cube, { scroll: "instant" });
} 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, { scroll: "instant" });
showTxFromData(pendingTx, secondaryPanel);
showSecondaryPanel();
pendingTx = null;
} else if (kind === "address" && value) {
const cube = lastCube();
if (cube) selectCube(cube, { scroll: "instant" });
navigateToAddr(value);
} else {
const cube = lastCube();
if (cube) selectCube(cube, { scroll: "instant" });
}
}
/** @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: "smooth" });
} 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);
}
const existing = findCube(hash);
if (existing) {
navigate();
selectCube(existing, { scroll: "smooth" });
return;
}
const signal = navigate();
try {
clearChain();
const height = /^\d+$/.test(hash)
? Number(hash)
: (await brk.getBlockV1(hash, { signal })).height;
if (signal.aborted) return;
const startHash = await loadInitial(height);
if (signal.aborted) return;
const cube = findCube(hash) ?? findCube(startHash);
if (cube) selectCube(cube);
} 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();
clearTx();
showPanel("tx");
try {
const tx = await brk.getTx(txid, {
signal,
onUpdate: (tx) => {
txCache.set(txid, tx);
if (!signal.aborted) showTxAndSelectBlock(tx);
},
});
const tx = txCache.get(txid) ?? (await brk.getTx(txid, { signal }));
if (signal.aborted) return;
txCache.set(txid, tx);
if (tx.status?.blockHash) {
let cube = findCube(tx.status.blockHash);
if (!cube) {
clearChain();
const startHash = await loadInitial(tx.status.blockHeight ?? null);
if (signal.aborted) return;
cube = findCube(tx.status.blockHash) ?? findCube(startHash);
}
if (cube) selectCube(cube, { scroll: "smooth", silent: true });
}
updateTx(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: "smooth" });
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();
navigate();
deselectCube();
updateAddr(address, navController.signal);
showPanel("addr");
}

View File

@@ -1,5 +1,16 @@
export const TX_PAGE_SIZE = 25;
/** @param {HTMLElement} el */
export function showPanel(el) {
el.hidden = false;
el.scrollTop = 0;
}
/** @param {HTMLElement} el */
export function hidePanel(el) {
el.hidden = true;
}
/** @param {number} sats */
export function formatBtc(sats) {
return (sats / 1e8).toFixed(8);
@@ -33,7 +44,7 @@ export function createHeightElement(height) {
const container = document.createElement("span");
const str = height.toString();
const prefix = document.createElement("span");
prefix.style.opacity = "0.5";
prefix.classList.add("dim");
prefix.style.userSelect = "none";
prefix.textContent = "#" + "0".repeat(7 - str.length);
const num = document.createElement("span");
@@ -62,12 +73,6 @@ export function renderRows(rows, parent) {
}
}
/**
* @param {Transaction} tx
* @param {string} [coinbaseAscii]
*/
const IO_LIMIT = 10;
/**
* @param {TxIn} vin
* @param {string} [coinbaseAscii]
@@ -142,16 +147,16 @@ function renderOutput(vout) {
* @param {(item: T) => HTMLElement} render
* @param {HTMLElement} container
*/
function renderCapped(items, render, container) {
const limit = Math.min(items.length, IO_LIMIT);
function renderCapped(items, render, container, max = 10) {
const limit = Math.min(items.length, max);
for (let i = 0; i < limit; i++) container.append(render(items[i]));
if (items.length > IO_LIMIT) {
if (items.length > max) {
const btn = document.createElement("button");
btn.classList.add("show-more");
btn.textContent = `Show ${items.length - IO_LIMIT} more`;
btn.textContent = `Show ${items.length - max} more`;
btn.addEventListener("click", () => {
btn.remove();
for (let i = IO_LIMIT; i < items.length; i++) container.append(render(items[i]));
for (let i = max; i < items.length; i++) container.append(render(items[i]));
});
container.append(btn);
}

View File

@@ -1,12 +1,34 @@
import { formatBtc, formatFeeRate, renderRows, renderTx } from "./render.js";
import { formatBtc, formatFeeRate, renderRows, renderTx, showPanel, hidePanel } from "./render.js";
/**
* @param {Transaction} tx
* @param {HTMLDivElement} el
*/
export function showTxFromData(tx, el) {
el.hidden = false;
el.scrollTop = 0;
/** @type {HTMLDivElement} */ let el;
/** @param {HTMLElement} parent @param {(e: MouseEvent) => void} linkHandler */
export function initTxDetails(parent, linkHandler) {
el = document.createElement("div");
el.id = "tx-details";
el.hidden = true;
parent.append(el);
el.addEventListener("click", linkHandler);
}
export function show() { showPanel(el); }
export function hide() { hidePanel(el); }
export function clear() {
if (el.children.length) {
el.querySelector(".transactions")?.remove();
for (const v of el.querySelectorAll(".row .value")) {
v.classList.add("dim");
}
} else {
const title = document.createElement("h1");
title.textContent = "Transaction";
el.append(title);
}
}
/** @param {Transaction} tx */
export function update(tx) {
el.innerHTML = "";
const title = document.createElement("h1");