website: fixes

This commit is contained in:
nym21
2026-04-20 18:11:30 +02:00
parent 08175009d2
commit 327873d010
9 changed files with 140 additions and 96 deletions

View File

@@ -1,10 +1,10 @@
import { brk } from "../utils/client.js";
import { createMapCache } from "../utils/cache.js";
import { latestPrice } from "../utils/price.js";
import { formatBtc, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
import { createRow, formatBtc, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
/** @type {HTMLDivElement} */ let el;
/** @type {HTMLSpanElement[]} */ let valueEls;
/** @type {HTMLElement[]} */ let valueEls;
/** @type {HTMLDivElement} */ let txSection;
/** @type {string} */ let currentAddr = "";
@@ -36,14 +36,7 @@ export function initAddrDetails(parent, linkHandler) {
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);
const { row, valueEl } = createRow(label);
el.append(row);
return valueEl;
});
@@ -70,9 +63,9 @@ export async function update(address, signal) {
while (txSection.children.length > 1) txSection.lastChild?.remove();
try {
const cached = statsCache.get(address);
const stats = cached ?? (await brk.getAddress(address, { signal }));
if (!cached) statsCache.set(address, stats);
const stats = await statsCache.fetch(address, () =>
brk.getAddress(address, { signal }),
);
if (signal.aborted || currentAddr !== address) return;
const chain = stats.chainStats;
@@ -97,9 +90,7 @@ export async function update(address, signal) {
pendingUtxos.toLocaleString(),
`${formatBtc(chain.fundedTxoSum)} BTC`,
chain.txCount.toLocaleString(),
(/** @type {any} */ (stats).addrType ?? "unknown")
.replace(/^v\d+_/, "")
.toUpperCase(),
stats.addrType.replace(/^v\d+_/, "").toUpperCase(),
chain.realizedPrice
? `$${Number(chain.realizedPrice).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: "N/A",
@@ -129,10 +120,9 @@ export async function update(address, signal) {
loading = true;
const key = `${address}:${pageIndex}`;
try {
const cached = txCache.get(key);
const txs =
cached ?? (await brk.getAddressTxs(address, afterTxid, { signal }));
if (!cached) txCache.set(key, txs);
const txs = await txCache.fetch(key, () =>
brk.getAddressTxs(address, afterTxid, { signal }),
);
if (currentAddr !== address) return;
for (const tx of txs) txSection.append(renderTx(tx));
pageIndex++;

View File

@@ -1,12 +1,12 @@
import { brk } from "../utils/client.js";
import { createMapCache } from "../utils/cache.js";
import { createPersistedValue } from "../utils/persisted.js";
import { formatFeeRate, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
import { createRow, formatFeeRate, formatHeightPrefix, renderTx, showPanel, hidePanel, 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);
/** @param {(x: BlockInfoV1["extras"]) => string | null} fn @returns {(b: BlockInfoV1) => string | null} */
const ext = (fn) => (b) => fn(b.extras);
/** @type {RowDef[]} */
const ROW_DEFS = [
@@ -33,7 +33,7 @@ const ROW_DEFS = [
["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 Range", ext((x) => x.feeRange.map(formatFeeRate).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`)],
@@ -59,11 +59,11 @@ const ROW_DEFS = [
/** @type {HTMLDivElement} */ let el;
/** @type {HTMLSpanElement} */ let heightPrefix;
/** @type {HTMLSpanElement} */ let heightNum;
/** @type {{ row: HTMLDivElement, valueEl: HTMLSpanElement }[]} */ let detailRows;
/** @type {{ row: HTMLDivElement, valueEl: HTMLElement }[]} */ let detailRows;
/** @type {HTMLDivElement} */ let txList;
/** @type {HTMLDivElement} */ let txSection;
/** @type {IntersectionObserver} */ let txObserver;
/** @type {TxNav[]} */ let txNavs = [];
/** @type {TxNav[]} */ const txNavs = [];
/** @type {BlockInfoV1 | null} */ let txBlock = null;
let txTotalPages = 0;
let txLoading = false;
@@ -99,14 +99,7 @@ export function initBlockDetails(parent, linkHandler) {
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);
const { row, valueEl } = createRow(label, Boolean(linkFn));
el.append(row);
return { row, valueEl };
});
@@ -170,9 +163,8 @@ function updateTxNavs(page) {
/** @param {BlockInfoV1} block */
export function update(block) {
const str = block.height.toString();
heightPrefix.textContent = "#" + "0".repeat(7 - str.length);
heightNum.textContent = str;
heightPrefix.textContent = formatHeightPrefix(block.height);
heightNum.textContent = block.height.toString();
ROW_DEFS.forEach(([, getter, linkFn], i) => {
const value = getter(block);
@@ -202,18 +194,19 @@ export function hide() { hidePanel(el); }
/** @param {number} page @param {boolean} [pushUrl] */
async function loadTxPage(page, pushUrl = true) {
if (txLoading || !txBlock || page < 0 || page >= txTotalPages) return;
const block = txBlock;
if (txLoading || !block || page < 0 || page >= txTotalPages) return;
txLoading = true;
txLoaded = true;
if (pushUrl) txPageParam.setImmediate(page);
updateTxNavs(page);
const key = `${txBlock.id}:${page}`;
const key = `${block.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);
const txs = await txPageCache.fetch(key, () =>
brk.getBlockTxsFromIndex(block.id, page * TX_PAGE_SIZE),
);
txList.innerHTML = "";
const ascii = txBlock.extras?.coinbaseSignatureAscii;
const ascii = block.extras.coinbaseSignatureAscii;
for (const tx of txs) txList.append(renderTx(tx, ascii));
} catch (e) {
console.error("explorer txs:", e);

View File

@@ -38,7 +38,6 @@ export function initChain(parent, callbacks) {
olderObserver = new IntersectionObserver(
(entries) => {
return; // edge fetching disabled for layout debugging
if (entries[0].isIntersecting) loadOlder();
},
{ root: chainEl },
@@ -47,13 +46,8 @@ export function initChain(parent, callbacks) {
chainEl.addEventListener(
"scroll",
() => {
return; // edge fetching disabled for layout debugging
const nearStart =
(chainEl.scrollHeight > chainEl.clientHeight &&
chainEl.scrollTop <= 50) ||
(chainEl.scrollWidth > chainEl.clientWidth &&
chainEl.scrollLeft <= 50);
if (nearStart && !reachedTip && !loadingNewer) loadNewer();
if (reachedTip || loadingNewer) return;
if (chainEl.scrollTop <= 50 && chainEl.scrollLeft <= 50) loadNewer();
},
{ passive: true },
);
@@ -85,7 +79,9 @@ export function selectCube(cube, { scroll, silent } = {}) {
selectedCube = cube;
cube.classList.add("selected");
}
if (scroll) cube.scrollIntoView({ behavior: scroll });
if (scroll) {
cube.scrollIntoView({ behavior: scroll, block: "center", inline: "center" });
}
if (!silent) {
const hash = cube.dataset.hash;
if (hash) {
@@ -218,8 +214,11 @@ async function loadNewer() {
if (loadingNewer || newestHeight === -1 || reachedTip) return;
loadingNewer = true;
try {
const prevNewest = newestHeight;
const blocks = await brk.getBlocksV1FromHeight(newestHeight + LOOKAHEAD);
if (!appendNewerBlocks(blocks)) reachedTip = true;
if (!appendNewerBlocks(blocks) || newestHeight === prevNewest) {
reachedTip = true;
}
} catch (e) {
console.error("explorer loadNewer:", e);
}
@@ -260,8 +259,8 @@ function createBlockCube(block) {
cubeElement.dataset.height = String(block.height);
cubeElement.dataset.timestamp = String(block.timestamp);
const vsize = block.extras?.virtualSize ?? block.weight / 4;
const fill = Math.min(1, vsize / 1_000_000);
const { pool, medianFee, feeRange, virtualSize } = block.extras;
const fill = Math.min(1, virtualSize / 1_000_000);
const { topFace, rightFace, leftFace } = createCube(cubeElement, fill);
blocksByHash.set(block.id, block);
// Intercept plain left-clicks for SPA nav; let modified clicks
@@ -273,10 +272,7 @@ function createBlockCube(block) {
onCubeClick(cubeElement);
});
const extras = block.extras;
const minerName = extras ? extras.pool.name : "Unknown";
const medianFee = extras ? extras.medianFee : 0;
const feeRange = extras ? extras.feeRange : [0, 0, 0, 0, 0, 0, 0];
const minerName = pool.name;
// Top: short date / HH:MM (colon dimmed).
const dateP = document.createElement("p");
@@ -308,7 +304,7 @@ function createBlockCube(block) {
const feesEl = document.createElement("div");
feesEl.classList.add("fees");
const avg = document.createElement("p");
avg.textContent = `~${formatFeeRate(medianFee)}`;
avg.append(span("~", "dim"), formatFeeRate(medianFee));
const range = document.createElement("p");
range.append(
formatFeeRate(feeRange[0]),

View File

@@ -28,11 +28,6 @@ import {
hide as hideAddr,
} from "./address.js";
/** @returns {string[]} */
function pathSegments() {
return window.location.pathname.split("/").filter((v) => v);
}
/** @type {number | undefined} */ let pollInterval;
let navController = new AbortController();
const txCache = createMapCache(50);
@@ -111,7 +106,7 @@ export function init(selected) {
function startPolling() {
stopPolling();
poll();
pollInterval = setInterval(poll, 15_000);
pollInterval = setInterval(poll, 5_000);
}
function stopPolling() {
@@ -124,14 +119,13 @@ function stopPolling() {
async function load() {
const signal = navigate();
try {
const [kind, value] = pathSegments();
const [kind, value] = window.location.pathname.split("/").filter(Boolean);
if (kind === "tx" && value) {
const txid = await resolveTxid(value, { signal });
if (signal.aborted) return;
const tx = txCache.get(txid) ?? (await brk.getTx(txid, { signal }));
const tx = await txCache.fetch(txid, () => brk.getTx(txid, { signal }));
if (signal.aborted) return;
txCache.set(txid, tx);
await goToCube(tx.status?.blockHash ?? tx.status?.blockHeight ?? null, { silent: true });
updateTx(tx);
showPanel("tx");
@@ -155,9 +149,8 @@ async function load() {
/** @param {string} hashOrHeight */
async function navigateToBlock(hashOrHeight) {
const signal = navigate();
navigate();
await goToCube(hashOrHeight);
if (!signal.aborted) showPanel("block");
}
/** @param {Txid | TxIndex} value @param {{ signal?: AbortSignal }} [options] */
@@ -175,9 +168,8 @@ async function navigateToTx(txidOrIndex) {
try {
const txid = await resolveTxid(txidOrIndex, { signal });
if (signal.aborted) return;
const tx = txCache.get(txid) ?? (await brk.getTx(txid, { signal }));
const tx = await txCache.fetch(txid, () => brk.getTx(txid, { signal }));
if (signal.aborted) return;
txCache.set(txid, tx);
await goToCube(tx.status?.blockHash ?? tx.status?.blockHeight ?? null, { silent: true });
updateTx(tx);
} catch (e) {

View File

@@ -18,6 +18,9 @@ export function formatBtc(sats) {
/** @param {number} rate */
export function formatFeeRate(rate) {
if (rate >= 1_000_000) return `${(rate / 1_000_000).toFixed(1)}M`;
if (rate >= 100_000) return `${Math.round(rate / 1_000)}k`;
if (rate >= 1_000) return `${(rate / 1_000).toFixed(1)}k`;
if (rate >= 100) return Math.round(rate).toLocaleString();
if (rate >= 10) return rate.toFixed(1);
return rate.toFixed(2);
@@ -39,6 +42,11 @@ export function setAddrContent(text, el) {
el.append(head, tail);
}
/** @param {number} height */
export function formatHeightPrefix(height) {
return "#" + "0".repeat(Math.max(0, 7 - String(height).length));
}
/** @param {number} height */
export function createHeightElement(height) {
const container = document.createElement("span");
@@ -46,29 +54,39 @@ export function createHeightElement(height) {
const prefix = document.createElement("span");
prefix.classList.add("dim");
prefix.style.userSelect = "none";
prefix.textContent = "#" + "0".repeat(7 - str.length);
prefix.textContent = formatHeightPrefix(height);
const num = document.createElement("span");
num.textContent = str;
container.append(prefix, num);
return container;
}
/**
* @param {string} label
* @param {boolean} [isLink]
* @returns {{ row: HTMLDivElement, valueEl: HTMLElement }}
*/
export function createRow(label, isLink = false) {
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(isLink ? "a" : "span");
valueEl.classList.add("value");
row.append(labelEl, valueEl);
return { row, valueEl };
}
/**
* @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");
const { row, valueEl } = createRow(label, Boolean(href));
valueEl.textContent = value;
if (href) /** @type {HTMLAnchorElement} */ (valueEl).href = href;
row.append(labelEl, valueEl);
parent.append(row);
}
}

View File

@@ -6,27 +6,33 @@ export function createMapCache(maxSize = 100) {
/** @type {Map<string, V>} */
const map = new Map();
/** @param {string} key @param {V} value */
const 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);
};
return {
/** @param {string} key @returns {V | undefined} */
get(key) {
return map.get(key);
},
get: (key) => 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);
has: (key) => map.has(key),
set,
/** @param {string} key @param {() => Promise<V>} fetcher @returns {Promise<V>} */
async fetch(key, fetcher) {
const hit = map.get(key);
if (hit !== undefined) return hit;
const value = await fetcher();
set(key, value);
return value;
},
};
}
/**
* @template V
* @typedef {{ get: (key: string) => V | undefined, has: (key: string) => boolean, set: (key: string, value: V) => void }} MapCache
* @typedef {ReturnType<typeof createMapCache<V>>} MapCache
*/

View File

@@ -74,6 +74,8 @@ const lineWidth = /** @type {1} */ (/** @type {unknown} */ (1.5));
const MAX_SIZE = 10_000;
let hintShown = false;
/** @typedef {{ label: string, index: IndexLabel, from: number }} RangePreset */
/** @returns {RangePreset[]} */
@@ -1677,6 +1679,20 @@ export function createChart({ parent, brk, fitContent }) {
});
chartEl.append(captureButton);
if (!hintShown) {
hintShown = true;
const hint = document.createElement("div");
hint.className = "chart-hint";
hint.textContent = matchMedia("(pointer: coarse)").matches
? "pinch to zoom · swipe to pan"
: "scroll to zoom · drag to pan";
root.append(hint);
const dismiss = () => hint.classList.add("done");
chartEl.addEventListener("wheel", dismiss, { once: true, passive: true });
chartEl.addEventListener("pointerdown", dismiss, { once: true });
}
return chart;
}

View File

@@ -279,4 +279,36 @@
color: var(--off-color);
}
}
> div.chart-hint {
position: absolute;
bottom: 4rem;
left: 50%;
transform: translateX(-50%);
height: auto;
margin: 0;
z-index: 40;
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
color: var(--off-color);
pointer-events: none;
white-space: nowrap;
opacity: 0;
animation: chart-hint 4s 0.2s ease-in-out both;
&.done {
animation: none;
opacity: 0;
transition: opacity 250ms ease;
}
}
}
@keyframes chart-hint {
0%, 100% {
opacity: 0;
}
15%, 85% {
opacity: 0.85;
}
}

View File

@@ -92,6 +92,7 @@
@container aside (max-width: 767px) {
overflow-x: auto;
padding-bottom: 1rem;
}
@container aside (min-width: 768px) {