mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-11 19:18:14 -07:00
website_next: part 12
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<number, number>} */ ({}));
|
||||
|
||||
/**
|
||||
* @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
|
||||
|
||||
@@ -50,18 +50,22 @@ const FILTER_BITS = /** @type {Map<string, number>} */ (
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,16 @@ function weightToSpan(weight, capacity, columns) {
|
||||
* @param {readonly Cell[]} cells
|
||||
* @param {number} capacity
|
||||
* @param {number} columns
|
||||
* @returns {ResolvedCapacityCell<Cell>[]}
|
||||
*/
|
||||
function resolveCapacityCells(cells, capacity, columns) {
|
||||
return cells.map((cell) => ({
|
||||
...cell,
|
||||
span: weightToSpan(cell.weight, capacity, columns),
|
||||
}));
|
||||
const resolved = /** @type {ResolvedCapacityCell<Cell>[]} */ (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
|
||||
*/
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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 {(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 */
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -47,14 +47,14 @@ function memoize(load) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BlockPreviewTransaction[]} transactions
|
||||
* @param {BlockPreviewData} data
|
||||
* @param {() => Promise<BlockPreviewFilterState>} 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 */
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void> | 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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user