website_next: part 12

This commit is contained in:
nym21
2026-07-10 12:45:55 +02:00
parent e279116bff
commit 8d43f340a0
11 changed files with 320 additions and 161 deletions
-17
View File
@@ -18,23 +18,6 @@ export const FEE_RATE_STOPS = /** @type {const} */ ([
{ label: "max", color: "var(--red)" }, { 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} feeRate
* @param {number[]} ranges * @param {number[]} ranges
+37 -17
View File
@@ -1,11 +1,17 @@
import { brk } from "../../../utils/client.js"; 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([ const VERSION_BITS = [
[1, getFilterBit("version:1")], 0,
[2, getFilterBit("version:2")], getFilterBit("version:1"),
[3, getFilterBit("version:3")], getFilterBit("version:2"),
]); getFilterBit("version:3"),
];
const RBF_YES_BIT = getFilterBit("rbf:yes"); const RBF_YES_BIT = getFilterBit("rbf:yes");
const RBF_NO_BIT = getFilterBit("rbf:no"); 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 INPUT_MULTI_BIT = getFilterBit("input:multi");
const OUTPUT_ONE_BIT = getFilterBit("output:one"); const OUTPUT_ONE_BIT = getFilterBit("output:one");
const OUTPUT_MULTI_BIT = getFilterBit("output:multi"); const OUTPUT_MULTI_BIT = getFilterBit("output:multi");
const FILTER_INDEX_BY_BIT = FILTERS.reduce((indexes, { bit, index }) => {
indexes[bit] = index;
return indexes;
}, /** @type {Record<number, number>} */ ({}));
/** /**
* @template T * @template T
@@ -73,11 +84,8 @@ export async function loadBlockPreview(block, signal) {
return { return {
range: { start, end }, range: { start, end },
transactions: weights.data.map((weight, index) => ({ feeRates: /** @type {number[]} */ (feeRates.data),
txIndex: start + index, weights: /** @type {number[]} */ (weights.data),
weight: /** @type {number} */ (weight),
feeRate: /** @type {number} */ (feeRates.data[index]),
})),
}; };
} }
@@ -112,8 +120,11 @@ function rangeEnd(starts, counts) {
* @param {number} mask * @param {number} mask
*/ */
function countMask(counts, mask) { function countMask(counts, mask) {
for (const { bit, index } of FILTERS) { while (mask) {
if (mask & bit) counts[index] += 1; 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; let mask = 0;
for (let index = start; index < end; index += 1) { 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; return mask;
@@ -186,7 +198,8 @@ export async function loadBlockPreviewFilters(range, signal) {
const masks = new Uint32Array(end - start); const masks = new Uint32Array(end - start);
const counts = new Uint32Array(FILTERS.length); 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 inputCount = /** @type {number} */ (inputCounts.data[index]);
const outputCount = /** @type {number} */ (outputCounts.data[index]); const outputCount = /** @type {number} */ (outputCounts.data[index]);
const inputTypeStart = /** @type {number} */ (firstInputs.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 outputTypeStart = /** @type {number} */ (firstOutputs.data[index]);
const outputTypeEnd = outputTypeStart + outputCount; const outputTypeEnd = outputTypeStart + outputCount;
const mask = const mask =
(VERSION_BITS.get(/** @type {number} */ (version)) ?? 0) | (VERSION_BITS[version] ?? 0) |
(rbfs.data[index] ? RBF_YES_BIT : RBF_NO_BIT) | (rbfs.data[index] ? RBF_YES_BIT : RBF_NO_BIT) |
(inputCount === 1 ? INPUT_ONE_BIT : INPUT_MULTI_BIT) | (inputCount === 1 ? INPUT_ONE_BIT : INPUT_MULTI_BIT) |
(outputCount === 1 ? OUTPUT_ONE_BIT : OUTPUT_MULTI_BIT) | (outputCount === 1 ? OUTPUT_ONE_BIT : OUTPUT_MULTI_BIT) |
@@ -211,7 +224,7 @@ export async function loadBlockPreviewFilters(range, signal) {
masks[index] = mask; masks[index] = mask;
countMask(counts, mask); countMask(counts, mask);
}); }
return { counts, masks, start }; return { counts, masks, start };
} }
@@ -222,6 +235,13 @@ export async function loadBlockPreviewFilters(range, signal) {
* @property {number} end * @property {number} end
*/ */
/**
* @typedef {Object} BlockPreviewData
* @property {BlockPreviewRange} range
* @property {number[]} weights
* @property {number[]} feeRates
*/
/** /**
* @typedef {Object} BlockPreviewTransaction * @typedef {Object} BlockPreviewTransaction
* @property {number} txIndex * @property {number} txIndex
@@ -50,18 +50,22 @@ const FILTER_BITS = /** @type {Map<string, number>} */ (
new Map(FILTERS.map(({ bit, key }) => [key, bit])) new Map(FILTERS.map(({ bit, key }) => [key, bit]))
); );
const TYPE_KEYS = /** @type {const} */ ({ export const TYPE_FILTER_MASK = FILTERS
empty: "type:empty", .filter(({ group }) => group === "type")
multisig: "type:multisig", .reduce((mask, { bit }) => mask | bit, 0);
op_return: "type:op_return",
p2a: "type:p2a", export const TYPE_BITS = /** @type {const} */ ({
p2pk: "type:p2pk", empty: getFilterBit("type:empty"),
p2pkh: "type:p2pkh", multisig: getFilterBit("type:multisig"),
p2sh: "type:p2sh", op_return: getFilterBit("type:op_return"),
unknown: "type:unknown", p2a: getFilterBit("type:p2a"),
v0_p2wpkh: "type:p2wpkh", p2pk: getFilterBit("type:p2pk"),
v0_p2wsh: "type:p2wsh", p2pkh: getFilterBit("type:p2pkh"),
v1_p2tr: "type:taproot", 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) { export function getFilterBit(key) {
return FILTER_BITS.get(key) ?? 0; 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);
}
@@ -16,12 +16,16 @@ function weightToSpan(weight, capacity, columns) {
* @param {readonly Cell[]} cells * @param {readonly Cell[]} cells
* @param {number} capacity * @param {number} capacity
* @param {number} columns * @param {number} columns
* @returns {ResolvedCapacityCell<Cell>[]}
*/ */
function resolveCapacityCells(cells, capacity, columns) { function resolveCapacityCells(cells, capacity, columns) {
return cells.map((cell) => ({ const resolved = /** @type {ResolvedCapacityCell<Cell>[]} */ (cells);
...cell,
span: weightToSpan(cell.weight, capacity, columns), 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 * @param {number} columns
*/ */
function fitCapacityCells(cells, capacity, columns) { function fitCapacityCells(cells, capacity, columns) {
let resolvedCells = resolveCapacityCells(cells, capacity, columns).slice( const resolvedCells = resolveCapacityCells(cells, capacity, columns);
0,
columns * columns,
);
let layouts = packCells(resolvedCells, columns, columns); let layouts = packCells(resolvedCells, columns, columns);
while (layouts === null) { while (layouts === null) {
@@ -71,3 +72,8 @@ export function createSquareLayout(cells, capacity, columns) {
* @typedef {Object} CapacityCell * @typedef {Object} CapacityCell
* @property {number} weight * @property {number} weight
*/ */
/**
* @template {CapacityCell} Cell
* @typedef {Cell & { span: number }} ResolvedCapacityCell
*/
@@ -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) { export function createPreviewFeeRange(feeRates, order) {
const feeRates = new Array(transactions.length); 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) { return feeRates[order[index]];
feeRates[index] = transactions[index].feeRate; });
}
/**
* @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 */
@@ -21,20 +21,42 @@ function getGap(canvas, width) {
return Math.max(1, maxGap * Math.min(1, width / GAP_REFERENCE_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 {SquareLayout} square
* @param {HTMLCanvasElement} canvas * @param {HTMLCanvasElement} canvas
* @param {number} width * @param {number} width
*/ */
export function createPreviewRects(square, canvas, width) { export function createPreviewGeometry(square, canvas, width) {
const gap = getGap(canvas, width); const gap = getGap(canvas, width);
const cell = Math.max(1, (width - gap * (square.columns - 1)) / square.columns); const cell = Math.max(1, (width - gap * (square.columns - 1)) / square.columns);
const unit = cell + gap; 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 transaction = square.resolvedCells[index].transaction;
const rectSize = unitsToPixels(layout.span, cell, gap); const rectSize = unitsToPixels(layout.span, cell, gap);
fillLookup(lookup, square.columns, layout, index + 1);
return { return {
color: square.resolvedCells[index].color, color: square.resolvedCells[index].color,
size: rectSize, size: rectSize,
@@ -43,25 +65,39 @@ export function createPreviewRects(square, canvas, width) {
y: width - layout.y * unit - rectSize, 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} x
* @param {number} y * @param {number} y
*/ */
export function hitTest(rects, x, y) { export function hitTest(geometry, x, y) {
for (let index = rects.length - 1; index >= 0; index -= 1) { if (x < 0 || y < 0 || x > geometry.width || y > geometry.width) {
const rect = rects[index]; return null;
}
if ( const column = Math.min(geometry.columns - 1, Math.floor(x / geometry.unit));
x >= rect.x && const rowFromTop = Math.min(
x <= rect.x + rect.size && geometry.columns - 1,
y >= rect.y && Math.floor(y / geometry.unit),
y <= rect.y + rect.size );
) { const row = geometry.columns - rowFromTop - 1;
return rect.transaction; 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; return null;
@@ -76,6 +112,15 @@ export function hitTest(rects, x, y) {
* @property {number} 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 * @typedef {Object} SquareLayout
* @property {number} columns * @property {number} columns
@@ -2,44 +2,83 @@ import { MAX_BLOCK_WEIGHT } from "../../format.js";
import { createPreviewFeeRange, orderTransactions } from "./fees.js"; import { createPreviewFeeRange, orderTransactions } from "./fees.js";
import { createSquareLayout } from "./capacity.js"; import { createSquareLayout } from "./capacity.js";
import { getCanvasFeeRateColor } from "./color.js"; import { getCanvasFeeRateColor } from "./color.js";
import { createPreviewRects, hitTest } from "./geometry.js"; import { createPreviewGeometry, hitTest } from "./geometry.js";
import { drawPreview } from "./draw.js"; import { drawPreview } from "./draw.js";
const COLUMNS = 84; const COLUMNS = 84;
const VISIBLE_CELLS = COLUMNS * COLUMNS; 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<number, string>} */ (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 {Object} [options]
* @param {(transaction: BlockPreviewTransaction | null, point: BlockPreviewPointer | null, eager: boolean) => void} [options.onInspect] * @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 canvas = document.createElement("canvas");
const context = /** @type {CanvasRenderingContext2D} */ ( const context = /** @type {CanvasRenderingContext2D} */ (
canvas.getContext("2d") canvas.getContext("2d")
); );
const ordered = orderTransactions(transactions); const order = orderTransactions(data.weights, data.feeRates);
const ranges = createPreviewFeeRange(ordered); const ranges = createPreviewFeeRange(data.feeRates, order);
const visible = ordered.slice(0, VISIBLE_CELLS); const cells = createCapacityCells(data, order, ranges);
const cells = visible.map((transaction) => ({
color: getCanvasFeeRateColor(transaction.feeRate, ranges),
transaction,
weight: transaction.weight,
}));
const square = createSquareLayout(cells, MAX_BLOCK_WEIGHT, COLUMNS); const square = createSquareLayout(cells, MAX_BLOCK_WEIGHT, COLUMNS);
let disabledMask = 0; let disabledMask = 0;
let filterState = /** @type {BlockPreviewFilterState | null} */ (null); let filterState = /** @type {BlockPreviewFilterState | null} */ (null);
let frame = 0; let frame = 0;
let inspected = /** @type {BlockPreviewTransaction | null} */ (null); 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 previewMask = /** @type {number | null} */ (null);
let rects = /** @type {PreviewRect[]} */ ([]); let geometry = /** @type {PreviewGeometry | null} */ (null);
let rectWidth = 0; let rectWidth = 0;
let capturedPointer = /** @type {number | null} */ (null); let capturedPointer = /** @type {number | null} */ (null);
canvas.dataset.blockPreviewHeatmap = ""; canvas.dataset.blockPreviewHeatmap = "";
function measure() {
bounds = canvas.getBoundingClientRect();
return bounds;
}
function draw() { function draw() {
const width = canvas.getBoundingClientRect().width; const width = measure().width;
if (width <= 0) return; if (width <= 0) return;
@@ -53,7 +92,7 @@ export function createBlockPreviewHeatmap(transactions, options = {}) {
if (rectWidth !== width) { if (rectWidth !== width) {
rectWidth = width; rectWidth = width;
rects = createPreviewRects(square, canvas, width); geometry = createPreviewGeometry(square, canvas, width);
} }
context.setTransform(dpr, 0, 0, dpr, 0, 0); context.setTransform(dpr, 0, 0, dpr, 0, 0);
@@ -64,7 +103,7 @@ export function createBlockPreviewHeatmap(transactions, options = {}) {
filterState, filterState,
inspected, inspected,
previewMask, previewMask,
rects, rects: geometry?.rects ?? [],
}); });
} }
@@ -73,6 +112,12 @@ export function createBlockPreviewHeatmap(transactions, options = {}) {
frame = requestAnimationFrame(draw); frame = requestAnimationFrame(draw);
} }
function cancelInspectFrame() {
cancelAnimationFrame(inspectFrame);
inspectFrame = 0;
inspectPoint = null;
}
/** @param {BlockPreviewTransaction | null} transaction */ /** @param {BlockPreviewTransaction | null} transaction */
function setInspected(transaction) { function setInspected(transaction) {
if (transaction === inspected) return; if (transaction === inspected) return;
@@ -82,46 +127,64 @@ export function createBlockPreviewHeatmap(transactions, options = {}) {
} }
/** /**
* @param {PointerEvent} event * @param {BlockPreviewPointer} point
* @param {boolean} eager * @param {boolean} eager
*/ */
function inspectAt(event, eager) { function inspectAt(point, eager) {
const bounds = canvas.getBoundingClientRect(); const nextBounds = bounds ?? measure();
if (geometry === null) draw();
if (geometry === null) return;
const transaction = hitTest( const transaction = hitTest(
rects, geometry,
event.clientX - bounds.left, point.clientX - nextBounds.left,
event.clientY - bounds.top, point.clientY - nextBounds.top,
); );
if (transaction === null && inspected !== null) { if (transaction === null && inspected !== null) {
options.onInspect?.( options.onInspect?.(inspected, point, eager);
inspected,
{ clientX: event.clientX, clientY: event.clientY },
eager,
);
return; return;
} }
setInspected(transaction); setInspected(transaction);
options.onInspect?.( options.onInspect?.(transaction, point, eager);
transaction, }
{ clientX: event.clientX, clientY: event.clientY },
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 */ /** @param {PointerEvent} event */
function startInspect(event) { function startInspect(event) {
capturedPointer = event.pointerId; capturedPointer = event.pointerId;
canvas.setPointerCapture(event.pointerId); canvas.setPointerCapture(event.pointerId);
inspectAt(event, true); cancelInspectFrame();
measure();
inspectAt(pointFromEvent(event), true);
if (event.pointerType !== "mouse") event.preventDefault(); if (event.pointerType !== "mouse") event.preventDefault();
} }
/** @param {PointerEvent} event */ /** @param {PointerEvent} event */
function moveInspect(event) { function moveInspect(event) {
if (capturedPointer !== null || event.pointerType === "mouse") { if (capturedPointer !== null || event.pointerType === "mouse") {
inspectAt(event, false); scheduleInspect(event);
if (capturedPointer !== null && event.pointerType !== "mouse") { if (capturedPointer !== null && event.pointerType !== "mouse") {
event.preventDefault(); event.preventDefault();
} }
@@ -138,6 +201,7 @@ export function createBlockPreviewHeatmap(transactions, options = {}) {
} }
function clearInspect() { function clearInspect() {
cancelInspectFrame();
setInspected(null); setInspected(null);
options.onInspect?.(null, null, false); options.onInspect?.(null, null, false);
} }
@@ -151,6 +215,7 @@ export function createBlockPreviewHeatmap(transactions, options = {}) {
const observer = new ResizeObserver(scheduleDraw); const observer = new ResizeObserver(scheduleDraw);
canvas.addEventListener("pointerenter", measure);
canvas.addEventListener("pointermove", moveInspect); canvas.addEventListener("pointermove", moveInspect);
canvas.addEventListener("pointerdown", startInspect); canvas.addEventListener("pointerdown", startInspect);
canvas.addEventListener("pointerup", stopInspect); canvas.addEventListener("pointerup", stopInspect);
@@ -170,9 +235,9 @@ export function createBlockPreviewHeatmap(transactions, options = {}) {
return /** @type {const} */ ({ return /** @type {const} */ ({
element: canvas, element: canvas,
ordered,
destroy() { destroy() {
cancelAnimationFrame(frame); cancelAnimationFrame(frame);
cancelInspectFrame();
document.removeEventListener("pointerdown", clearOnOutsidePointer); document.removeEventListener("pointerdown", clearOnOutsidePointer);
window.removeEventListener("blur", clearInspect); window.removeEventListener("blur", clearInspect);
observer.disconnect(); 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").BlockPreviewTransaction} BlockPreviewTransaction */
/** @typedef {import("../data.js").BlockPreviewFilterState} BlockPreviewFilterState */ /** @typedef {import("../data.js").BlockPreviewFilterState} BlockPreviewFilterState */
@@ -208,4 +274,4 @@ export function createBlockPreviewHeatmap(transactions, options = {}) {
* @property {number} clientY * @property {number} clientY
*/ */
/** @typedef {import("./geometry.js").PreviewRect} PreviewRect */ /** @typedef {import("./geometry.js").PreviewGeometry} PreviewGeometry */
@@ -1,41 +1,39 @@
/** /**
* @param {readonly PackCell[]} cells * @param {readonly PackCell[]} cells
* @param {number} columns * @param {number} columns
* @param {number} [rows] * @param {number} rows
* @returns {PackLayout[] | null} * @returns {PackLayout[] | null}
*/ */
export function packCells(cells, columns, rows) { export function packCells(cells, columns, rows) {
const occupied = /** @type {boolean[][]} */ ([]); const occupied = new Uint8Array(columns * rows);
const layouts = []; const layouts = [];
let usedRows = 0;
for (const cell of cells) { for (const cell of cells) {
const span = Math.min(cell.span, columns); 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; if (position === null) return null;
fillCells(occupied, position.x, position.y, span); fillCells(occupied, columns, position.x, position.y, span);
usedRows = Math.max(usedRows, position.y + span); layouts.push({ x: position.x, y: position.y, span });
layouts.push({ ...position, span, rows: rows ?? usedRows });
} }
return layouts; return layouts;
} }
/** /**
* @param {boolean[][]} occupied * @param {Uint8Array} occupied
* @param {number} columns * @param {number} columns
* @param {number} rows
* @param {number} span * @param {number} span
* @param {number} [rows]
* @returns {{ x: number, y: number } | null} * @returns {{ x: number, y: number } | null}
*/ */
function findPosition(occupied, columns, span, rows) { function findPosition(occupied, columns, rows, span) {
const lastRow = rows === undefined ? Infinity : rows - span; const lastRow = rows - span;
for (let y = 0; y <= lastRow; y += 1) { for (let y = 0; y <= lastRow; y += 1) {
for (let x = 0; x <= columns - span; x += 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} x
* @param {number} y * @param {number} y
* @param {number} span * @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) { for (let row = y; row < y + span; row += 1) {
const offset = row * columns;
for (let column = x; column < x + span; column += 1) { 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} x
* @param {number} y * @param {number} y
* @param {number} span * @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) { for (let row = y; row < y + span; row += 1) {
occupied[row] ??= []; const offset = row * columns;
for (let column = x; column < x + span; column += 1) { 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} x
* @property {number} y * @property {number} y
* @property {number} span * @property {number} span
* @property {number} rows
*/ */
+7 -7
View File
@@ -47,14 +47,14 @@ function memoize(load) {
} }
/** /**
* @param {BlockPreviewTransaction[]} transactions * @param {BlockPreviewData} data
* @param {() => Promise<BlockPreviewFilterState>} loadFilters * @param {() => Promise<BlockPreviewFilterState>} loadFilters
* @param {AbortSignal} signal * @param {AbortSignal} signal
*/ */
function createPreview(transactions, loadFilters, signal) { function createPreview(data, loadFilters, signal) {
const loadFilterState = memoize(loadFilters); const loadFilterState = memoize(loadFilters);
const inspector = createBlockPreviewInspector(signal, loadFilterState); const inspector = createBlockPreviewInspector(signal, loadFilterState);
const heatmap = createBlockPreviewHeatmap(transactions, { const heatmap = createBlockPreviewHeatmap(data, {
onInspect: inspector.inspect, onInspect: inspector.inspect,
}); });
const filters = createPreviewFilters(loadFilterState, heatmap); const filters = createPreviewFilters(loadFilterState, heatmap);
@@ -94,11 +94,11 @@ export function createBlockPreviewPane(block) {
renderStatus(content, "Loading"); renderStatus(content, "Loading");
void loadBlockPreview(block, controller.signal) void loadBlockPreview(block, controller.signal)
.then(({ range, transactions }) => { .then((data) => {
if (!live) return; if (!live) return;
const preview = createPreview( const preview = createPreview(
transactions, data,
() => loadBlockPreviewFilters(range, controller.signal), () => loadBlockPreviewFilters(data.range, controller.signal),
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 */ /** @typedef {import("./data.js").BlockPreviewFilterState} BlockPreviewFilterState */
/** /**
@@ -50,6 +50,7 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) {
const txidLoader = createTxidLoader(parentSignal); const txidLoader = createTxidLoader(parentSignal);
let inspected = /** @type {BlockPreviewTransaction | null} */ (null); let inspected = /** @type {BlockPreviewTransaction | null} */ (null);
let point = /** @type {BlockPreviewPointer | null} */ (null); let point = /** @type {BlockPreviewPointer | null} */ (null);
let readoutSize = /** @type {BlockPreviewReadoutSize | null} */ (null);
let filterState = /** @type {BlockPreviewFilterState | null} */ (null); let filterState = /** @type {BlockPreviewFilterState | null} */ (null);
let filterPromise = /** @type {Promise<void> | null} */ (null); let filterPromise = /** @type {Promise<void> | null} */ (null);
let traitsTimer = 0; let traitsTimer = 0;
@@ -62,7 +63,24 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) {
txidLoader.abort(); 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() { function setTraitsLoading() {
invalidateReadoutSize();
for (const { value } of traitFields) { for (const { value } of traitFields) {
value.removeAttribute("title"); value.removeAttribute("title");
setField(value, "loading", true); setField(value, "loading", true);
@@ -71,6 +89,8 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) {
/** @param {number} mask */ /** @param {number} mask */
function setTraits(mask) { function setTraits(mask) {
invalidateReadoutSize();
for (const { filters, value } of traitFields) { for (const { filters, value } of traitFields) {
const labels = filters const labels = filters
.filter(({ bit }) => mask & bit) .filter(({ bit }) => mask & bit)
@@ -88,7 +108,7 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) {
const mask = state.masks[transaction.txIndex - state.start]; const mask = state.masks[transaction.txIndex - state.start];
setTraits(mask); setTraits(mask);
if (point !== null) placeReadout(element, point); if (point !== null) place(point);
} }
function loadFilterState() { function loadFilterState() {
@@ -141,12 +161,15 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) {
element.hidden = false; element.hidden = false;
point = nextPoint; point = nextPoint;
placeReadout(element, nextPoint);
if (transaction === inspected) return; if (transaction === inspected) {
place(nextPoint);
return;
}
abortPending(); abortPending();
inspected = transaction; inspected = transaction;
invalidateReadoutSize();
setField(tx, `#${formatNumber(transaction.txIndex)}`); setField(tx, `#${formatNumber(transaction.txIndex)}`);
setField(fee, `${formatFeeRate(transaction.feeRate)} sat/vB`); setField(fee, `${formatFeeRate(transaction.feeRate)} sat/vB`);
@@ -159,8 +182,10 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) {
(loadedTxid) => { (loadedTxid) => {
if (inspected !== transaction) return; if (inspected !== transaction) return;
invalidateReadoutSize();
setField(txid, loadedTxid); setField(txid, loadedTxid);
txid.title = loadedTxid; txid.title = loadedTxid;
if (point !== null) place(point);
}, },
(error, signal) => { (error, signal) => {
if (signal.aborted) return; if (signal.aborted) return;
@@ -173,6 +198,8 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) {
txid.removeAttribute("title"); txid.removeAttribute("title");
setField(txid, "loading", true); setField(txid, "loading", true);
} }
place(nextPoint);
} }
return /** @type {const} */ ({ return /** @type {const} */ ({
@@ -190,3 +217,9 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) {
* @property {number} clientX * @property {number} clientX
* @property {number} clientY * @property {number} clientY
*/ */
/**
* @typedef {Object} BlockPreviewReadoutSize
* @property {number} width
* @property {number} height
*/
@@ -3,12 +3,12 @@ const OVERLAY_MARGIN = 8;
/** /**
* @param {HTMLElement} element * @param {HTMLElement} element
* @param {BlockPreviewPointer} point * @param {BlockPreviewPointer} point
* @param {BlockPreviewReadoutSize} size
*/ */
export function placeReadout(element, point) { export function placeReadout(element, point, size) {
const figure = /** @type {HTMLElement} */ (element.parentElement); const figure = /** @type {HTMLElement} */ (element.parentElement);
const bounds = figure.getBoundingClientRect(); const bounds = figure.getBoundingClientRect();
const width = element.offsetWidth; const { width, height } = size;
const height = element.offsetHeight;
const minX = Math.min(bounds.width / 2, width / 2 + OVERLAY_MARGIN); const minX = Math.min(bounds.width / 2, width / 2 + OVERLAY_MARGIN);
const maxX = Math.max(minX, bounds.width - minX); const maxX = Math.max(minX, bounds.width - minX);
const rawX = point.clientX - bounds.left; const rawX = point.clientX - bounds.left;
@@ -31,3 +31,9 @@ export function placeReadout(element, point) {
* @property {number} clientX * @property {number} clientX
* @property {number} clientY * @property {number} clientY
*/ */
/**
* @typedef {Object} BlockPreviewReadoutSize
* @property {number} width
* @property {number} height
*/