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
@@ -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
*/