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