website: snap

This commit is contained in:
nym21
2026-04-06 22:30:02 +02:00
parent 02f543af38
commit e91f1386b1
35 changed files with 872 additions and 895 deletions

View File

@@ -1,433 +0,0 @@
// // @ts-nocheck
// import { randomFromArray } from "../utils/array.js";
// import { createButtonElement, createHeader, createSelect } from "../utils/dom.js";
// import { tableElement } from "../utils/elements.js";
// import { serdeSeries, serdeString } from "../utils/serde.js";
// import { resetParams } from "../utils/url.js";
// export function init() {
// tableElement.innerHTML = "wip, will hopefuly be back soon, sorry !";
// // const parent = tableElement;
// // const { headerElement } = createHeader("Table");
// // parent.append(headerElement);
// // const div = window.document.createElement("div");
// // parent.append(div);
// // const table = createTable({
// // signals,
// // brk,
// // resources,
// // option,
// // });
// // div.append(table.element);
// // const span = window.document.createElement("span");
// // span.innerHTML = "Add column";
// // div.append(
// // createButtonElement({
// // onClick: () => {
// // table.addRandomCol?.();
// // },
// // inside: span,
// // title: "Click or tap to add a column to the table",
// // }),
// // );
// }
// // /**
// // * @param {Object} args
// // * @param {Option} args.option
// // * @param {Signals} args.signals
// // * @param {BrkClient} args.brk
// // * @param {Resources} args.resources
// // */
// // function createTable({ brk, signals, option, resources }) {
// // const indexToSeries = createIndexToMetrics(seriesToIndexes);
// // const serializedIndexes = createSerializedIndexes();
// // /** @type {SerializedIndex} */
// // const defaultSerializedIndex = "height";
// // const serializedIndex = /** @type {Signal<SerializedIndex>} */ (
// // signals.createSignal(
// // /** @type {SerializedIndex} */ (defaultSerializedIndex),
// // {
// // save: {
// // ...serdeString,
// // keyPrefix: "table",
// // key: "index",
// // },
// // },
// // )
// // );
// // const index = signals.createMemo(() =>
// // serializedIndexToIndex(serializedIndex()),
// // );
// // const table = window.document.createElement("table");
// // const obj = {
// // element: table,
// // /** @type {VoidFunction | undefined} */
// // addRandomCol: undefined,
// // };
// // signals.createEffect(index, (index, prevIndex) => {
// // if (prevIndex !== undefined) {
// // resetParams(option);
// // }
// // const possibleSeries = indexToSeries[index];
// // const columns = signals.createSignal(/** @type {Metric[]} */ ([]), {
// // equals: false,
// // save: {
// // ...serdeSeries,
// // keyPrefix: `table-${serializedIndex()}`,
// // key: `columns`,
// // },
// // });
// // columns.set((l) => l.filter((id) => possibleSeries.includes(id)));
// // signals.createEffect(columns, (columns) => {
// // console.log(columns);
// // });
// // table.innerHTML = "";
// // const thead = window.document.createElement("thead");
// // table.append(thead);
// // const trHead = window.document.createElement("tr");
// // thead.append(trHead);
// // const tbody = window.document.createElement("tbody");
// // table.append(tbody);
// // const rowElements = signals.createSignal(
// // /** @type {HTMLTableRowElement[]} */ ([]),
// // );
// // /**
// // * @param {Object} args
// // * @param {HTMLSelectElement} args.select
// // * @param {Unit} [args.unit]
// // * @param {(event: MouseEvent) => void} [args.onLeft]
// // * @param {(event: MouseEvent) => void} [args.onRight]
// // * @param {(event: MouseEvent) => void} [args.onRemove]
// // */
// // function addThCol({ select, onLeft, onRight, onRemove, unit: _unit }) {
// // const th = window.document.createElement("th");
// // th.scope = "col";
// // trHead.append(th);
// // const div = window.document.createElement("div");
// // div.append(select);
// // // const top = window.document.createElement("div");
// // // div.append(top);
// // // top.append(select);
// // // top.append(
// // // createAnchorElement({
// // // href: "",
// // // blank: true,
// // // }),
// // // );
// // const bottom = window.document.createElement("div");
// // const unit = window.document.createElement("span");
// // if (_unit) {
// // unit.innerHTML = _unit;
// // }
// // const moveLeft = createButtonElement({
// // inside: "←",
// // title: "Move column to the left",
// // onClick: onLeft || (() => {}),
// // });
// // const moveRight = createButtonElement({
// // inside: "→",
// // title: "Move column to the right",
// // onClick: onRight || (() => {}),
// // });
// // const remove = createButtonElement({
// // inside: "×",
// // title: "Remove column",
// // onClick: onRemove || (() => {}),
// // });
// // bottom.append(unit);
// // bottom.append(moveLeft);
// // bottom.append(moveRight);
// // bottom.append(remove);
// // div.append(bottom);
// // th.append(div);
// // return {
// // element: th,
// // /**
// // * @param {Unit} _unit
// // */
// // setUnit(_unit) {
// // unit.innerHTML = _unit;
// // },
// // };
// // }
// // addThCol({
// // ...createSelect({
// // list: serializedIndexes,
// // signal: serializedIndex,
// // }),
// // unit: "index",
// // });
// // let from = 0;
// // let to = 0;
// // resources
// // .getOrCreate(index, serializedIndex())
// // .fetch()
// // .then((vec) => {
// // if (!vec) return;
// // from = /** @type {number} */ (vec[0]);
// // to = /** @type {number} */ (vec.at(-1)) + 1;
// // const trs = /** @type {HTMLTableRowElement[]} */ ([]);
// // for (let i = vec.length - 1; i >= 0; i--) {
// // const value = vec[i];
// // const tr = window.document.createElement("tr");
// // trs.push(tr);
// // tbody.append(tr);
// // const th = window.document.createElement("th");
// // th.innerHTML = serializeValue({
// // value,
// // unit: "index",
// // });
// // th.scope = "row";
// // tr.append(th);
// // }
// // rowElements.set(() => trs);
// // });
// // const owner = signals.getOwner();
// // /**
// // * @param {Series} s
// // * @param {number} [_colIndex]
// // */
// // function addCol(s, _colIndex = columns().length) {
// // signals.runWithOwner(owner, () => {
// // /** @type {VoidFunction | undefined} */
// // let dispose;
// // signals.createRoot((_dispose) => {
// // dispose = _dispose;
// // const seriesOption = signals.createSignal({
// // name: s,
// // value: s,
// // });
// // const { select } = createSelect({
// // list: possibleSeries.map((s) => ({
// // name: s,
// // value: s,
// // })),
// // signal: seriesOption,
// // });
// // signals.createEffect(seriesOption, (seriesOption) => {
// // select.style.width = `${21 + 7.25 * seriesOption.name.length}px`;
// // });
// // if (_colIndex === columns().length) {
// // columns.set((l) => {
// // l.push(s);
// // return l;
// // });
// // }
// // const colIndex = signals.createSignal(_colIndex);
// // /**
// // * @param {boolean} right
// // * @returns {(event: MouseEvent) => void}
// // */
// // function createMoveColumnFunction(right) {
// // return () => {
// // const oldColIndex = colIndex();
// // const newColIndex = oldColIndex + (right ? 1 : -1);
// // const currentTh = /** @type {HTMLTableCellElement} */ (
// // trHead.childNodes[oldColIndex + 1]
// // );
// // const oterTh = /** @type {HTMLTableCellElement} */ (
// // trHead.childNodes[newColIndex + 1]
// // );
// // if (right) {
// // oterTh.after(currentTh);
// // } else {
// // oterTh.before(currentTh);
// // }
// // columns.set((l) => {
// // [l[oldColIndex], l[newColIndex]] = [
// // l[newColIndex],
// // l[oldColIndex],
// // ];
// // return l;
// // });
// // const rows = rowElements();
// // for (let i = 0; i < rows.length; i++) {
// // const element = rows[i].childNodes[oldColIndex + 1];
// // const sibling = rows[i].childNodes[newColIndex + 1];
// // const temp = element.textContent;
// // element.textContent = sibling.textContent;
// // sibling.textContent = temp;
// // }
// // };
// // }
// // const th = addThCol({
// // select,
// // unit: serdeUnit.deserialize(s),
// // onLeft: createMoveColumnFunction(false),
// // onRight: createMoveColumnFunction(true),
// // onRemove: () => {
// // const ci = colIndex();
// // trHead.childNodes[ci + 1].remove();
// // columns.set((l) => {
// // l.splice(ci, 1);
// // return l;
// // });
// // const rows = rowElements();
// // for (let i = 0; i < rows.length; i++) {
// // rows[i].childNodes[ci + 1].remove();
// // }
// // dispose?.();
// // },
// // });
// // signals.createEffect(columns, () => {
// // colIndex.set(Array.from(trHead.children).indexOf(th.element) - 1);
// // });
// // console.log(colIndex());
// // signals.createEffect(rowElements, (rowElements) => {
// // if (!rowElements.length) return;
// // for (let i = 0; i < rowElements.length; i++) {
// // const td = window.document.createElement("td");
// // rowElements[i].append(td);
// // }
// // signals.createEffect(
// // () => seriesOption().name,
// // (s, prevSeries) => {
// // const unit = serdeUnit.deserialize(s);
// // th.setUnit(unit);
// // const vec = resources.getOrCreate(index, s);
// // vec.fetch({ from, to });
// // const fetchedKey = resources.genFetchedKey({ from, to });
// // columns.set((l) => {
// // const i = l.indexOf(prevSeries ?? s);
// // if (i === -1) {
// // l.push(s);
// // } else {
// // l[i] = s;
// // }
// // return l;
// // });
// // signals.createEffect(
// // () => vec.fetched().get(fetchedKey)?.vec(),
// // (vec) => {
// // if (!vec?.length) return;
// // const thIndex = colIndex() + 1;
// // for (let i = 0; i < rowElements.length; i++) {
// // const iRev = vec.length - 1 - i;
// // const value = vec[iRev];
// // // @ts-ignore
// // rowElements[i].childNodes[thIndex].innerHTML =
// // serializeValue({
// // value,
// // unit,
// // });
// // }
// // },
// // );
// // return () => s;
// // },
// // );
// // });
// // });
// // signals.onCleanup(() => {
// // dispose?.();
// // });
// // });
// // }
// // columns().forEach((s, colIndex) => addCol(s, colIndex));
// // obj.addRandomCol = function () {
// // addCol(randomFromArray(possibleSeries));
// // };
// // return () => index;
// // });
// // return obj;
// // }
// /**
// * @param {SeriesToIndexes} seriesToIndexes
// */
// function createIndexToMetrics(seriesToIndexes) {
// // const indexToSeries = Object.entries(seriesToIndexes).reduce(
// // (arr, [_id, indexes]) => {
// // const id = /** @type {Series} */ (_id);
// // indexes.forEach((i) => {
// // arr[i] ??= [];
// // arr[i].push(id);
// // });
// // return arr;
// // },
// // /** @type {Series[][]} */ (Array.from({ length: 24 })),
// // );
// // indexToSeries.forEach((arr) => {
// // arr.sort();
// // });
// // return indexToSeries;
// }
// /**
// * @param {Object} args
// * @param {number | string | Object | Array<any>} args.value
// * @param {Unit} args.unit
// */
// function serializeValue({ value, unit }) {
// const t = typeof value;
// if (value === null) {
// return "null";
// } else if (typeof value === "string") {
// return value;
// } else if (t !== "number") {
// return JSON.stringify(value).replaceAll('"', "").slice(1, -1);
// } else if (value !== 18446744073709552000) {
// if (unit === "usd" || unit === "difficulty" || unit === "sat/vb") {
// return value.toLocaleString("en-us", {
// minimumFractionDigits: 2,
// maximumFractionDigits: 2,
// });
// } else if (unit === "btc") {
// return value.toLocaleString("en-us", {
// minimumFractionDigits: 8,
// maximumFractionDigits: 8,
// });
// } else {
// return value.toLocaleString("en-us");
// }
// } else {
// return "";
// }
// }

