website_next: part 11

This commit is contained in:
nym21
2026-07-09 23:08:47 +02:00
parent 5c376acb9f
commit e279116bff
12 changed files with 430 additions and 279 deletions
+2 -2
View File
@@ -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 */
+4 -1
View File
@@ -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;
};
+68 -112
View File
@@ -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 */