From e279116bff9b649bd986c5c69aee9c94bc8a33ae Mon Sep 17 00:00:00 2001 From: nym21 Date: Thu, 9 Jul 2026 23:08:47 +0200 Subject: [PATCH] website_next: part 11 --- website_next/explore/block/fee-rates.js | 4 +- .../explore/block/preview/filters/index.js | 14 +- .../explore/block/preview/filters/model.js | 7 + .../explore/block/preview/heatmap/capacity.js | 24 ++- .../explore/block/preview/heatmap/draw.js | 89 +++++++++ .../explore/block/preview/heatmap/fees.js | 11 +- .../explore/block/preview/heatmap/geometry.js | 99 ++++++++++ .../explore/block/preview/heatmap/index.js | 169 +++------------- website_next/explore/block/preview/index.js | 5 +- .../explore/block/preview/inspector.js | 180 +++++++----------- .../block/preview/inspector/position.js | 33 ++++ .../explore/block/preview/inspector/txid.js | 74 +++++++ 12 files changed, 430 insertions(+), 279 deletions(-) create mode 100644 website_next/explore/block/preview/heatmap/draw.js create mode 100644 website_next/explore/block/preview/heatmap/geometry.js create mode 100644 website_next/explore/block/preview/inspector/position.js create mode 100644 website_next/explore/block/preview/inspector/txid.js diff --git a/website_next/explore/block/fee-rates.js b/website_next/explore/block/fee-rates.js index 396fbbf16..a98307b17 100644 --- a/website_next/explore/block/fee-rates.js +++ b/website_next/explore/block/fee-rates.js @@ -28,10 +28,10 @@ function percentileValue(values, percentile) { /** @param {number[]} feeRates */ export function createFeeRateRange(feeRates) { - const values = feeRates.toSorted((a, b) => a - b); + feeRates.sort((a, b) => a - b); return FEE_RATE_PERCENTILES.map((percentile) => { - return percentileValue(values, percentile); + return percentileValue(feeRates, percentile); }); } diff --git a/website_next/explore/block/preview/filters/index.js b/website_next/explore/block/preview/filters/index.js index 3a281f9ee..4105d41aa 100644 --- a/website_next/explore/block/preview/filters/index.js +++ b/website_next/explore/block/preview/filters/index.js @@ -1,6 +1,6 @@ import { createLegendItem } from "../../../../legend/index.js"; import { formatNumber } from "../../format.js"; -import { FILTER_GROUP_LABELS, FILTER_GROUPS, FILTERS } from "./model.js"; +import { FILTER_GROUP_FILTERS, FILTER_GROUP_LABELS, FILTERS } from "./model.js"; const SUMMARY_LABEL_COUNT = 3; const CAN_HOVER = matchMedia("(hover: hover) and (pointer: fine)"); @@ -130,10 +130,10 @@ export function createPreviewFilters(loadFilters, heatmap) { function renderFilters(filterState) { clearGroups(panel, summary); - for (const { key, label } of FILTER_GROUPS) { + for (const { label, filters } of FILTER_GROUP_FILTERS) { const group = createFilterGroup(label); - for (const filter of FILTERS.filter((item) => item.group === key)) { + for (const filter of filters) { const { button, value } = createLegendItem({ ariaLabel: filter.label, color: filter.color, @@ -222,4 +222,10 @@ export function createPreviewFilters(loadFilters, heatmap) { } /** @typedef {import("../data.js").BlockPreviewFilterState} BlockPreviewFilterState */ -/** @typedef {ReturnType} BlockPreviewHeatmap */ + +/** + * @typedef {Object} BlockPreviewHeatmap + * @property {(mask: number | null) => void} setPreviewMask + * @property {(mask: number) => void} setDisabledMask + * @property {(state: BlockPreviewFilterState) => void} setFilterState + */ diff --git a/website_next/explore/block/preview/filters/model.js b/website_next/explore/block/preview/filters/model.js index 5287eec9d..3ae1474a9 100644 --- a/website_next/explore/block/preview/filters/model.js +++ b/website_next/explore/block/preview/filters/model.js @@ -35,6 +35,13 @@ export const FILTERS = FILTER_DEFS.map(([group, label, key, color], index) => { return /** @type {const} */ ({ bit: 1 << index, color, group, index, key, label }); }); +export const FILTER_GROUP_FILTERS = FILTER_GROUPS.map((group) => { + return /** @type {const} */ ({ + ...group, + filters: FILTERS.filter((filter) => filter.group === group.key), + }); +}); + export const FILTER_GROUP_LABELS = new Map(FILTER_GROUPS.map(({ key, label }) => { return [key, label]; })); diff --git a/website_next/explore/block/preview/heatmap/capacity.js b/website_next/explore/block/preview/heatmap/capacity.js index fdaee9c4d..69f3141b1 100644 --- a/website_next/explore/block/preview/heatmap/capacity.js +++ b/website_next/explore/block/preview/heatmap/capacity.js @@ -31,26 +31,24 @@ function resolveCapacityCells(cells, capacity, columns) { * @param {number} columns */ function fitCapacityCells(cells, capacity, columns) { - let resolvedCells = resolveCapacityCells(cells, capacity, columns); + let resolvedCells = resolveCapacityCells(cells, capacity, columns).slice( + 0, + columns * columns, + ); let layouts = packCells(resolvedCells, columns, columns); while (layouts === null) { - const largest = Math.max(...resolvedCells.map(({ span }) => span)); + let largest = 0; + + for (const { span } of resolvedCells) largest = Math.max(largest, span); if (largest <= 1) break; - resolvedCells = resolvedCells.map((cell) => ({ - ...cell, - span: cell.span === largest ? largest - 1 : cell.span, - })); - layouts = packCells(resolvedCells, columns, columns); - } + for (const cell of resolvedCells) { + if (cell.span === largest) cell.span -= 1; + } - if (layouts === null) { - resolvedCells = resolvedCells.slice(0, columns * columns); - layouts = /** @type {NonNullable} */ ( - packCells(resolvedCells, columns, columns) - ); + layouts = packCells(resolvedCells, columns, columns); } return { diff --git a/website_next/explore/block/preview/heatmap/draw.js b/website_next/explore/block/preview/heatmap/draw.js new file mode 100644 index 000000000..4086fa9ca --- /dev/null +++ b/website_next/explore/block/preview/heatmap/draw.js @@ -0,0 +1,89 @@ +import { getCanvasColor } from "./color.js"; + +const HOVER_FILL_ALPHA = 0.18; +const HOVER_MARKER_MIN_SIZE = 10; +const MUTED_ALPHA = 0.12; + +const CANVAS_COLORS = { + black: getCanvasColor("var(--black)"), + white: getCanvasColor("var(--white)"), +}; + +/** + * @param {CanvasRenderingContext2D} context + * @param {number} alpha + * @param {string} color + * @param {PreviewRect} rect + */ +function drawRect(context, alpha, color, rect) { + context.globalAlpha = alpha; + context.fillStyle = color; + context.fillRect(rect.x, rect.y, rect.size, rect.size); +} + +/** + * @param {CanvasRenderingContext2D} context + * @param {PreviewRect} rect + */ +function drawHover(context, rect) { + const size = Math.max(rect.size, HOVER_MARKER_MIN_SIZE); + const x = rect.x + rect.size / 2 - size / 2; + const y = rect.y + rect.size / 2 - size / 2; + const width = Math.max(1, Math.min(3, size / 5)); + + context.fillStyle = CANVAS_COLORS.white; + context.globalAlpha = HOVER_FILL_ALPHA; + context.fillRect(x, y, size, size); + context.globalAlpha = 1; + context.lineJoin = "miter"; + context.lineWidth = width + 2; + context.strokeStyle = CANVAS_COLORS.black; + context.strokeRect(x, y, size, size); + context.lineWidth = width; + context.strokeStyle = CANVAS_COLORS.white; + context.strokeRect(x, y, size, size); +} + +/** + * @param {DrawPreviewArgs} args + */ +export function drawPreview(args) { + const { context, disabledMask, filterState, inspected, previewMask, rects } = args; + let inspectedRect = /** @type {PreviewRect | null} */ (null); + + if (filterState === null || (disabledMask === 0 && previewMask === null)) { + for (const rect of rects) { + drawRect(context, 1, rect.color, rect); + if (rect.transaction === inspected) inspectedRect = rect; + } + } else { + const activeMask = previewMask ?? disabledMask; + + for (const rect of rects) { + const mask = filterState.masks[rect.transaction.txIndex - filterState.start]; + const alpha = previewMask === null + ? (mask & activeMask ? MUTED_ALPHA : 1) + : (mask & activeMask ? 1 : MUTED_ALPHA); + + drawRect(context, alpha, rect.color, rect); + if (rect.transaction === inspected) inspectedRect = rect; + } + } + + if (inspectedRect !== null) drawHover(context, inspectedRect); + context.globalAlpha = 1; +} + +/** + * @typedef {Object} DrawPreviewArgs + * @property {CanvasRenderingContext2D} context + * @property {number} disabledMask + * @property {BlockPreviewFilterState | null} filterState + * @property {BlockPreviewTransaction | null} inspected + * @property {number | null} previewMask + * @property {PreviewRect[]} rects + */ + +/** @typedef {import("../data.js").BlockPreviewFilterState} BlockPreviewFilterState */ +/** @typedef {import("../data.js").BlockPreviewTransaction} BlockPreviewTransaction */ +/** @typedef {import("./geometry.js").PreviewRect} PreviewRect */ diff --git a/website_next/explore/block/preview/heatmap/fees.js b/website_next/explore/block/preview/heatmap/fees.js index 2fa7d65ba..790e630a6 100644 --- a/website_next/explore/block/preview/heatmap/fees.js +++ b/website_next/explore/block/preview/heatmap/fees.js @@ -4,15 +4,20 @@ import { createFeeRateRange } from "../../fee-rates.js"; * @param {BlockPreviewTransaction[]} transactions */ export function createPreviewFeeRange(transactions) { - return createFeeRateRange(transactions.map(({ feeRate }) => feeRate)); + const feeRates = new Array(transactions.length); + + for (let index = 0; index < transactions.length; index += 1) { + feeRates[index] = transactions[index].feeRate; + } + + return createFeeRateRange(feeRates); } /** * @param {BlockPreviewTransaction[]} transactions */ export function orderTransactions(transactions) { - return transactions - .toSorted((a, b) => b.feeRate - a.feeRate || b.weight - a.weight); + return transactions.sort((a, b) => b.feeRate - a.feeRate || b.weight - a.weight); } /** @typedef {import("../data.js").BlockPreviewTransaction} BlockPreviewTransaction */ diff --git a/website_next/explore/block/preview/heatmap/geometry.js b/website_next/explore/block/preview/heatmap/geometry.js new file mode 100644 index 000000000..12ab38a8f --- /dev/null +++ b/website_next/explore/block/preview/heatmap/geometry.js @@ -0,0 +1,99 @@ +const GAP_REFERENCE_WIDTH = 640; + +/** + * @param {number} count + * @param {number} cell + * @param {number} gap + */ +function unitsToPixels(count, cell, gap) { + return count * cell + Math.max(0, count - 1) * gap; +} + +/** + * @param {HTMLCanvasElement} canvas + * @param {number} width + */ +function getGap(canvas, width) { + const maxGap = Number.parseFloat( + getComputedStyle(canvas).getPropertyValue("--block-preview-heatmap-gap"), + ) || 0; + + return Math.max(1, maxGap * Math.min(1, width / GAP_REFERENCE_WIDTH)); +} + +/** + * @param {SquareLayout} square + * @param {HTMLCanvasElement} canvas + * @param {number} width + */ +export function createPreviewRects(square, canvas, width) { + const gap = getGap(canvas, width); + const cell = Math.max(1, (width - gap * (square.columns - 1)) / square.columns); + const unit = cell + gap; + + return square.layouts.map((layout, index) => { + const transaction = square.resolvedCells[index].transaction; + const rectSize = unitsToPixels(layout.span, cell, gap); + + return { + color: square.resolvedCells[index].color, + size: rectSize, + transaction, + x: layout.x * unit, + y: width - layout.y * unit - rectSize, + }; + }); +} + +/** + * @param {PreviewRect[]} rects + * @param {number} x + * @param {number} y + */ +export function hitTest(rects, x, y) { + for (let index = rects.length - 1; index >= 0; index -= 1) { + const rect = rects[index]; + + if ( + x >= rect.x && + x <= rect.x + rect.size && + y >= rect.y && + y <= rect.y + rect.size + ) { + return rect.transaction; + } + } + + return null; +} + +/** + * @typedef {Object} PreviewRect + * @property {string} color + * @property {number} size + * @property {BlockPreviewTransaction} transaction + * @property {number} x + * @property {number} y + */ + +/** + * @typedef {Object} SquareLayout + * @property {number} columns + * @property {SquareCell[]} resolvedCells + * @property {SquareCellLayout[]} layouts + */ + +/** + * @typedef {Object} SquareCell + * @property {string} color + * @property {BlockPreviewTransaction} transaction + */ + +/** + * @typedef {Object} SquareCellLayout + * @property {number} span + * @property {number} x + * @property {number} y + */ + +/** @typedef {import("../data.js").BlockPreviewTransaction} BlockPreviewTransaction */ diff --git a/website_next/explore/block/preview/heatmap/index.js b/website_next/explore/block/preview/heatmap/index.js index 045cf8dd9..517b6c644 100644 --- a/website_next/explore/block/preview/heatmap/index.js +++ b/website_next/explore/block/preview/heatmap/index.js @@ -1,101 +1,12 @@ import { MAX_BLOCK_WEIGHT } from "../../format.js"; import { createPreviewFeeRange, orderTransactions } from "./fees.js"; import { createSquareLayout } from "./capacity.js"; -import { getCanvasColor, getCanvasFeeRateColor } from "./color.js"; +import { getCanvasFeeRateColor } from "./color.js"; +import { createPreviewRects, hitTest } from "./geometry.js"; +import { drawPreview } from "./draw.js"; const COLUMNS = 84; -const MUTED_ALPHA = 0.12; -const GAP_REFERENCE_WIDTH = 640; -const HOVER_FILL_ALPHA = 0.18; -const HOVER_MARKER_MIN_SIZE = 10; - -/** - * @param {number} count - * @param {number} cell - * @param {number} gap - */ -function unitsToPixels(count, cell, gap) { - return count * cell + Math.max(0, count - 1) * gap; -} - -/** - * @param {HTMLCanvasElement} canvas - * @param {number} width - */ -function getGap(canvas, width) { - const maxGap = Number.parseFloat( - getComputedStyle(canvas).getPropertyValue("--block-preview-heatmap-gap"), - ) || 0; - - return Math.max(1, maxGap * Math.min(1, width / GAP_REFERENCE_WIDTH)); -} - -/** - * @param {BlockPreviewTransaction} transaction - * @param {BlockPreviewFilterState | null} filterState - */ -function getTransactionMask(transaction, filterState) { - return filterState?.masks[transaction.txIndex - filterState.start] ?? 0; -} - -/** - * @param {CanvasRenderingContext2D} context - * @param {number} alpha - * @param {string} color - * @param {PreviewRect} rect - */ -function drawRect(context, alpha, color, rect) { - context.globalAlpha = alpha; - context.fillStyle = color; - context.fillRect(rect.x, rect.y, rect.size, rect.size); -} - -/** - * @param {CanvasRenderingContext2D} context - * @param {PreviewRect} rect - * @param {CanvasColors} colors - */ -function drawHover(context, rect, colors) { - const size = Math.max(rect.size, HOVER_MARKER_MIN_SIZE); - const x = rect.x + rect.size / 2 - size / 2; - const y = rect.y + rect.size / 2 - size / 2; - const width = Math.max(1, Math.min(3, size / 5)); - - context.globalAlpha = 1; - context.fillStyle = colors.white; - context.globalAlpha = HOVER_FILL_ALPHA; - context.fillRect(x, y, size, size); - context.globalAlpha = 1; - context.lineJoin = "miter"; - context.lineWidth = width + 2; - context.strokeStyle = colors.black; - context.strokeRect(x, y, size, size); - context.lineWidth = width; - context.strokeStyle = colors.white; - context.strokeRect(x, y, size, size); -} - -/** - * @param {PreviewRect[]} rects - * @param {number} x - * @param {number} y - */ -function hitTest(rects, x, y) { - for (let index = rects.length - 1; index >= 0; index -= 1) { - const rect = rects[index]; - - if ( - x >= rect.x && - x <= rect.x + rect.size && - y >= rect.y && - y <= rect.y + rect.size - ) { - return rect.transaction; - } - } - - return null; -} +const VISIBLE_CELLS = COLUMNS * COLUMNS; /** * @param {BlockPreviewTransaction[]} transactions @@ -109,22 +20,20 @@ export function createBlockPreviewHeatmap(transactions, options = {}) { ); const ordered = orderTransactions(transactions); const ranges = createPreviewFeeRange(ordered); - const cells = ordered.map((transaction) => ({ + const visible = ordered.slice(0, VISIBLE_CELLS); + const cells = visible.map((transaction) => ({ color: getCanvasFeeRateColor(transaction.feeRate, ranges), transaction, weight: transaction.weight, })); const square = createSquareLayout(cells, MAX_BLOCK_WEIGHT, COLUMNS); - const colors = { - black: getCanvasColor("var(--black)"), - white: getCanvasColor("var(--white)"), - }; let disabledMask = 0; let filterState = /** @type {BlockPreviewFilterState | null} */ (null); let frame = 0; let inspected = /** @type {BlockPreviewTransaction | null} */ (null); let previewMask = /** @type {number | null} */ (null); let rects = /** @type {PreviewRect[]} */ ([]); + let rectWidth = 0; let capturedPointer = /** @type {number | null} */ (null); canvas.dataset.blockPreviewHeatmap = ""; @@ -142,40 +51,21 @@ export function createBlockPreviewHeatmap(transactions, options = {}) { canvas.height = size; } - const gap = getGap(canvas, width); - const cell = Math.max(1, (width - gap * (square.columns - 1)) / square.columns); - const unit = cell + gap; - const activeMask = previewMask ?? disabledMask; - let inspectedRect = /** @type {PreviewRect | null} */ (null); - - rects = square.layouts.map((layout, index) => { - const transaction = square.resolvedCells[index].transaction; - const rectSize = unitsToPixels(layout.span, cell, gap); - - return { - color: square.resolvedCells[index].color, - size: rectSize, - transaction, - x: layout.x * unit, - y: width - layout.y * unit - rectSize, - }; - }); + if (rectWidth !== width) { + rectWidth = width; + rects = createPreviewRects(square, canvas, width); + } context.setTransform(dpr, 0, 0, dpr, 0, 0); context.clearRect(0, 0, width, width); - - for (const rect of rects) { - const mask = getTransactionMask(rect.transaction, filterState); - const alpha = previewMask === null - ? (mask & activeMask ? MUTED_ALPHA : 1) - : (mask & activeMask ? 1 : MUTED_ALPHA); - - drawRect(context, alpha, rect.color, rect); - if (rect.transaction === inspected) inspectedRect = rect; - } - - if (inspectedRect !== null) drawHover(context, inspectedRect, colors); - context.globalAlpha = 1; + drawPreview({ + context, + disabledMask, + filterState, + inspected, + previewMask, + rects, + }); } function scheduleDraw() { @@ -289,11 +179,15 @@ export function createBlockPreviewHeatmap(transactions, options = {}) { }, /** @param {number | null} mask */ setPreviewMask(mask) { + if (previewMask === mask) return; + previewMask = mask; scheduleDraw(); }, /** @param {number} mask */ setDisabledMask(mask) { + if (disabledMask === mask) return; + disabledMask = mask; scheduleDraw(); }, @@ -308,23 +202,10 @@ export function createBlockPreviewHeatmap(transactions, options = {}) { /** @typedef {import("../data.js").BlockPreviewTransaction} BlockPreviewTransaction */ /** @typedef {import("../data.js").BlockPreviewFilterState} BlockPreviewFilterState */ -/** - * @typedef {Object} PreviewRect - * @property {string} color - * @property {number} size - * @property {BlockPreviewTransaction} transaction - * @property {number} x - * @property {number} y - */ - -/** - * @typedef {Object} CanvasColors - * @property {string} black - * @property {string} white - */ - /** * @typedef {Object} BlockPreviewPointer * @property {number} clientX * @property {number} clientY */ + +/** @typedef {import("./geometry.js").PreviewRect} PreviewRect */ diff --git a/website_next/explore/block/preview/index.js b/website_next/explore/block/preview/index.js index dc05d63e3..59fb975b4 100644 --- a/website_next/explore/block/preview/index.js +++ b/website_next/explore/block/preview/index.js @@ -37,7 +37,10 @@ function memoize(load) { let promise = /** @type {Promise | null} */ (null); return () => { - promise ??= load(); + promise ??= load().catch((error) => { + promise = null; + throw error; + }); return promise; }; diff --git a/website_next/explore/block/preview/inspector.js b/website_next/explore/block/preview/inspector.js index 8fb661ae0..48700f750 100644 --- a/website_next/explore/block/preview/inspector.js +++ b/website_next/explore/block/preview/inspector.js @@ -1,11 +1,10 @@ import { formatFeeRate } from "../../../utils/fee-rate.js"; import { formatNumber, formatWeight } from "../format.js"; -import { loadBlockPreviewTxid } from "./data.js"; -import { FILTER_GROUPS, FILTERS } from "./filters/model.js"; +import { FILTER_GROUP_FILTERS } from "./filters/model.js"; +import { placeReadout } from "./inspector/position.js"; +import { createTxidLoader } from "./inspector/txid.js"; -const TXID_CACHE_LIMIT = 64; -const TXID_DWELL_MS = 120; -const OVERLAY_MARGIN = 8; +const TRAITS_DWELL_MS = 180; /** * @param {HTMLElement} parent @@ -35,46 +34,6 @@ function setField(element, value, loading = false) { else element.removeAttribute("aria-busy"); } -/** - * @param {HTMLElement} element - * @param {BlockPreviewPointer} point - */ -function placeReadout(element, point) { - const figure = /** @type {HTMLElement} */ (element.parentElement); - const bounds = figure.getBoundingClientRect(); - const width = element.offsetWidth; - const height = element.offsetHeight; - const minX = Math.min(bounds.width / 2, width / 2 + OVERLAY_MARGIN); - const maxX = Math.max(minX, bounds.width - minX); - const rawX = point.clientX - bounds.left; - const rawY = point.clientY - bounds.top; - const x = Math.min(maxX, Math.max(minX, rawX)); - const showBelow = rawY < height + OVERLAY_MARGIN * 2; - const minY = showBelow ? OVERLAY_MARGIN : height + OVERLAY_MARGIN; - const maxY = showBelow - ? Math.max(minY, bounds.height - height - OVERLAY_MARGIN) - : Math.max(minY, bounds.height - OVERLAY_MARGIN); - const y = Math.min(maxY, Math.max(minY, rawY)); - - element.style.setProperty("--tx-x", `${x}px`); - element.style.setProperty("--tx-y", `${y}px`); - element.toggleAttribute("data-below", showBelow); -} - -/** - * @param {number} txIndex - * @param {string} txid - * @param {Map} cache - */ -function rememberTxid(txIndex, txid, cache) { - if (cache.has(txIndex)) cache.delete(txIndex); - else if (cache.size >= TXID_CACHE_LIMIT) { - cache.delete(/** @type {number} */ (cache.keys().next().value)); - } - - cache.set(txIndex, txid); -} - /** * @param {AbortSignal} parentSignal * @param {() => Promise} loadFilters @@ -85,51 +44,22 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { const tx = appendField(element, "tx"); const fee = appendField(element, "fee"); const weight = appendField(element, "weight"); - const traitFields = FILTER_GROUPS.map(({ key, label }) => { - return /** @type {const} */ ({ key, value: appendField(element, label) }); + const traitFields = FILTER_GROUP_FILTERS.map(({ filters, label }) => { + return /** @type {const} */ ({ filters, value: appendField(element, label) }); }); - const cache = /** @type {Map} */ (new Map()); - let controller = /** @type {AbortController | null} */ (null); + const txidLoader = createTxidLoader(parentSignal); let inspected = /** @type {BlockPreviewTransaction | null} */ (null); let point = /** @type {BlockPreviewPointer | null} */ (null); - let timer = 0; - let version = 0; + let filterState = /** @type {BlockPreviewFilterState | null} */ (null); + let filterPromise = /** @type {Promise | null} */ (null); + let traitsTimer = 0; element.dataset.blockPreviewTransaction = ""; element.hidden = true; function abortPending() { - clearTimeout(timer); - controller?.abort(); - controller = null; - } - - /** - * @param {BlockPreviewTransaction} transaction - * @param {boolean} eager - */ - function fetchTxid(transaction, eager) { - const current = version; - const txidController = new AbortController(); - - controller = txidController; - timer = setTimeout(() => { - const signal = AbortSignal.any([parentSignal, txidController.signal]); - - void loadBlockPreviewTxid(transaction.txIndex, signal) - .then((loadedTxid) => { - if (current !== version || inspected !== transaction) return; - - rememberTxid(transaction.txIndex, loadedTxid, cache); - setField(txid, loadedTxid); - txid.title = loadedTxid; - }) - .catch((error) => { - if (current !== version || signal.aborted) return; - - console.error(error); - }); - }, eager ? 0 : TXID_DWELL_MS); + clearTimeout(traitsTimer); + txidLoader.abort(); } function setTraitsLoading() { @@ -141,9 +71,9 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { /** @param {number} mask */ function setTraits(mask) { - for (const { key, value } of traitFields) { - const labels = FILTERS - .filter(({ bit, group }) => group === key && mask & bit) + for (const { filters, value } of traitFields) { + const labels = filters + .filter(({ bit }) => mask & bit) .map(({ label }) => label); const text = labels.join(" ยท ") || "none"; @@ -152,30 +82,49 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { } } - /** - * @param {BlockPreviewTransaction} transaction - */ - function loadTraits(transaction) { - const current = version; + /** @param {BlockPreviewTransaction} transaction */ + function setTransactionTraits(transaction) { + const state = /** @type {BlockPreviewFilterState} */ (filterState); + const mask = state.masks[transaction.txIndex - state.start]; - setTraitsLoading(); - void loadFilters() + setTraits(mask); + if (point !== null) placeReadout(element, point); + } + + function loadFilterState() { + if (filterState !== null || filterPromise !== null) return; + + filterPromise = loadFilters() .then((state) => { - if (current !== version || inspected !== transaction) return; - - const mask = state.masks[transaction.txIndex - state.start]; - - setTraits(mask); - if (point !== null) placeReadout(element, point); + filterState = state; + filterPromise = null; + if (inspected !== null) setTransactionTraits(inspected); }) .catch((error) => { - if (current !== version || parentSignal.aborted) return; + filterPromise = null; + if (parentSignal.aborted || inspected === null) return; for (const { value } of traitFields) setField(value, "unavailable"); console.error(error); }); } + /** + * @param {BlockPreviewTransaction} transaction + * @param {boolean} eager + */ + function loadTraits(transaction, eager) { + setTraitsLoading(); + if (filterState !== null) { + setTransactionTraits(transaction); + return; + } + + traitsTimer = setTimeout(() => { + loadFilterState(); + }, eager ? 0 : TRAITS_DWELL_MS); + } + /** * @param {BlockPreviewTransaction | null} transaction * @param {BlockPreviewPointer | null} nextPoint @@ -183,7 +132,6 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { */ function inspect(transaction, nextPoint, eager) { if (transaction === null || nextPoint === null) { - version += 1; abortPending(); inspected = null; point = null; @@ -197,26 +145,34 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { if (transaction === inspected) return; - version += 1; abortPending(); inspected = transaction; - const cachedTxid = cache.get(transaction.txIndex); - setField(tx, `#${formatNumber(transaction.txIndex)}`); setField(fee, `${formatFeeRate(transaction.feeRate)} sat/vB`); setField(weight, formatWeight(transaction.weight)); - loadTraits(transaction); + loadTraits(transaction, eager); - if (cachedTxid !== undefined) { - setField(txid, cachedTxid); - txid.title = cachedTxid; - return; + const cached = txidLoader.load( + transaction, + eager, + (loadedTxid) => { + if (inspected !== transaction) return; + + setField(txid, loadedTxid); + txid.title = loadedTxid; + }, + (error, signal) => { + if (signal.aborted) return; + + console.error(error); + }, + ); + + if (!cached) { + txid.removeAttribute("title"); + setField(txid, "loading", true); } - - txid.removeAttribute("title"); - setField(txid, "loading", true); - fetchTxid(transaction, eager); } return /** @type {const} */ ({ diff --git a/website_next/explore/block/preview/inspector/position.js b/website_next/explore/block/preview/inspector/position.js new file mode 100644 index 000000000..ba626b2d6 --- /dev/null +++ b/website_next/explore/block/preview/inspector/position.js @@ -0,0 +1,33 @@ +const OVERLAY_MARGIN = 8; + +/** + * @param {HTMLElement} element + * @param {BlockPreviewPointer} point + */ +export function placeReadout(element, point) { + const figure = /** @type {HTMLElement} */ (element.parentElement); + const bounds = figure.getBoundingClientRect(); + const width = element.offsetWidth; + const height = element.offsetHeight; + const minX = Math.min(bounds.width / 2, width / 2 + OVERLAY_MARGIN); + const maxX = Math.max(minX, bounds.width - minX); + const rawX = point.clientX - bounds.left; + const rawY = point.clientY - bounds.top; + const x = Math.min(maxX, Math.max(minX, rawX)); + const showBelow = rawY < height + OVERLAY_MARGIN * 2; + const minY = showBelow ? OVERLAY_MARGIN : height + OVERLAY_MARGIN; + const maxY = showBelow + ? Math.max(minY, bounds.height - height - OVERLAY_MARGIN) + : Math.max(minY, bounds.height - OVERLAY_MARGIN); + const y = Math.min(maxY, Math.max(minY, rawY)); + + element.style.setProperty("--tx-x", `${x}px`); + element.style.setProperty("--tx-y", `${y}px`); + element.toggleAttribute("data-below", showBelow); +} + +/** + * @typedef {Object} BlockPreviewPointer + * @property {number} clientX + * @property {number} clientY + */ diff --git a/website_next/explore/block/preview/inspector/txid.js b/website_next/explore/block/preview/inspector/txid.js new file mode 100644 index 000000000..614b5b206 --- /dev/null +++ b/website_next/explore/block/preview/inspector/txid.js @@ -0,0 +1,74 @@ +import { loadBlockPreviewTxid } from "../data.js"; + +const TXID_CACHE_LIMIT = 64; +const TXID_DWELL_MS = 120; + +/** + * @param {number} txIndex + * @param {string} txid + * @param {Map} cache + */ +function rememberTxid(txIndex, txid, cache) { + if (cache.has(txIndex)) cache.delete(txIndex); + else if (cache.size >= TXID_CACHE_LIMIT) { + cache.delete(/** @type {number} */ (cache.keys().next().value)); + } + + cache.set(txIndex, txid); +} + +/** + * @param {AbortSignal} parentSignal + */ +export function createTxidLoader(parentSignal) { + const cache = /** @type {Map} */ (new Map()); + let controller = /** @type {AbortController | null} */ (null); + let timer = 0; + + function abort() { + clearTimeout(timer); + controller?.abort(); + controller = null; + } + + /** + * @param {BlockPreviewTransaction} transaction + * @param {boolean} eager + * @param {(txid: string) => void} onTxid + * @param {(error: unknown, signal: AbortSignal) => void} onError + * @returns {boolean} + */ + function load(transaction, eager, onTxid, onError) { + const cached = cache.get(transaction.txIndex); + + abort(); + + if (cached !== undefined) { + onTxid(cached); + return true; + } + + const nextController = new AbortController(); + + controller = nextController; + timer = setTimeout(() => { + const signal = AbortSignal.any([parentSignal, nextController.signal]); + + void loadBlockPreviewTxid(transaction.txIndex, signal) + .then((txid) => { + rememberTxid(transaction.txIndex, txid, cache); + onTxid(txid); + }) + .catch((error) => onError(error, signal)); + }, eager ? 0 : TXID_DWELL_MS); + + return false; + } + + return /** @type {const} */ ({ + abort, + load, + }); +} + +/** @typedef {import("../data.js").BlockPreviewTransaction} BlockPreviewTransaction */