View File

@@ -79,9 +79,7 @@ export function init() {
// Convert to sats if needed
const close =
unit === Unit.sats
? Math.floor(ONE_BTC_IN_SATS / latest)
: latest;
unit === Unit.sats ? Math.floor(ONE_BTC_IN_SATS / latest) : latest;
if ("close" in last) {
// Candlestick data
@@ -117,11 +115,19 @@ const ALL_GROUPS = [
{
label: "Time",
items: [
"10mn", "30mn",
"1h", "4h", "12h",
"1d", "3d", "1w",
"1m", "3m", "6m",
"1y", "10y",
"10mn",
"30mn",
"1h",
"4h",
"12h",
"1d",
"3d",
"1w",
"1m",
"3m",
"6m",
"1y",
"10y",
],
},
{ label: "Block", items: ["blk", "epch", "halv"] },
@@ -149,16 +155,13 @@ function computeChoices(opt) {
.flatMap((blueprint) => blueprint.series.indexes()),
);
const groups = ALL_GROUPS
.map(({ label, items }) => ({
label,
items: items.filter((choice) => rawIndexes.has(INDEX_FROM_LABEL[choice])),
}))
.filter(({ items }) => items.length > 0);
const groups = ALL_GROUPS.map(({ label, items }) => ({
label,
items: items.filter((choice) => rawIndexes.has(INDEX_FROM_LABEL[choice])),
})).filter(({ items }) => items.length > 0);
return {
choices: groups.flatMap((g) => g.items),
groups,
};
}

View File

@@ -1,20 +1,15 @@
import { explorerElement } from "../utils/elements.js";
import { brk } from "../client.js";
/** @type {HTMLDivElement} */
let chain;
const LOOKAHEAD = 15;
const TX_PAGE_SIZE = 25;
/** @type {HTMLDivElement} */
let blocksEl;
/** @type {HTMLDivElement} */
let details;
/** @type {HTMLDivElement} */
let olderSentinel;
/** @type {HTMLDivElement} */
let newerSentinel;
/** @type {HTMLDivElement} */ let chain;
/** @type {HTMLDivElement} */ let blocksEl;
/** @type {HTMLDivElement} */ let details;
/** @type {HTMLDivElement | null} */ let selectedCube = null;
/** @type {number | undefined} */ let pollInterval;
/** @type {IntersectionObserver} */ let olderObserver;
/** @type {Map<BlockHash, BlockInfoV1>} */
const blocksByHash = new Map();
@@ -26,11 +21,67 @@ let loadingOlder = false;
let loadingNewer = false;
let reachedTip = false;
/** @type {HTMLDivElement | null} */
let selectedCube = null;
/** @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 {number | undefined} */
let pollInterval;
/** @typedef {{ first: HTMLButtonElement, prev: HTMLButtonElement, label: HTMLSpanElement, next: HTMLButtonElement, last: HTMLButtonElement }} TxNav */
/** @type {TxNav[]} */ let txNavs = [];
/** @type {BlockInfoV1 | null} */ let txBlock = null;
let txPage = -1;
let txTotalPages = 0;
let txLoading = false;
export function init() {
chain = document.createElement("div");
chain.id = "chain";
explorerElement.append(chain);
blocksEl = document.createElement("div");
blocksEl.classList.add("blocks");
chain.append(blocksEl);
details = document.createElement("div");
details.id = "block-details";
explorerElement.append(details);
initDetails();
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();
});
loadLatest();
}
function startPolling() {
stopPolling();
@@ -45,60 +96,24 @@ function stopPolling() {
}
}
export function init() {
chain = window.document.createElement("div");
chain.id = "chain";
explorerElement.append(chain);
function observeOldestEdge() {
olderObserver.disconnect();
const oldest = blocksEl.firstElementChild;
if (oldest) olderObserver.observe(oldest);
}
newerSentinel = window.document.createElement("div");
newerSentinel.classList.add("sentinel");
chain.append(newerSentinel);
blocksEl = window.document.createElement("div");
blocksEl.classList.add("blocks");
chain.append(blocksEl);
details = window.document.createElement("div");
details.id = "block-details";
explorerElement.append(details);
olderSentinel = window.document.createElement("div");
olderSentinel.classList.add("sentinel");
blocksEl.append(olderSentinel);
function checkSentinels() {
const p = chain.getBoundingClientRect();
const older = olderSentinel.getBoundingClientRect();
if (older.top < p.bottom + 200 && older.bottom > p.top) {
loadOlder();
}
const newer = newerSentinel.getBoundingClientRect();
if (newer.bottom > p.top - 200 && newer.top < p.bottom) {
loadNewer();
/** @param {BlockInfoV1[]} blocks */
function appendNewerBlocks(blocks) {
if (!blocks.length) return false;
for (const b of [...blocks].reverse()) {
if (b.height > newestHeight) {
blocksEl.append(createBlockCube(b));
} else {
blocksByHash.set(b.id, b);
}
}
chain.addEventListener("scroll", checkSentinels, { passive: true });
// Self-contained lifecycle: poll when visible, stop when hidden
new MutationObserver(() => {
if (explorerElement.hidden) {
stopPolling();
} else {
startPolling();
}
}).observe(explorerElement, {
attributes: true,
attributeFilter: ["hidden"],
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden && !explorerElement.hidden) {
loadLatest();
}
});
loadLatest();
newestHeight = Math.max(newestHeight, blocks[0].height);
return true;
}
/** @returns {Promise<number | null>} */
@@ -107,8 +122,7 @@ async function getStartHeight() {
if (path[0] !== "block" || !path[1]) return null;
const value = path[1];
if (/^\d+$/.test(value)) return Number(value);
const block = await brk.getBlockV1(value);
return block.height;
return (await brk.getBlockV1(value)).height;
}
async function loadLatest() {
@@ -122,41 +136,20 @@ async function loadLatest() {
? await brk.getBlocksV1FromHeight(startHeight)
: await brk.getBlocksV1();
// First load: insert all blocks between sentinels
if (newestHeight === -1) {
const cubes = blocks.map((b) => createBlockCube(b));
for (const cube of cubes) {
olderSentinel.after(cube);
}
for (const b of blocks) blocksEl.prepend(createBlockCube(b));
newestHeight = blocks[0].height;
oldestHeight = blocks[blocks.length - 1].height;
if (startHeight === null) reachedTip = true;
selectCube(cubes[0]);
if (!reachedTip) {
newerSentinel.style.minHeight = chain.clientHeight + "px";
requestAnimationFrame(() => {
if (selectedCube) {
selectedCube.scrollIntoView({
behavior: "instant",
block: "center",
});
}
});
}
selectCube(/** @type {HTMLDivElement} */ (blocksEl.lastElementChild));
loadingLatest = false;
observeOldestEdge();
if (!reachedTip) await loadNewer();
return;
} else {
// Subsequent polls: append newer blocks to blocksEl
const newBlocks = blocks.filter((b) => b.height > newestHeight);
if (newBlocks.length) {
newBlocks.sort((a, b) => a.height - b.height);
for (const b of newBlocks) {
blocksEl.append(createBlockCube(b));
}
newestHeight = newBlocks[newBlocks.length - 1].height;
}
reachedTip = true;
}
appendNewerBlocks(blocks);
reachedTip = true;
} catch (e) {
console.error("explorer poll:", e);
}
@@ -168,11 +161,10 @@ async function loadOlder() {
loadingOlder = true;
try {
const blocks = await brk.getBlocksV1FromHeight(oldestHeight - 1);
for (const block of blocks) {
olderSentinel.after(createBlockCube(block));
}
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);
@@ -184,17 +176,18 @@ async function loadNewer() {
if (loadingNewer || newestHeight === -1 || reachedTip) return;
loadingNewer = true;
try {
const blocks = await brk.getBlocksV1FromHeight(newestHeight + 15);
const newer = blocks.filter((b) => b.height > newestHeight);
if (newer.length) {
newer.sort((a, b) => a.height - b.height);
for (const b of newer) {
blocksEl.append(createBlockCube(b));
const anchor = blocksEl.lastElementChild;
const anchorRect = anchor?.getBoundingClientRect();
const blocks = await brk.getBlocksV1FromHeight(newestHeight + LOOKAHEAD);
if (appendNewerBlocks(blocks)) {
if (anchor && anchorRect) {
const r = anchor.getBoundingClientRect();
chain.scrollTop += r.top - anchorRect.top;
chain.scrollLeft += r.left - anchorRect.left;
}
newestHeight = newer[newer.length - 1].height;
} else {
reachedTip = true;
newerSentinel.style.minHeight = "";
}
} catch (e) {
console.error("explorer loadNewer:", e);
@@ -204,106 +197,279 @@ async function loadNewer() {
/** @param {HTMLDivElement} cube */
function selectCube(cube) {
if (selectedCube) {
selectedCube.classList.remove("selected");
}
if (selectedCube) selectedCube.classList.remove("selected");
selectedCube = cube;
if (cube) {
cube.classList.add("selected");
const hash = cube.dataset.hash;
if (hash) {
renderDetails(blocksByHash.get(hash));
}
if (hash) updateDetails(blocksByHash.get(hash));
}
}
/** @typedef {[string, (b: BlockInfoV1) => string | null]} RowDef */
/** @type {RowDef[]} */
const ROW_DEFS = [
["Hash", (b) => b.id],
["Previous Hash", (b) => 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],
];
function initDetails() {
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);
details.append(title);
detailRows = ROW_DEFS.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);
details.append(row);
return { row, valueEl };
});
txSection = document.createElement("div");
txSection.classList.add("transactions");
details.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 && txPage === -1) loadTxPage(0);
});
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(txPage - 1));
next.addEventListener("click", () => loadTxPage(txPage + 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 renderDetails(block) {
details.innerHTML = "";
function updateDetails(block) {
if (!block) return;
details.scrollTop = 0;
const title = window.document.createElement("h1");
title.textContent = "Block ";
const titleCode = window.document.createElement("code");
titleCode.append(createHeightElement(block.height));
title.append(titleCode);
details.append(title);
const str = block.height.toString();
heightPrefix.textContent = "#" + "0".repeat(7 - str.length);
heightNum.textContent = str;
const extras = block.extras;
ROW_DEFS.forEach(([, getter], i) => {
const value = getter(block);
const { row, valueEl } = detailRows[i];
if (value !== null) {
valueEl.textContent = value;
row.hidden = false;
} else {
row.hidden = true;
}
});
/** @type {[string, string][]} */
const rows = [
["Hash", block.id],
["Previous Hash", block.previousblockhash],
["Merkle Root", block.merkleRoot],
["Timestamp", new Date(block.timestamp * 1000).toUTCString()],
["Median Time", new Date(block.mediantime * 1000).toUTCString()],
["Version", `0x${block.version.toString(16)}`],
["Bits", block.bits.toString(16)],
["Nonce", block.nonce.toLocaleString()],
["Difficulty", Number(block.difficulty).toLocaleString()],
["Size", `${(block.size / 1_000_000).toFixed(2)} MB`],
["Weight", `${(block.weight / 1_000_000).toFixed(2)} MWU`],
["Transactions", block.txCount.toLocaleString()],
];
txBlock = block;
txTotalPages = Math.ceil(block.txCount / TX_PAGE_SIZE);
txPage = -1;
updateTxNavs(0);
txList.innerHTML = "";
txObserver.disconnect();
txObserver.observe(txSection);
}
if (extras) {
rows.push(
["Price", `$${extras.price.toLocaleString()}`],
["Pool", extras.pool.name],
["Pool ID", extras.pool.id.toString()],
["Pool Slug", extras.pool.slug],
["Miner Names", extras.pool.minerNames?.join(", ") || "N/A"],
["Reward", `${(extras.reward / 1e8).toFixed(8)} BTC`],
["Total Fees", `${(extras.totalFees / 1e8).toFixed(8)} BTC`],
["Median Fee Rate", `${formatFeeRate(extras.medianFee)} sat/vB`],
["Avg Fee Rate", `${formatFeeRate(extras.avgFeeRate)} sat/vB`],
["Avg Fee", `${extras.avgFee.toLocaleString()} sat`],
["Median Fee", `${extras.medianFeeAmt.toLocaleString()} sat`],
[
"Fee Range",
extras.feeRange.map((f) => formatFeeRate(f)).join(", ") + " sat/vB",
],
[
"Fee Percentiles",
extras.feePercentiles.map((f) => f.toLocaleString()).join(", ") +
" sat",
],
["Avg Tx Size", `${extras.avgTxSize.toLocaleString()} B`],
["Virtual Size", `${extras.virtualSize.toLocaleString()} vB`],
["Inputs", extras.totalInputs.toLocaleString()],
["Outputs", extras.totalOutputs.toLocaleString()],
["Total Input Amount", `${(extras.totalInputAmt / 1e8).toFixed(8)} BTC`],
[
"Total Output Amount",
`${(extras.totalOutputAmt / 1e8).toFixed(8)} BTC`,
],
["UTXO Set Change", extras.utxoSetChange.toLocaleString()],
["UTXO Set Size", extras.utxoSetSize.toLocaleString()],
["SegWit Txs", extras.segwitTotalTxs.toLocaleString()],
["SegWit Size", `${extras.segwitTotalSize.toLocaleString()} B`],
["SegWit Weight", `${extras.segwitTotalWeight.toLocaleString()} WU`],
["Coinbase Address", extras.coinbaseAddress || "N/A"],
["Coinbase Addresses", extras.coinbaseAddresses.join(", ") || "N/A"],
["Coinbase Raw", extras.coinbaseRaw],
["Coinbase Signature", extras.coinbaseSignature],
["Coinbase Signature ASCII", extras.coinbaseSignatureAscii],
["Header", extras.header],
);
/** @param {number} page */
async function loadTxPage(page) {
if (txLoading || !txBlock || page < 0 || page >= txTotalPages) return;
txLoading = true;
txPage = 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("span");
txidEl.classList.add("txid");
txidEl.textContent = 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");
} else {
const a = /** @type {string | undefined} */ (/** @type {any} */ (vin.prevout)?.scriptpubkey_address);
setAddrContent(a || "Unknown", addr);
}
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);
}
for (const [label, value] of rows) {
const row = window.document.createElement("div");
row.classList.add("row");
const labelElement = window.document.createElement("span");
labelElement.classList.add("label");
labelElement.textContent = label;
const valueElement = window.document.createElement("span");
valueElement.classList.add("value");
valueElement.textContent = value;
row.append(labelElement, valueElement);
details.append(row);
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 {
setAddrContent(a || 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 */
@@ -313,17 +479,33 @@ function formatFeeRate(rate) {
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 = window.document.createElement("span");
const container = document.createElement("span");
const str = height.toString();
const spanPrefix = window.document.createElement("span");
spanPrefix.style.opacity = "0.5";
spanPrefix.style.userSelect = "none";
spanPrefix.textContent = "#" + "0".repeat(7 - str.length);
const spanHeight = window.document.createElement("span");
spanHeight.textContent = str;
container.append(spanPrefix, spanHeight);
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;
}
@@ -336,66 +518,56 @@ function createBlockCube(block) {
blocksByHash.set(block.id, block);
cubeElement.addEventListener("click", () => selectCube(cubeElement));
const heightElement = window.document.createElement("p");
heightElement.append(createHeightElement(block.height));
rightFaceElement.append(heightElement);
const heightEl = document.createElement("p");
heightEl.append(createHeightElement(block.height));
rightFaceElement.append(heightEl);
const feesElement = window.document.createElement("div");
feesElement.classList.add("fees");
leftFaceElement.append(feesElement);
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 averageFeeElement = window.document.createElement("p");
feesElement.append(averageFeeElement);
averageFeeElement.innerHTML = `~${formatFeeRate(medianFee)}`;
const feeRangeElement = window.document.createElement("p");
feesElement.append(feeRangeElement);
const minFeeElement = window.document.createElement("span");
minFeeElement.innerHTML = formatFeeRate(feeRange[0]);
feeRangeElement.append(minFeeElement);
const dashElement = window.document.createElement("span");
dashElement.style.opacity = "0.5";
dashElement.innerHTML = `-`;
feeRangeElement.append(dashElement);
const maxFeeElement = window.document.createElement("span");
maxFeeElement.innerHTML = formatFeeRate(feeRange[6]);
feeRangeElement.append(maxFeeElement);
const feeUnitElement = window.document.createElement("p");
feesElement.append(feeUnitElement);
feeUnitElement.style.opacity = "0.5";
feeUnitElement.innerHTML = `sat/vB`;
const 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 spanMiner = window.document.createElement("span");
spanMiner.innerHTML = extras ? extras.pool.name : "Unknown";
topFaceElement.append(spanMiner);
const miner = document.createElement("span");
miner.innerHTML = extras ? extras.pool.name : "Unknown";
topFaceElement.append(miner);
return cubeElement;
}
function createCube() {
const cubeElement = window.document.createElement("div");
const cubeElement = document.createElement("div");
cubeElement.classList.add("cube");
const rightFaceElement = window.document.createElement("div");
rightFaceElement.classList.add("face");
rightFaceElement.classList.add("right");
const rightFaceElement = document.createElement("div");
rightFaceElement.classList.add("face", "right");
cubeElement.append(rightFaceElement);
const leftFaceElement = window.document.createElement("div");
leftFaceElement.classList.add("face");
leftFaceElement.classList.add("left");
const leftFaceElement = document.createElement("div");
leftFaceElement.classList.add("face", "left");
cubeElement.append(leftFaceElement);
const topFaceElement = window.document.createElement("div");
topFaceElement.classList.add("face");
topFaceElement.classList.add("top");
const topFaceElement = document.createElement("div");
topFaceElement.classList.add("face", "top");
cubeElement.append(topFaceElement);
return {
cubeElement,
leftFaceElement,
rightFaceElement,
topFaceElement,
};
return { cubeElement, leftFaceElement, rightFaceElement, topFaceElement };
}