diff --git a/website/scripts/explorer/address.js b/website/scripts/explorer/address.js index 5588289fc..c9e857745 100644 --- a/website/scripts/explorer/address.js +++ b/website/scripts/explorer/address.js @@ -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++; diff --git a/website/scripts/explorer/block.js b/website/scripts/explorer/block.js index 1981d3885..6e7c0f559 100644 --- a/website/scripts/explorer/block.js +++ b/website/scripts/explorer/block.js @@ -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) => 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); diff --git a/website/scripts/explorer/chain.js b/website/scripts/explorer/chain.js index fb6b99289..881220cda 100644 --- a/website/scripts/explorer/chain.js +++ b/website/scripts/explorer/chain.js @@ -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]), diff --git a/website/scripts/explorer/index.js b/website/scripts/explorer/index.js index 108f40b6d..f5dc723d2 100644 --- a/website/scripts/explorer/index.js +++ b/website/scripts/explorer/index.js @@ -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) { diff --git a/website/scripts/explorer/render.js b/website/scripts/explorer/render.js index 98a244e7d..7f0ac5ced 100644 --- a/website/scripts/explorer/render.js +++ b/website/scripts/explorer/render.js @@ -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); } } diff --git a/website/scripts/utils/cache.js b/website/scripts/utils/cache.js index 19be7ae94..743e91ba6 100644 --- a/website/scripts/utils/cache.js +++ b/website/scripts/utils/cache.js @@ -6,27 +6,33 @@ export function createMapCache(maxSize = 100) { /** @type {Map} */ 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} fetcher @returns {Promise} */ + 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>} MapCache */ diff --git a/website/scripts/utils/chart/index.js b/website/scripts/utils/chart/index.js index 5906061f4..00b564f0c 100644 --- a/website/scripts/utils/chart/index.js +++ b/website/scripts/utils/chart/index.js @@ -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; } diff --git a/website/styles/chart.css b/website/styles/chart.css index cdad03395..959792b94 100644 --- a/website/styles/chart.css +++ b/website/styles/chart.css @@ -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; + } } diff --git a/website/styles/panes/explorer.css b/website/styles/panes/explorer.css index 237c3289b..2177ec13b 100644 --- a/website/styles/panes/explorer.css +++ b/website/styles/panes/explorer.css @@ -92,6 +92,7 @@ @container aside (max-width: 767px) { overflow-x: auto; + padding-bottom: 1rem; } @container aside (min-width: 768px) {