From 8d43f340a0298faab0ea0fb1c7803772613706a2 Mon Sep 17 00:00:00 2001 From: nym21 Date: Fri, 10 Jul 2026 12:45:55 +0200 Subject: [PATCH] website_next: part 12 --- website_next/explore/block/fee-rates.js | 17 --- website_next/explore/block/preview/data.js | 54 ++++--- .../explore/block/preview/filters/model.js | 37 +++-- .../explore/block/preview/heatmap/capacity.js | 22 +-- .../explore/block/preview/heatmap/fees.js | 36 ++--- .../explore/block/preview/heatmap/geometry.js | 73 ++++++++-- .../explore/block/preview/heatmap/index.js | 136 +++++++++++++----- .../explore/block/preview/heatmap/pack.js | 41 +++--- website_next/explore/block/preview/index.js | 14 +- .../explore/block/preview/inspector.js | 39 ++++- .../block/preview/inspector/position.js | 12 +- 11 files changed, 320 insertions(+), 161 deletions(-) diff --git a/website_next/explore/block/fee-rates.js b/website_next/explore/block/fee-rates.js index a98307b17..7fd5c7ff8 100644 --- a/website_next/explore/block/fee-rates.js +++ b/website_next/explore/block/fee-rates.js @@ -18,23 +18,6 @@ export const FEE_RATE_STOPS = /** @type {const} */ ([ { label: "max", color: "var(--red)" }, ]); -/** - * @param {number[]} values - * @param {number} percentile - */ -function percentileValue(values, percentile) { - return values[Math.round((percentile / 100) * (values.length - 1))]; -} - -/** @param {number[]} feeRates */ -export function createFeeRateRange(feeRates) { - feeRates.sort((a, b) => a - b); - - return FEE_RATE_PERCENTILES.map((percentile) => { - return percentileValue(feeRates, percentile); - }); -} - /** * @param {number} feeRate * @param {number[]} ranges diff --git a/website_next/explore/block/preview/data.js b/website_next/explore/block/preview/data.js index 7f3c1d742..803366e62 100644 --- a/website_next/explore/block/preview/data.js +++ b/website_next/explore/block/preview/data.js @@ -1,11 +1,17 @@ import { brk } from "../../../utils/client.js"; -import { FILTERS, getFilterBit, getTypeBit } from "./filters/model.js"; +import { + FILTERS, + TYPE_BITS, + TYPE_FILTER_MASK, + getFilterBit, +} from "./filters/model.js"; -const VERSION_BITS = new Map([ - [1, getFilterBit("version:1")], - [2, getFilterBit("version:2")], - [3, getFilterBit("version:3")], -]); +const VERSION_BITS = [ + 0, + getFilterBit("version:1"), + getFilterBit("version:2"), + getFilterBit("version:3"), +]; const RBF_YES_BIT = getFilterBit("rbf:yes"); const RBF_NO_BIT = getFilterBit("rbf:no"); @@ -13,6 +19,11 @@ const INPUT_ONE_BIT = getFilterBit("input:one"); const INPUT_MULTI_BIT = getFilterBit("input:multi"); const OUTPUT_ONE_BIT = getFilterBit("output:one"); const OUTPUT_MULTI_BIT = getFilterBit("output:multi"); +const FILTER_INDEX_BY_BIT = FILTERS.reduce((indexes, { bit, index }) => { + indexes[bit] = index; + + return indexes; +}, /** @type {Record} */ ({})); /** * @template T @@ -73,11 +84,8 @@ export async function loadBlockPreview(block, signal) { return { range: { start, end }, - transactions: weights.data.map((weight, index) => ({ - txIndex: start + index, - weight: /** @type {number} */ (weight), - feeRate: /** @type {number} */ (feeRates.data[index]), - })), + feeRates: /** @type {number[]} */ (feeRates.data), + weights: /** @type {number[]} */ (weights.data), }; } @@ -112,8 +120,11 @@ function rangeEnd(starts, counts) { * @param {number} mask */ function countMask(counts, mask) { - for (const { bit, index } of FILTERS) { - if (mask & bit) counts[index] += 1; + while (mask) { + const bit = mask & -mask; + + counts[FILTER_INDEX_BY_BIT[bit]] += 1; + mask ^= bit; } } @@ -126,7 +137,8 @@ function getTypesMask(types, start, end) { let mask = 0; for (let index = start; index < end; index += 1) { - mask |= getTypeBit(types[index]); + mask |= TYPE_BITS[/** @type {keyof typeof TYPE_BITS} */ (types[index])] ?? 0; + if (mask === TYPE_FILTER_MASK) return mask; } return mask; @@ -186,7 +198,8 @@ export async function loadBlockPreviewFilters(range, signal) { const masks = new Uint32Array(end - start); const counts = new Uint32Array(FILTERS.length); - versions.data.forEach((version, index) => { + for (let index = 0; index < versions.data.length; index += 1) { + const version = /** @type {number} */ (versions.data[index]); const inputCount = /** @type {number} */ (inputCounts.data[index]); const outputCount = /** @type {number} */ (outputCounts.data[index]); const inputTypeStart = /** @type {number} */ (firstInputs.data[index]); @@ -194,7 +207,7 @@ export async function loadBlockPreviewFilters(range, signal) { const outputTypeStart = /** @type {number} */ (firstOutputs.data[index]); const outputTypeEnd = outputTypeStart + outputCount; const mask = - (VERSION_BITS.get(/** @type {number} */ (version)) ?? 0) | + (VERSION_BITS[version] ?? 0) | (rbfs.data[index] ? RBF_YES_BIT : RBF_NO_BIT) | (inputCount === 1 ? INPUT_ONE_BIT : INPUT_MULTI_BIT) | (outputCount === 1 ? OUTPUT_ONE_BIT : OUTPUT_MULTI_BIT) | @@ -211,7 +224,7 @@ export async function loadBlockPreviewFilters(range, signal) { masks[index] = mask; countMask(counts, mask); - }); + } return { counts, masks, start }; } @@ -222,6 +235,13 @@ export async function loadBlockPreviewFilters(range, signal) { * @property {number} end */ +/** + * @typedef {Object} BlockPreviewData + * @property {BlockPreviewRange} range + * @property {number[]} weights + * @property {number[]} feeRates + */ + /** * @typedef {Object} BlockPreviewTransaction * @property {number} txIndex diff --git a/website_next/explore/block/preview/filters/model.js b/website_next/explore/block/preview/filters/model.js index 3ae1474a9..6b8e3e0a3 100644 --- a/website_next/explore/block/preview/filters/model.js +++ b/website_next/explore/block/preview/filters/model.js @@ -50,18 +50,22 @@ const FILTER_BITS = /** @type {Map} */ ( new Map(FILTERS.map(({ bit, key }) => [key, bit])) ); -const TYPE_KEYS = /** @type {const} */ ({ - empty: "type:empty", - multisig: "type:multisig", - op_return: "type:op_return", - p2a: "type:p2a", - p2pk: "type:p2pk", - p2pkh: "type:p2pkh", - p2sh: "type:p2sh", - unknown: "type:unknown", - v0_p2wpkh: "type:p2wpkh", - v0_p2wsh: "type:p2wsh", - v1_p2tr: "type:taproot", +export const TYPE_FILTER_MASK = FILTERS + .filter(({ group }) => group === "type") + .reduce((mask, { bit }) => mask | bit, 0); + +export const TYPE_BITS = /** @type {const} */ ({ + empty: getFilterBit("type:empty"), + multisig: getFilterBit("type:multisig"), + op_return: getFilterBit("type:op_return"), + p2a: getFilterBit("type:p2a"), + p2pk: getFilterBit("type:p2pk"), + p2pkh: getFilterBit("type:p2pkh"), + p2sh: getFilterBit("type:p2sh"), + unknown: getFilterBit("type:unknown"), + v0_p2wpkh: getFilterBit("type:p2wpkh"), + v0_p2wsh: getFilterBit("type:p2wsh"), + v1_p2tr: getFilterBit("type:taproot"), }); /** @@ -70,12 +74,3 @@ const TYPE_KEYS = /** @type {const} */ ({ export function getFilterBit(key) { return FILTER_BITS.get(key) ?? 0; } - -/** - * @param {string} type - */ -export function getTypeBit(type) { - const key = TYPE_KEYS[/** @type {keyof typeof TYPE_KEYS} */ (type)]; - - return key === undefined ? 0 : getFilterBit(key); -} diff --git a/website_next/explore/block/preview/heatmap/capacity.js b/website_next/explore/block/preview/heatmap/capacity.js index 69f3141b1..47e445784 100644 --- a/website_next/explore/block/preview/heatmap/capacity.js +++ b/website_next/explore/block/preview/heatmap/capacity.js @@ -16,12 +16,16 @@ function weightToSpan(weight, capacity, columns) { * @param {readonly Cell[]} cells * @param {number} capacity * @param {number} columns + * @returns {ResolvedCapacityCell[]} */ function resolveCapacityCells(cells, capacity, columns) { - return cells.map((cell) => ({ - ...cell, - span: weightToSpan(cell.weight, capacity, columns), - })); + const resolved = /** @type {ResolvedCapacityCell[]} */ (cells); + + for (const cell of resolved) { + cell.span = weightToSpan(cell.weight, capacity, columns); + } + + return resolved; } /** @@ -31,10 +35,7 @@ function resolveCapacityCells(cells, capacity, columns) { * @param {number} columns */ function fitCapacityCells(cells, capacity, columns) { - let resolvedCells = resolveCapacityCells(cells, capacity, columns).slice( - 0, - columns * columns, - ); + const resolvedCells = resolveCapacityCells(cells, capacity, columns); let layouts = packCells(resolvedCells, columns, columns); while (layouts === null) { @@ -71,3 +72,8 @@ export function createSquareLayout(cells, capacity, columns) { * @typedef {Object} CapacityCell * @property {number} weight */ + +/** + * @template {CapacityCell} Cell + * @typedef {Cell & { span: number }} ResolvedCapacityCell + */ diff --git a/website_next/explore/block/preview/heatmap/fees.js b/website_next/explore/block/preview/heatmap/fees.js index 790e630a6..62c1b722e 100644 --- a/website_next/explore/block/preview/heatmap/fees.js +++ b/website_next/explore/block/preview/heatmap/fees.js @@ -1,23 +1,27 @@ -import { createFeeRateRange } from "../../fee-rates.js"; +import { FEE_RATE_PERCENTILES } from "../../fee-rates.js"; /** - * @param {BlockPreviewTransaction[]} transactions + * @param {number[]} feeRates + * @param {Uint32Array} order */ -export function createPreviewFeeRange(transactions) { - const feeRates = new Array(transactions.length); +export function createPreviewFeeRange(feeRates, order) { + return FEE_RATE_PERCENTILES.map((percentile) => { + const index = Math.round(((100 - percentile) / 100) * (order.length - 1)); - for (let index = 0; index < transactions.length; index += 1) { - feeRates[index] = transactions[index].feeRate; + return feeRates[order[index]]; + }); +} + +/** + * @param {number[]} weights + * @param {number[]} feeRates + */ +export function orderTransactions(weights, feeRates) { + const order = new Uint32Array(feeRates.length); + + for (let index = 0; index < order.length; index += 1) { + order[index] = index; } - return createFeeRateRange(feeRates); + return order.sort((a, b) => feeRates[b] - feeRates[a] || weights[b] - weights[a]); } - -/** - * @param {BlockPreviewTransaction[]} transactions - */ -export function orderTransactions(transactions) { - 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 index 12ab38a8f..22955b361 100644 --- a/website_next/explore/block/preview/heatmap/geometry.js +++ b/website_next/explore/block/preview/heatmap/geometry.js @@ -21,20 +21,42 @@ function getGap(canvas, width) { return Math.max(1, maxGap * Math.min(1, width / GAP_REFERENCE_WIDTH)); } +/** + * @param {Uint16Array} lookup + * @param {number} columns + * @param {SquareCellLayout} layout + * @param {number} value + */ +function fillLookup(lookup, columns, layout, value) { + const endX = layout.x + layout.span; + const endY = layout.y + layout.span; + + for (let row = layout.y; row < endY; row += 1) { + const offset = row * columns; + + for (let column = layout.x; column < endX; column += 1) { + lookup[offset + column] = value; + } + } +} + /** * @param {SquareLayout} square * @param {HTMLCanvasElement} canvas * @param {number} width */ -export function createPreviewRects(square, canvas, width) { +export function createPreviewGeometry(square, canvas, width) { const gap = getGap(canvas, width); const cell = Math.max(1, (width - gap * (square.columns - 1)) / square.columns); const unit = cell + gap; + const lookup = new Uint16Array(square.columns * square.columns); - return square.layouts.map((layout, index) => { + const rects = square.layouts.map((layout, index) => { const transaction = square.resolvedCells[index].transaction; const rectSize = unitsToPixels(layout.span, cell, gap); + fillLookup(lookup, square.columns, layout, index + 1); + return { color: square.resolvedCells[index].color, size: rectSize, @@ -43,25 +65,39 @@ export function createPreviewRects(square, canvas, width) { y: width - layout.y * unit - rectSize, }; }); + + return { columns: square.columns, lookup, rects, unit, width }; } /** - * @param {PreviewRect[]} rects + * @param {PreviewGeometry} geometry * @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]; +export function hitTest(geometry, x, y) { + if (x < 0 || y < 0 || x > geometry.width || y > geometry.width) { + return null; + } - if ( - x >= rect.x && - x <= rect.x + rect.size && - y >= rect.y && - y <= rect.y + rect.size - ) { - return rect.transaction; - } + const column = Math.min(geometry.columns - 1, Math.floor(x / geometry.unit)); + const rowFromTop = Math.min( + geometry.columns - 1, + Math.floor(y / geometry.unit), + ); + const row = geometry.columns - rowFromTop - 1; + const index = geometry.lookup[row * geometry.columns + column] - 1; + + if (index < 0) return null; + + const rect = geometry.rects[index]; + + if ( + x >= rect.x && + x <= rect.x + rect.size && + y >= rect.y && + y <= rect.y + rect.size + ) { + return rect.transaction; } return null; @@ -76,6 +112,15 @@ export function hitTest(rects, x, y) { * @property {number} y */ +/** + * @typedef {Object} PreviewGeometry + * @property {number} columns + * @property {Uint16Array} lookup + * @property {PreviewRect[]} rects + * @property {number} unit + * @property {number} width + */ + /** * @typedef {Object} SquareLayout * @property {number} columns diff --git a/website_next/explore/block/preview/heatmap/index.js b/website_next/explore/block/preview/heatmap/index.js index 517b6c644..e8a8ebdeb 100644 --- a/website_next/explore/block/preview/heatmap/index.js +++ b/website_next/explore/block/preview/heatmap/index.js @@ -2,44 +2,83 @@ import { MAX_BLOCK_WEIGHT } from "../../format.js"; import { createPreviewFeeRange, orderTransactions } from "./fees.js"; import { createSquareLayout } from "./capacity.js"; import { getCanvasFeeRateColor } from "./color.js"; -import { createPreviewRects, hitTest } from "./geometry.js"; +import { createPreviewGeometry, hitTest } from "./geometry.js"; import { drawPreview } from "./draw.js"; const COLUMNS = 84; const VISIBLE_CELLS = COLUMNS * COLUMNS; /** - * @param {BlockPreviewTransaction[]} transactions + * @param {BlockPreviewData} data + * @param {Uint32Array} order + * @param {number[]} ranges + */ +function createCapacityCells(data, order, ranges) { + const count = Math.min(order.length, VISIBLE_CELLS); + const colors = /** @type {Map} */ (new Map()); + const cells = []; + + for (let index = 0; index < count; index += 1) { + const offset = order[index]; + const feeRate = data.feeRates[offset]; + const weight = data.weights[offset]; + let color = colors.get(feeRate); + + if (color === undefined) { + color = getCanvasFeeRateColor(feeRate, ranges); + colors.set(feeRate, color); + } + + cells.push({ + color, + transaction: { + feeRate, + txIndex: data.range.start + offset, + weight, + }, + weight, + }); + } + + return cells; +} + +/** + * @param {BlockPreviewData} data * @param {Object} [options] * @param {(transaction: BlockPreviewTransaction | null, point: BlockPreviewPointer | null, eager: boolean) => void} [options.onInspect] */ -export function createBlockPreviewHeatmap(transactions, options = {}) { +export function createBlockPreviewHeatmap(data, options = {}) { const canvas = document.createElement("canvas"); const context = /** @type {CanvasRenderingContext2D} */ ( canvas.getContext("2d") ); - const ordered = orderTransactions(transactions); - const ranges = createPreviewFeeRange(ordered); - const visible = ordered.slice(0, VISIBLE_CELLS); - const cells = visible.map((transaction) => ({ - color: getCanvasFeeRateColor(transaction.feeRate, ranges), - transaction, - weight: transaction.weight, - })); + const order = orderTransactions(data.weights, data.feeRates); + const ranges = createPreviewFeeRange(data.feeRates, order); + const cells = createCapacityCells(data, order, ranges); const square = createSquareLayout(cells, MAX_BLOCK_WEIGHT, COLUMNS); let disabledMask = 0; let filterState = /** @type {BlockPreviewFilterState | null} */ (null); let frame = 0; let inspected = /** @type {BlockPreviewTransaction | null} */ (null); + let inspectFrame = 0; + let inspectPoint = /** @type {BlockPreviewPointer | null} */ (null); + let bounds = /** @type {DOMRectReadOnly | null} */ (null); let previewMask = /** @type {number | null} */ (null); - let rects = /** @type {PreviewRect[]} */ ([]); + let geometry = /** @type {PreviewGeometry | null} */ (null); let rectWidth = 0; let capturedPointer = /** @type {number | null} */ (null); canvas.dataset.blockPreviewHeatmap = ""; + function measure() { + bounds = canvas.getBoundingClientRect(); + + return bounds; + } + function draw() { - const width = canvas.getBoundingClientRect().width; + const width = measure().width; if (width <= 0) return; @@ -53,7 +92,7 @@ export function createBlockPreviewHeatmap(transactions, options = {}) { if (rectWidth !== width) { rectWidth = width; - rects = createPreviewRects(square, canvas, width); + geometry = createPreviewGeometry(square, canvas, width); } context.setTransform(dpr, 0, 0, dpr, 0, 0); @@ -64,7 +103,7 @@ export function createBlockPreviewHeatmap(transactions, options = {}) { filterState, inspected, previewMask, - rects, + rects: geometry?.rects ?? [], }); } @@ -73,6 +112,12 @@ export function createBlockPreviewHeatmap(transactions, options = {}) { frame = requestAnimationFrame(draw); } + function cancelInspectFrame() { + cancelAnimationFrame(inspectFrame); + inspectFrame = 0; + inspectPoint = null; + } + /** @param {BlockPreviewTransaction | null} transaction */ function setInspected(transaction) { if (transaction === inspected) return; @@ -82,46 +127,64 @@ export function createBlockPreviewHeatmap(transactions, options = {}) { } /** - * @param {PointerEvent} event + * @param {BlockPreviewPointer} point * @param {boolean} eager */ - function inspectAt(event, eager) { - const bounds = canvas.getBoundingClientRect(); + function inspectAt(point, eager) { + const nextBounds = bounds ?? measure(); + if (geometry === null) draw(); + if (geometry === null) return; + const transaction = hitTest( - rects, - event.clientX - bounds.left, - event.clientY - bounds.top, + geometry, + point.clientX - nextBounds.left, + point.clientY - nextBounds.top, ); if (transaction === null && inspected !== null) { - options.onInspect?.( - inspected, - { clientX: event.clientX, clientY: event.clientY }, - eager, - ); + options.onInspect?.(inspected, point, eager); return; } setInspected(transaction); - options.onInspect?.( - transaction, - { clientX: event.clientX, clientY: event.clientY }, - eager, - ); + options.onInspect?.(transaction, point, eager); + } + + /** @param {PointerEvent} event */ + function pointFromEvent(event) { + return { clientX: event.clientX, clientY: event.clientY }; + } + + function inspectLatest() { + const point = inspectPoint; + + inspectFrame = 0; + inspectPoint = null; + if (point !== null) inspectAt(point, false); + } + + /** @param {PointerEvent} event */ + function scheduleInspect(event) { + inspectPoint = pointFromEvent(event); + if (inspectFrame) return; + + inspectFrame = requestAnimationFrame(inspectLatest); } /** @param {PointerEvent} event */ function startInspect(event) { capturedPointer = event.pointerId; canvas.setPointerCapture(event.pointerId); - inspectAt(event, true); + cancelInspectFrame(); + measure(); + inspectAt(pointFromEvent(event), true); if (event.pointerType !== "mouse") event.preventDefault(); } /** @param {PointerEvent} event */ function moveInspect(event) { if (capturedPointer !== null || event.pointerType === "mouse") { - inspectAt(event, false); + scheduleInspect(event); if (capturedPointer !== null && event.pointerType !== "mouse") { event.preventDefault(); } @@ -138,6 +201,7 @@ export function createBlockPreviewHeatmap(transactions, options = {}) { } function clearInspect() { + cancelInspectFrame(); setInspected(null); options.onInspect?.(null, null, false); } @@ -151,6 +215,7 @@ export function createBlockPreviewHeatmap(transactions, options = {}) { const observer = new ResizeObserver(scheduleDraw); + canvas.addEventListener("pointerenter", measure); canvas.addEventListener("pointermove", moveInspect); canvas.addEventListener("pointerdown", startInspect); canvas.addEventListener("pointerup", stopInspect); @@ -170,9 +235,9 @@ export function createBlockPreviewHeatmap(transactions, options = {}) { return /** @type {const} */ ({ element: canvas, - ordered, destroy() { cancelAnimationFrame(frame); + cancelInspectFrame(); document.removeEventListener("pointerdown", clearOnOutsidePointer); window.removeEventListener("blur", clearInspect); observer.disconnect(); @@ -199,6 +264,7 @@ export function createBlockPreviewHeatmap(transactions, options = {}) { }); } +/** @typedef {import("../data.js").BlockPreviewData} BlockPreviewData */ /** @typedef {import("../data.js").BlockPreviewTransaction} BlockPreviewTransaction */ /** @typedef {import("../data.js").BlockPreviewFilterState} BlockPreviewFilterState */ @@ -208,4 +274,4 @@ export function createBlockPreviewHeatmap(transactions, options = {}) { * @property {number} clientY */ -/** @typedef {import("./geometry.js").PreviewRect} PreviewRect */ +/** @typedef {import("./geometry.js").PreviewGeometry} PreviewGeometry */ diff --git a/website_next/explore/block/preview/heatmap/pack.js b/website_next/explore/block/preview/heatmap/pack.js index 218e713a8..1392c50ca 100644 --- a/website_next/explore/block/preview/heatmap/pack.js +++ b/website_next/explore/block/preview/heatmap/pack.js @@ -1,41 +1,39 @@ /** * @param {readonly PackCell[]} cells * @param {number} columns - * @param {number} [rows] + * @param {number} rows * @returns {PackLayout[] | null} */ export function packCells(cells, columns, rows) { - const occupied = /** @type {boolean[][]} */ ([]); + const occupied = new Uint8Array(columns * rows); const layouts = []; - let usedRows = 0; for (const cell of cells) { const span = Math.min(cell.span, columns); - const position = findPosition(occupied, columns, span, rows); + const position = findPosition(occupied, columns, rows, span); if (position === null) return null; - fillCells(occupied, position.x, position.y, span); - usedRows = Math.max(usedRows, position.y + span); - layouts.push({ ...position, span, rows: rows ?? usedRows }); + fillCells(occupied, columns, position.x, position.y, span); + layouts.push({ x: position.x, y: position.y, span }); } return layouts; } /** - * @param {boolean[][]} occupied + * @param {Uint8Array} occupied * @param {number} columns + * @param {number} rows * @param {number} span - * @param {number} [rows] * @returns {{ x: number, y: number } | null} */ -function findPosition(occupied, columns, span, rows) { - const lastRow = rows === undefined ? Infinity : rows - span; +function findPosition(occupied, columns, rows, span) { + const lastRow = rows - span; for (let y = 0; y <= lastRow; y += 1) { for (let x = 0; x <= columns - span; x += 1) { - if (canPlace(occupied, x, y, span)) return { x, y }; + if (canPlace(occupied, columns, x, y, span)) return { x, y }; } } @@ -43,15 +41,18 @@ function findPosition(occupied, columns, span, rows) { } /** - * @param {boolean[][]} occupied + * @param {Uint8Array} occupied + * @param {number} columns * @param {number} x * @param {number} y * @param {number} span */ -function canPlace(occupied, x, y, span) { +function canPlace(occupied, columns, x, y, span) { for (let row = y; row < y + span; row += 1) { + const offset = row * columns; + for (let column = x; column < x + span; column += 1) { - if (occupied[row]?.[column]) return false; + if (occupied[offset + column]) return false; } } @@ -59,17 +60,18 @@ function canPlace(occupied, x, y, span) { } /** - * @param {boolean[][]} occupied + * @param {Uint8Array} occupied + * @param {number} columns * @param {number} x * @param {number} y * @param {number} span */ -function fillCells(occupied, x, y, span) { +function fillCells(occupied, columns, x, y, span) { for (let row = y; row < y + span; row += 1) { - occupied[row] ??= []; + const offset = row * columns; for (let column = x; column < x + span; column += 1) { - occupied[row][column] = true; + occupied[offset + column] = 1; } } } @@ -84,5 +86,4 @@ function fillCells(occupied, x, y, span) { * @property {number} x * @property {number} y * @property {number} span - * @property {number} rows */ diff --git a/website_next/explore/block/preview/index.js b/website_next/explore/block/preview/index.js index 59fb975b4..25f0c8f93 100644 --- a/website_next/explore/block/preview/index.js +++ b/website_next/explore/block/preview/index.js @@ -47,14 +47,14 @@ function memoize(load) { } /** - * @param {BlockPreviewTransaction[]} transactions + * @param {BlockPreviewData} data * @param {() => Promise} loadFilters * @param {AbortSignal} signal */ -function createPreview(transactions, loadFilters, signal) { +function createPreview(data, loadFilters, signal) { const loadFilterState = memoize(loadFilters); const inspector = createBlockPreviewInspector(signal, loadFilterState); - const heatmap = createBlockPreviewHeatmap(transactions, { + const heatmap = createBlockPreviewHeatmap(data, { onInspect: inspector.inspect, }); const filters = createPreviewFilters(loadFilterState, heatmap); @@ -94,11 +94,11 @@ export function createBlockPreviewPane(block) { renderStatus(content, "Loading"); void loadBlockPreview(block, controller.signal) - .then(({ range, transactions }) => { + .then((data) => { if (!live) return; const preview = createPreview( - transactions, - () => loadBlockPreviewFilters(range, controller.signal), + data, + () => loadBlockPreviewFilters(data.range, controller.signal), controller.signal, ); @@ -122,7 +122,7 @@ export function createBlockPreviewPane(block) { }; } -/** @typedef {import("./data.js").BlockPreviewTransaction} BlockPreviewTransaction */ +/** @typedef {import("./data.js").BlockPreviewData} BlockPreviewData */ /** @typedef {import("./data.js").BlockPreviewFilterState} BlockPreviewFilterState */ /** diff --git a/website_next/explore/block/preview/inspector.js b/website_next/explore/block/preview/inspector.js index 48700f750..f976e02e3 100644 --- a/website_next/explore/block/preview/inspector.js +++ b/website_next/explore/block/preview/inspector.js @@ -50,6 +50,7 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { const txidLoader = createTxidLoader(parentSignal); let inspected = /** @type {BlockPreviewTransaction | null} */ (null); let point = /** @type {BlockPreviewPointer | null} */ (null); + let readoutSize = /** @type {BlockPreviewReadoutSize | null} */ (null); let filterState = /** @type {BlockPreviewFilterState | null} */ (null); let filterPromise = /** @type {Promise | null} */ (null); let traitsTimer = 0; @@ -62,7 +63,24 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { txidLoader.abort(); } + function invalidateReadoutSize() { + readoutSize = null; + } + + /** + * @param {BlockPreviewPointer} nextPoint + */ + function place(nextPoint) { + readoutSize ??= { + height: element.offsetHeight, + width: element.offsetWidth, + }; + placeReadout(element, nextPoint, readoutSize); + } + function setTraitsLoading() { + invalidateReadoutSize(); + for (const { value } of traitFields) { value.removeAttribute("title"); setField(value, "loading", true); @@ -71,6 +89,8 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { /** @param {number} mask */ function setTraits(mask) { + invalidateReadoutSize(); + for (const { filters, value } of traitFields) { const labels = filters .filter(({ bit }) => mask & bit) @@ -88,7 +108,7 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { const mask = state.masks[transaction.txIndex - state.start]; setTraits(mask); - if (point !== null) placeReadout(element, point); + if (point !== null) place(point); } function loadFilterState() { @@ -141,12 +161,15 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { element.hidden = false; point = nextPoint; - placeReadout(element, nextPoint); - if (transaction === inspected) return; + if (transaction === inspected) { + place(nextPoint); + return; + } abortPending(); inspected = transaction; + invalidateReadoutSize(); setField(tx, `#${formatNumber(transaction.txIndex)}`); setField(fee, `${formatFeeRate(transaction.feeRate)} sat/vB`); @@ -159,8 +182,10 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { (loadedTxid) => { if (inspected !== transaction) return; + invalidateReadoutSize(); setField(txid, loadedTxid); txid.title = loadedTxid; + if (point !== null) place(point); }, (error, signal) => { if (signal.aborted) return; @@ -173,6 +198,8 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { txid.removeAttribute("title"); setField(txid, "loading", true); } + + place(nextPoint); } return /** @type {const} */ ({ @@ -190,3 +217,9 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { * @property {number} clientX * @property {number} clientY */ + +/** + * @typedef {Object} BlockPreviewReadoutSize + * @property {number} width + * @property {number} height + */ diff --git a/website_next/explore/block/preview/inspector/position.js b/website_next/explore/block/preview/inspector/position.js index ba626b2d6..68a62625e 100644 --- a/website_next/explore/block/preview/inspector/position.js +++ b/website_next/explore/block/preview/inspector/position.js @@ -3,12 +3,12 @@ const OVERLAY_MARGIN = 8; /** * @param {HTMLElement} element * @param {BlockPreviewPointer} point + * @param {BlockPreviewReadoutSize} size */ -export function placeReadout(element, point) { +export function placeReadout(element, point, size) { const figure = /** @type {HTMLElement} */ (element.parentElement); const bounds = figure.getBoundingClientRect(); - const width = element.offsetWidth; - const height = element.offsetHeight; + const { width, height } = size; 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; @@ -31,3 +31,9 @@ export function placeReadout(element, point) { * @property {number} clientX * @property {number} clientY */ + +/** + * @typedef {Object} BlockPreviewReadoutSize + * @property {number} width + * @property {number} height + */