website_next: part 9

This commit is contained in:
nym21
2026-07-08 11:24:52 +02:00
parent 15fbdc168f
commit a43b00c12c
48 changed files with 1756 additions and 1042 deletions
+217 -23
View File
@@ -1,43 +1,237 @@
import { brk } from "../../../utils/client.js";
import { FILTERS, getFilterBit, getTypeBit } from "./filters/model.js";
const VERSION_BITS = new Map([
[1, getFilterBit("version:1")],
[2, getFilterBit("version:2")],
[3, getFilterBit("version:3")],
]);
const RBF_YES_BIT = getFilterBit("rbf:yes");
const RBF_NO_BIT = getFilterBit("rbf:no");
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");
/**
* @template T
* @param {{
* slice: (
* start: number,
* end: number,
* ) => {
* fetch: (
* options: { signal: AbortSignal, memCache: false },
* ) => Promise<{ data: T[] }>,
* },
* }} series
* @param {number} start
* @param {number} end
* @param {AbortSignal} signal
* @returns {Promise<{ data: T[] }>}
*/
function fetchSeriesSlice(series, start, end, signal) {
return series.slice(start, end).fetch({ signal, memCache: false });
}
/**
* @param {import("../../../modules/brk-client/index.js").BlockInfoV1} block
* @param {AbortSignal} signal
*/
export async function loadBlockPreview(block) {
async function loadBlockPreviewRange(block, signal) {
const firstTxIndex = (
await brk.series.transactions.raw.firstTxIndex.by.height
.get(block.height)
.fetch()
await fetchSeriesSlice(
brk.series.transactions.raw.firstTxIndex.by.height,
block.height,
block.height + 1,
signal,
)
).data[0];
signal.throwIfAborted();
const start = /** @type {number} */ (firstTxIndex);
const end = start + block.txCount;
return { start, end };
}
/**
* @param {import("../../../modules/brk-client/index.js").BlockInfoV1} block
* @param {AbortSignal} signal
*/
export async function loadBlockPreview(block, signal) {
const { start, end } = await loadBlockPreviewRange(block, signal);
const tx = brk.series.transactions;
const [weights, feeRates] = await Promise.all([
fetchSeriesSlice(tx.size.weight.txIndex.by.tx_index, start, end, signal),
fetchSeriesSlice(tx.fees.feeRate.by.tx_index, start, end, signal),
]);
signal.throwIfAborted();
return {
range: { start, end },
transactions: weights.data.map((weight, index) => ({
txIndex: start + index,
weight: /** @type {number} */ (weight),
feeRate: /** @type {number} */ (feeRates.data[index]),
})),
};
}
/**
* @param {number} txIndex
* @param {AbortSignal} signal
*/
export async function loadBlockPreviewTxid(txIndex, signal) {
const txid = (
await brk.series.transactions.raw.txid.by.tx_index
.get(txIndex)
.fetch({ signal, memCache: false })
).data[0];
signal.throwIfAborted();
return /** @type {string} */ (txid);
}
/**
* @param {number[]} starts
* @param {number[]} counts
*/
function rangeEnd(starts, counts) {
const last = starts.length - 1;
return starts[last] + counts[last];
}
/**
* @param {Uint32Array} counts
* @param {number} mask
*/
function countMask(counts, mask) {
for (const { bit, index } of FILTERS) {
if (mask & bit) counts[index] += 1;
}
}
/**
* @param {readonly string[]} types
* @param {number} start
* @param {number} end
*/
function getTypesMask(types, start, end) {
let mask = 0;
for (let index = start; index < end; index += 1) {
mask |= getTypeBit(types[index]);
}
return mask;
}
/**
* @param {BlockPreviewRange} range
* @param {AbortSignal} signal
*/
export async function loadBlockPreviewFilters(range, signal) {
const { start, end } = range;
const tx = brk.series.transactions;
const indexes = brk.series.indexes.txIndex;
const [
txids,
versions,
weights,
feeRates,
rbfs,
inputCounts,
outputCounts,
firstInputs,
firstOutputs,
] = await Promise.all([
tx.raw.txid.by.tx_index.slice(start, end).fetch(),
tx.raw.txVersion.by.tx_index.slice(start, end).fetch(),
tx.size.weight.txIndex.by.tx_index.slice(start, end).fetch(),
tx.fees.feeRate.by.tx_index.slice(start, end).fetch(),
tx.raw.isExplicitlyRbf.by.tx_index.slice(start, end).fetch(),
indexes.inputCount.by.tx_index.slice(start, end).fetch(),
indexes.outputCount.by.tx_index.slice(start, end).fetch(),
fetchSeriesSlice(tx.raw.txVersion.by.tx_index, start, end, signal),
fetchSeriesSlice(tx.raw.isExplicitlyRbf.by.tx_index, start, end, signal),
fetchSeriesSlice(indexes.inputCount.by.tx_index, start, end, signal),
fetchSeriesSlice(indexes.outputCount.by.tx_index, start, end, signal),
fetchSeriesSlice(tx.raw.firstTxinIndex.by.tx_index, start, end, signal),
fetchSeriesSlice(tx.raw.firstTxoutIndex.by.tx_index, start, end, signal),
]);
return txids.data.map((txid, index) => ({
txid,
version: versions.data[index],
weight: weights.data[index],
feeRate: feeRates.data[index],
rbf: rbfs.data[index],
inputCount: inputCounts.data[index],
outputCount: outputCounts.data[index],
}));
signal.throwIfAborted();
const inputStart = /** @type {number} */ (firstInputs.data[0]);
const outputStart = /** @type {number} */ (firstOutputs.data[0]);
const [inputTypes, outputTypes] = await Promise.all([
fetchSeriesSlice(
brk.series.inputs.raw.outputType.by.txin_index,
inputStart,
rangeEnd(
/** @type {number[]} */ (firstInputs.data),
/** @type {number[]} */ (inputCounts.data),
),
signal,
),
fetchSeriesSlice(
brk.series.outputs.raw.outputType.by.txout_index,
outputStart,
rangeEnd(
/** @type {number[]} */ (firstOutputs.data),
/** @type {number[]} */ (outputCounts.data),
),
signal,
),
]);
signal.throwIfAborted();
const masks = new Uint32Array(end - start);
const counts = new Uint32Array(FILTERS.length);
versions.data.forEach((version, index) => {
const inputCount = /** @type {number} */ (inputCounts.data[index]);
const outputCount = /** @type {number} */ (outputCounts.data[index]);
const inputTypeStart = /** @type {number} */ (firstInputs.data[index]);
const inputTypeEnd = inputTypeStart + inputCount;
const outputTypeStart = /** @type {number} */ (firstOutputs.data[index]);
const outputTypeEnd = outputTypeStart + outputCount;
const mask =
(VERSION_BITS.get(/** @type {number} */ (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) |
getTypesMask(
/** @type {string[]} */ (inputTypes.data),
inputTypeStart - inputStart,
inputTypeEnd - inputStart,
) |
getTypesMask(
/** @type {string[]} */ (outputTypes.data),
outputTypeStart - outputStart,
outputTypeEnd - outputStart,
);
masks[index] = mask;
countMask(counts, mask);
});
return { counts, masks, start };
}
/**
* @typedef {Object} BlockPreviewRange
* @property {number} start
* @property {number} end
*/
/**
* @typedef {Object} BlockPreviewTransaction
* @property {number} txIndex
* @property {number} weight
* @property {number} feeRate
*/
/**
* @typedef {Object} BlockPreviewFilterState
* @property {number} start
* @property {Uint32Array} masks
* @property {Uint32Array} counts
*/
@@ -1,195 +0,0 @@
import {
appendLegendListItem,
createLegendItem,
createLegendList,
} from "../../../legend/index.js";
import { formatNumber } from "../format.js";
/** @param {BlockPreviewTransaction} tx */
function isVersion1(tx) {
return tx.version === 1;
}
/** @param {BlockPreviewTransaction} tx */
function isVersion2(tx) {
return tx.version === 2;
}
/** @param {BlockPreviewTransaction} tx */
function isVersion3(tx) {
return tx.version === 3;
}
/** @param {BlockPreviewTransaction} tx */
function isRbf(tx) {
return tx.rbf;
}
/** @param {BlockPreviewTransaction} tx */
function isNotRbf(tx) {
return !tx.rbf;
}
/** @param {BlockPreviewTransaction} tx */
function hasOneInput(tx) {
return tx.inputCount === 1;
}
/** @param {BlockPreviewTransaction} tx */
function hasManyInputs(tx) {
return tx.inputCount !== 1;
}
/** @param {BlockPreviewTransaction} tx */
function hasOneOutput(tx) {
return tx.outputCount === 1;
}
/** @param {BlockPreviewTransaction} tx */
function hasManyOutputs(tx) {
return tx.outputCount !== 1;
}
const FILTERS = /** @type {const} */ ([
{ group: "version", label: "tx v1", key: "version:1", match: isVersion1 },
{ group: "version", label: "tx v2", key: "version:2", match: isVersion2 },
{ group: "version", label: "tx v3", key: "version:3", match: isVersion3 },
{ group: "rbf", label: "rbf", key: "rbf:yes", match: isRbf },
{ group: "rbf", label: "no rbf", key: "rbf:no", match: isNotRbf },
{ group: "input", label: "1 in", key: "input:one", match: hasOneInput },
{ group: "input", label: "multi in", key: "input:multi", match: hasManyInputs },
{ group: "output", label: "1 out", key: "output:one", match: hasOneOutput },
{ group: "output", label: "multi out", key: "output:multi", match: hasManyOutputs },
]);
const FILTER_GROUPS = /** @type {const} */ ([
"version",
"rbf",
"input",
"output",
]);
/** @param {number} version */
export function getVersionKey(version) {
return `version:${version}`;
}
/**
* @param {BlockPreviewTransaction} transaction
*/
export function getFilterKeys(transaction) {
return FILTERS
.filter(({ match }) => match(transaction))
.map(({ key }) => key);
}
/**
* @param {BlockPreviewTransaction[]} transactions
*/
function countFilters(transactions) {
return new Map(FILTERS.map(({ key, match }) => {
return [key, transactions.filter(match).length];
}));
}
function createActiveFilters() {
return new Map(FILTER_GROUPS.map((group) => {
const keys = FILTERS
.filter((filter) => filter.group === group)
.map(({ key }) => String(key));
return [group, new Set(keys)];
}));
}
/**
* @param {string[]} keys
* @param {Map<string, Set<string>>} activeFilters
*/
function matchesActiveFilters(keys, activeFilters) {
for (const group of FILTER_GROUPS) {
const activeKeys = activeFilters.get(group);
if ([...activeKeys ?? []].some((key) => keys.includes(key))) continue;
return false;
}
return true;
}
/**
* @param {HTMLButtonElement} button
* @param {boolean} active
*/
function setActive(button, active) {
button.setAttribute("aria-pressed", String(active));
button.toggleAttribute("data-muted", !active);
}
/**
* @param {HTMLButtonElement} button
*/
function setPending(button) {
button.disabled = true;
button.setAttribute("aria-pressed", "false");
button.removeAttribute("data-muted");
}
/**
* @param {BlockPreviewTransaction[]} transactions
* @param {HTMLElement | null} heatmap
* @param {Object} [options]
* @param {boolean} [options.pending]
*/
export function createPreviewFilters(transactions, heatmap, options = {}) {
const list = createLegendList({ scroll: true });
const counts = countFilters(transactions);
const pending = options.pending === true;
const activeFilters = createActiveFilters();
function updateCells() {
const cells = /** @type {HTMLElement[]} */ ([
...heatmap?.querySelectorAll("[data-heatmap-cell]") ?? [],
]);
for (const cell of cells) {
const keys = cell.dataset.heatmapGroups?.split(" ") ?? [];
const active = matchesActiveFilters(keys, activeFilters);
cell.toggleAttribute("data-muted", !active);
}
}
for (const filter of FILTERS) {
const count = counts.get(filter.key) ?? 0;
const { button, value } = createLegendItem({
label: filter.label,
color: "var(--white)",
ariaLabel: filter.label,
});
value.textContent = pending ? "..." : formatNumber(count);
if (pending) {
setPending(button);
} else {
button.addEventListener("click", () => {
const active = button.getAttribute("aria-pressed") !== "true";
const activeKeys = /** @type {Set<string>} */ (
activeFilters.get(filter.group)
);
setActive(button, active);
if (active) activeKeys.add(filter.key);
else activeKeys.delete(filter.key);
updateCells();
});
setActive(button, true);
}
appendLegendListItem(list, button);
}
return list;
}
/** @typedef {import("./fees.js").BlockPreviewTransaction} BlockPreviewTransaction */
@@ -0,0 +1,235 @@
import { createLegendItem } from "../../../../legend/index.js";
import { formatNumber } from "../../format.js";
import { FILTER_GROUP_LABELS, FILTER_GROUPS, FILTERS } from "./model.js";
const SUMMARY_LABEL_COUNT = 3;
const CAN_HOVER = matchMedia("(hover: hover) and (pointer: fine)");
/**
* @param {HTMLButtonElement} button
* @param {boolean} active
*/
function setActive(button, active) {
button.setAttribute("aria-pressed", String(active));
button.toggleAttribute("data-muted", !active);
}
/**
* @param {HTMLButtonElement} button
*/
function setPending(button) {
button.disabled = true;
button.setAttribute("aria-pressed", "false");
button.removeAttribute("data-muted");
}
/**
* @param {string[]} labels
*/
function formatSummaryValue(labels) {
const visible = labels.slice(0, SUMMARY_LABEL_COUNT);
const hidden = labels.length - visible.length;
const suffix = hidden > 0 ? ` ... +${hidden}` : "";
return `${visible.join(", ")}${suffix}`;
}
/**
* @param {number} disabledMask
* @param {HTMLElement} value
*/
function updateSummaryValue(disabledMask, value) {
const labels = FILTERS
.filter(({ bit }) => disabledMask & bit)
.map(({ group, label }) => {
return `${FILTER_GROUP_LABELS.get(group)} ${label}`;
});
value.textContent = labels.length > 0
? `hidden ${formatSummaryValue(labels)}`
: "NONE";
}
/**
* @param {string} label
*/
function createFilterGroup(label) {
const group = document.createElement("div");
const title = document.createElement("span");
title.textContent = label;
group.append(title);
return group;
}
/**
* @param {HTMLElement} panel
* @param {HTMLElement} summary
*/
function clearGroups(panel, summary) {
panel.replaceChildren(summary);
}
function createFilterPanel() {
const panel = document.createElement("details");
const summary = document.createElement("summary");
const prefix = document.createElement("span");
const value = document.createElement("span");
prefix.textContent = "filters:";
panel.dataset.blockPreviewFilters = "";
summary.append(prefix, value);
panel.append(summary);
return { panel, summary, summaryValue: value };
}
export function createPendingPreviewFilters() {
const { panel, summaryValue } = createFilterPanel();
summaryValue.textContent = "loading";
return panel;
}
/**
* @param {() => Promise<BlockPreviewFilterState>} loadFilters
* @param {BlockPreviewHeatmap} heatmap
*/
export function createPreviewFilters(loadFilters, heatmap) {
const { panel, summary, summaryValue } = createFilterPanel();
let disabledMask = 0;
let live = true;
let loading = false;
let state = /** @type {BlockPreviewFilterState | null} */ (null);
let previewButton = /** @type {HTMLButtonElement | null} */ (null);
updateSummaryValue(disabledMask, summaryValue);
function resetPreview() {
previewButton?.removeAttribute("data-preview");
previewButton = null;
heatmap.previewDisabledMask(null);
}
/**
* @param {HTMLButtonElement} button
* @param {number} nextMask
*/
function preview(button, nextMask) {
resetPreview();
previewButton = button;
button.dataset.preview = "";
heatmap.previewDisabledMask(nextMask);
}
/**
* @param {BlockPreviewFilterState | null} filterState
*/
function renderFilters(filterState) {
clearGroups(panel, summary);
for (const { key, label } of FILTER_GROUPS) {
const group = createFilterGroup(label);
for (const filter of FILTERS.filter((item) => item.group === key)) {
const { button, value } = createLegendItem({
ariaLabel: filter.label,
color: filter.color,
label: filter.label,
});
value.textContent = filterState === null
? "..."
: formatNumber(filterState.counts[filter.index]);
if (filterState === null) {
setPending(button);
} else {
const canPreview = CAN_HOVER.matches;
button.addEventListener("click", () => {
const active = (disabledMask & filter.bit) === 0;
resetPreview();
disabledMask = active
? disabledMask | filter.bit
: disabledMask & ~filter.bit;
setActive(button, !active);
updateSummaryValue(disabledMask, summaryValue);
heatmap.setDisabledMask(disabledMask);
});
if (canPreview) {
button.addEventListener("pointerenter", () => {
const active = (disabledMask & filter.bit) === 0;
const nextMask = active
? disabledMask | filter.bit
: disabledMask & ~filter.bit;
preview(button, nextMask);
});
button.addEventListener("pointerleave", resetPreview);
}
button.addEventListener("focus", () => {
const active = (disabledMask & filter.bit) === 0;
const nextMask = active
? disabledMask | filter.bit
: disabledMask & ~filter.bit;
preview(button, nextMask);
});
button.addEventListener("blur", resetPreview);
setActive(button, (disabledMask & filter.bit) === 0);
}
group.append(button);
}
panel.append(group);
}
}
function load() {
if (loading || state !== null) return;
loading = true;
summaryValue.textContent = "loading";
renderFilters(null);
void loadFilters()
.then((nextState) => {
if (!live) return;
loading = false;
state = nextState;
heatmap.setFilterState(nextState);
updateSummaryValue(disabledMask, summaryValue);
renderFilters(nextState);
})
.catch((error) => {
if (!live) return;
loading = false;
summaryValue.textContent = "unavailable";
clearGroups(panel, summary);
console.error(error);
});
}
panel.addEventListener("toggle", () => {
if (panel.open) load();
});
return /** @type {const} */ ({
destroy() {
live = false;
resetPreview();
},
element: panel,
});
}
/** @typedef {import("../data.js").BlockPreviewFilterState} BlockPreviewFilterState */
/** @typedef {ReturnType<import("../heatmap/index.js").createBlockPreviewHeatmap>} BlockPreviewHeatmap */
@@ -0,0 +1,74 @@
import { txColors } from "../../../../utils/colors.js";
export const FILTER_GROUPS = /** @type {const} */ ([
{ key: "version", label: "version" },
{ key: "rbf", label: "rbf" },
{ key: "input", label: "input" },
{ key: "output", label: "output" },
{ key: "type", label: "type" },
]);
const FILTER_DEFS = /** @type {const} */ ([
["version", "v1", "version:1", txColors.v1],
["version", "v2", "version:2", txColors.v2],
["version", "v3", "version:3", txColors.v3],
["rbf", "yes", "rbf:yes", txColors.rbf],
["rbf", "no", "rbf:no", txColors.noRbf],
["input", "1", "input:one", txColors.oneInput],
["input", "multi", "input:multi", txColors.multiInput],
["output", "1", "output:one", txColors.oneOutput],
["output", "multi", "output:multi", txColors.multiOutput],
["type", "p2pk", "type:p2pk", txColors.p2pk],
["type", "p2pkh", "type:p2pkh", txColors.p2pkh],
["type", "p2sh", "type:p2sh", txColors.p2sh],
["type", "p2wpkh", "type:p2wpkh", txColors.p2wpkh],
["type", "p2wsh", "type:p2wsh", txColors.p2wsh],
["type", "taproot", "type:taproot", txColors.taproot],
["type", "p2a", "type:p2a", txColors.p2a],
["type", "baremult", "type:multisig", txColors.baremult],
["type", "op ret", "type:op_return", txColors.opReturn],
["type", "empty", "type:empty", txColors.empty],
["type", "unknown", "type:unknown", txColors.unknown],
]);
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_LABELS = new Map(FILTER_GROUPS.map(({ key, label }) => {
return [key, label];
}));
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",
});
/**
* @param {string} key
*/
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);
}
@@ -0,0 +1,78 @@
[data-block-preview-filters] {
display: grid;
gap: 0.75rem;
min-width: 0;
> summary {
display: flex;
max-width: 100%;
color: var(--white);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-strong);
line-height: var(--line-height-xs);
list-style: none;
text-transform: uppercase;
cursor: pointer;
> span {
white-space: nowrap;
}
> span:first-child {
flex: 0 0 auto;
padding-right: 0.5rem;
color: var(--gray);
}
> span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
> summary::-webkit-details-marker {
display: none;
}
&[open] > summary {
padding-bottom: 0.25rem;
}
&:not([open]) > div {
display: none;
}
> div {
display: flex;
flex-wrap: wrap;
gap: 0;
align-items: baseline;
min-width: 0;
> span {
flex: 0 0 auto;
color: var(--gray);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-xs);
padding-right: 0.5rem;
text-transform: uppercase;
white-space: nowrap;
}
> [data-legend-item] {
display: flex;
gap: 0.5rem;
align-items: baseline;
min-width: max-content;
> [data-legend-value] {
width: auto;
min-height: 0;
margin-top: 0;
margin-left: 0;
}
}
}
}
@@ -0,0 +1,75 @@
import { packCells } from "./pack.js";
/**
* @param {number} weight
* @param {number} capacity
* @param {number} columns
*/
function weightToSpan(weight, capacity, columns) {
const cellWeight = capacity / (columns * columns);
return Math.max(1, Math.round(Math.sqrt(weight / cellWeight)));
}
/**
* @template {CapacityCell} Cell
* @param {readonly Cell[]} cells
* @param {number} capacity
* @param {number} columns
*/
function resolveCapacityCells(cells, capacity, columns) {
return cells.map((cell) => ({
...cell,
span: weightToSpan(cell.weight, capacity, columns),
}));
}
/**
* @template {CapacityCell} Cell
* @param {readonly Cell[]} cells
* @param {number} capacity
* @param {number} columns
*/
function fitCapacityCells(cells, capacity, columns) {
let resolvedCells = resolveCapacityCells(cells, capacity, columns);
let layouts = packCells(resolvedCells, columns, columns);
while (layouts === null) {
const largest = Math.max(...resolvedCells.map(({ span }) => span));
if (largest <= 1) break;
resolvedCells = resolvedCells.map((cell) => ({
...cell,
span: cell.span === largest ? largest - 1 : cell.span,
}));
layouts = packCells(resolvedCells, columns, columns);
}
if (layouts === null) {
resolvedCells = resolvedCells.slice(0, columns * columns);
layouts = /** @type {NonNullable<typeof layouts>} */ (
packCells(resolvedCells, columns, columns)
);
}
return {
layouts: /** @type {NonNullable<typeof layouts>} */ (layouts),
resolvedCells,
};
}
/**
* @template {CapacityCell} Cell
* @param {readonly Cell[]} cells
* @param {number} capacity
* @param {number} columns
*/
export function createSquareLayout(cells, capacity, columns) {
return { columns, ...fitCapacityCells(cells, capacity, columns) };
}
/**
* @typedef {Object} CapacityCell
* @property {number} weight
*/
@@ -0,0 +1,120 @@
import { FEE_RATE_STOPS } from "../../fee-rates.js";
let stops = /** @type {OklabColor[] | null} */ (null);
/** @param {number} value */
const clamp01 = (value) => Math.min(1, Math.max(0, value));
/**
* @param {string} css
*/
function parseOklch(css) {
const [, rawLightness, percent, rawChroma, rawHue] = /** @type {RegExpMatchArray} */ (
css.match(/oklch\(\s*([\d.]+)(%)?\s+([\d.]+)\s+([\d.]+)/)
);
const lightness = Number(rawLightness);
const hue = Number(rawHue) * Math.PI / 180;
const chroma = Number(rawChroma);
return {
a: chroma * Math.cos(hue),
b: chroma * Math.sin(hue),
l: percent ? lightness / 100 : lightness,
};
}
/**
* @param {string} color
*/
function resolveColor(color) {
const variable = color.match(/var\((--[^)]+)\)/)?.[1];
return variable === undefined
? color
: getComputedStyle(document.documentElement).getPropertyValue(variable);
}
function getStops() {
stops ??= FEE_RATE_STOPS.map(({ color }) => parseOklch(resolveColor(color)));
return stops;
}
/**
* @param {string} color
*/
export function getCanvasColor(color) {
return `rgb(${toRgb(parseOklch(resolveColor(color))).join(" ")})`;
}
/**
* @param {number} linear
*/
function encodeRgb(linear) {
const value = linear <= 0.0031308
? 12.92 * linear
: 1.055 * linear ** (1 / 2.4) - 0.055;
return Math.round(clamp01(value) * 255);
}
/**
* @param {OklabColor} color
*/
function toRgb(color) {
const l = color.l + 0.3963377774 * color.a + 0.2158037573 * color.b;
const m = color.l - 0.1055613458 * color.a - 0.0638541728 * color.b;
const s = color.l - 0.0894841775 * color.a - 1.2914855480 * color.b;
const l3 = l ** 3;
const m3 = m ** 3;
const s3 = s ** 3;
return [
encodeRgb(+4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3),
encodeRgb(-1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3),
encodeRgb(-0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3),
];
}
/**
* @param {OklabColor} start
* @param {OklabColor} end
* @param {number} ratio
*/
function mix(start, end, ratio) {
return {
a: start.a + (end.a - start.a) * ratio,
b: start.b + (end.b - start.b) * ratio,
l: start.l + (end.l - start.l) * ratio,
};
}
/**
* @param {number} feeRate
* @param {number[]} ranges
*/
export function getCanvasFeeRateColor(feeRate, ranges) {
const colors = getStops();
if (feeRate <= ranges[0]) return `rgb(${toRgb(colors[0]).join(" ")})`;
for (let index = 1; index < ranges.length; index += 1) {
if (feeRate > ranges[index]) continue;
const previousRate = ranges[index - 1];
const nextRate = ranges[index];
const span = nextRate - previousRate;
const ratio = span ? (feeRate - previousRate) / span : 0;
return `rgb(${toRgb(mix(colors[index - 1], colors[index], ratio)).join(" ")})`;
}
return `rgb(${toRgb(colors[colors.length - 1]).join(" ")})`;
}
/**
* @typedef {Object} OklabColor
* @property {number} a
* @property {number} b
* @property {number} l
*/
@@ -1,4 +1,4 @@
import { createFeeRateRange } from "../fee-rates.js";
import { createFeeRateRange } from "../../fee-rates.js";
/**
* @param {BlockPreviewTransaction[]} transactions
@@ -15,13 +15,4 @@ export function orderTransactions(transactions) {
.toSorted((a, b) => b.feeRate - a.feeRate || b.weight - a.weight);
}
/**
* @typedef {Object} BlockPreviewTransaction
* @property {string} txid
* @property {number} version
* @property {number} weight
* @property {number} feeRate
* @property {boolean} rbf
* @property {number} inputCount
* @property {number} outputCount
*/
/** @typedef {import("../data.js").BlockPreviewTransaction} BlockPreviewTransaction */
@@ -0,0 +1,328 @@
import { MAX_BLOCK_WEIGHT } from "../../format.js";
import { createPreviewFeeRange, orderTransactions } from "./fees.js";
import { createSquareLayout } from "./capacity.js";
import { getCanvasColor, getCanvasFeeRateColor } from "./color.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;
}
/**
* @param {BlockPreviewTransaction[]} transactions
* @param {Object} [options]
* @param {(transaction: BlockPreviewTransaction | null, point: BlockPreviewPointer | null, eager: boolean) => void} [options.onInspect]
*/
export function createBlockPreviewHeatmap(transactions, options = {}) {
const canvas = document.createElement("canvas");
const context = /** @type {CanvasRenderingContext2D} */ (
canvas.getContext("2d")
);
const ordered = orderTransactions(transactions);
const ranges = createPreviewFeeRange(ordered);
const cells = ordered.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 capturedPointer = /** @type {number | null} */ (null);
canvas.dataset.blockPreviewHeatmap = "";
function draw() {
const width = canvas.getBoundingClientRect().width;
if (width <= 0) return;
const dpr = globalThis.devicePixelRatio || 1;
const size = Math.round(width * dpr);
if (canvas.width !== size || canvas.height !== size) {
canvas.width = size;
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,
};
});
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 = mask & activeMask ? MUTED_ALPHA : 1;
drawRect(context, alpha, rect.color, rect);
if (rect.transaction === inspected) inspectedRect = rect;
}
if (inspectedRect !== null) drawHover(context, inspectedRect, colors);
context.globalAlpha = 1;
}
function scheduleDraw() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(draw);
}
/** @param {BlockPreviewTransaction | null} transaction */
function setInspected(transaction) {
if (transaction === inspected) return;
inspected = transaction;
scheduleDraw();
}
/**
* @param {PointerEvent} event
* @param {boolean} eager
*/
function inspectAt(event, eager) {
const bounds = canvas.getBoundingClientRect();
const transaction = hitTest(
rects,
event.clientX - bounds.left,
event.clientY - bounds.top,
);
if (transaction === null && inspected !== null) {
options.onInspect?.(
inspected,
{ clientX: event.clientX, clientY: event.clientY },
eager,
);
return;
}
setInspected(transaction);
options.onInspect?.(
transaction,
{ clientX: event.clientX, clientY: event.clientY },
eager,
);
}
/** @param {PointerEvent} event */
function startInspect(event) {
capturedPointer = event.pointerId;
canvas.setPointerCapture(event.pointerId);
inspectAt(event, true);
if (event.pointerType !== "mouse") event.preventDefault();
}
/** @param {PointerEvent} event */
function moveInspect(event) {
if (capturedPointer !== null || event.pointerType === "mouse") {
inspectAt(event, false);
if (capturedPointer !== null && event.pointerType !== "mouse") {
event.preventDefault();
}
}
}
/** @param {PointerEvent} event */
function stopInspect(event) {
if (capturedPointer !== event.pointerId) return;
capturedPointer = null;
canvas.releasePointerCapture(event.pointerId);
if (event.pointerType !== "mouse") event.preventDefault();
}
function clearInspect() {
setInspected(null);
options.onInspect?.(null, null, false);
}
/** @param {PointerEvent} event */
function clearOnOutsidePointer(event) {
if (event.target instanceof Node && canvas.contains(event.target)) return;
clearInspect();
}
const observer = new ResizeObserver(scheduleDraw);
canvas.addEventListener("pointermove", moveInspect);
canvas.addEventListener("pointerdown", startInspect);
canvas.addEventListener("pointerup", stopInspect);
canvas.addEventListener("pointercancel", (event) => {
stopInspect(event);
clearInspect();
});
canvas.addEventListener("pointerleave", (event) => {
if (capturedPointer === null && event.pointerType === "mouse") {
clearInspect();
}
});
document.addEventListener("pointerdown", clearOnOutsidePointer);
window.addEventListener("blur", clearInspect);
observer.observe(canvas);
scheduleDraw();
return /** @type {const} */ ({
element: canvas,
ordered,
destroy() {
cancelAnimationFrame(frame);
document.removeEventListener("pointerdown", clearOnOutsidePointer);
window.removeEventListener("blur", clearInspect);
observer.disconnect();
},
/** @param {number | null} mask */
previewDisabledMask(mask) {
previewMask = mask;
scheduleDraw();
},
/** @param {number} mask */
setDisabledMask(mask) {
disabledMask = mask;
scheduleDraw();
},
/** @param {BlockPreviewFilterState} state */
setFilterState(state) {
filterState = state;
scheduleDraw();
},
});
}
/** @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
*/
@@ -0,0 +1,88 @@
/**
* @param {readonly PackCell[]} cells
* @param {number} columns
* @param {number} [rows]
* @returns {PackLayout[] | null}
*/
export function packCells(cells, columns, rows) {
const occupied = /** @type {boolean[][]} */ ([]);
const layouts = [];
let usedRows = 0;
for (const cell of cells) {
const span = Math.min(cell.span, columns);
const position = findPosition(occupied, columns, span, rows);
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 });
}
return layouts;
}
/**
* @param {boolean[][]} occupied
* @param {number} columns
* @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;
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 };
}
}
return null;
}
/**
* @param {boolean[][]} occupied
* @param {number} x
* @param {number} y
* @param {number} span
*/
function canPlace(occupied, x, y, span) {
for (let row = y; row < y + span; row += 1) {
for (let column = x; column < x + span; column += 1) {
if (occupied[row]?.[column]) return false;
}
}
return true;
}
/**
* @param {boolean[][]} occupied
* @param {number} x
* @param {number} y
* @param {number} span
*/
function fillCells(occupied, x, y, span) {
for (let row = y; row < y + span; row += 1) {
occupied[row] ??= [];
for (let column = x; column < x + span; column += 1) {
occupied[row][column] = true;
}
}
}
/**
* @typedef {Object} PackCell
* @property {number} span
*/
/**
* @typedef {Object} PackLayout
* @property {number} x
* @property {number} y
* @property {number} span
* @property {number} rows
*/
@@ -0,0 +1,9 @@
[data-block-preview-heatmap] {
--block-preview-heatmap-gap: 2px;
display: block;
width: 100%;
aspect-ratio: 1;
min-width: 0;
touch-action: none;
}
+71 -57
View File
@@ -1,69 +1,69 @@
import { createHeatmap } from "../../../heatmap/index.js";
import { formatFeeRate } from "../../../utils/fee-rate.js";
import { formatWeight, MAX_BLOCK_WEIGHT } from "../format.js";
import { getFeeRateColor } from "../fee-rates.js";
import { loadBlockPreview } from "./data.js";
import { createPreviewFeeRange, orderTransactions } from "./fees.js";
import { createPreviewFilters, getFilterKeys, getVersionKey } from "./filters.js";
import {
loadBlockPreview,
loadBlockPreviewFilters,
} from "./data.js";
import {
createPendingPreviewFilters,
createPreviewFilters,
} from "./filters/index.js";
import { createBlockPreviewHeatmap } from "./heatmap/index.js";
import { createBlockPreviewInspector } from "./inspector.js";
/**
* @param {BlockPreviewTransaction} transaction
* @param {number[]} ranges
*/
function createPreviewItem(transaction, ranges) {
return {
color: getFeeRateColor(transaction.feeRate, ranges),
group: getVersionKey(transaction.version),
groups: getFilterKeys(transaction),
weight: transaction.weight,
title: [
transaction.txid,
`v${transaction.version}`,
transaction.rbf ? "RBF" : "no RBF",
`${transaction.inputCount} in`,
`${transaction.outputCount} out`,
`${formatFeeRate(transaction.feeRate)} sat/vB`,
formatWeight(transaction.weight),
].join(" · "),
};
}
function noop() {}
/**
* @param {HTMLElement} body
* @param {HTMLElement} filters
* @param {HTMLElement} [inspector]
*/
function createFigure(body, filters) {
function createFigure(body, filters, inspector) {
const figure = document.createElement("figure");
const caption = document.createElement("figcaption");
const title = document.createElement("h5");
figure.dataset.blockPreviewFigure = "";
caption.dataset.blockPreviewLegend = "";
title.append("Filters");
caption.append(title, filters);
caption.append(filters);
figure.append(caption, body);
if (inspector) figure.append(inspector);
return figure;
}
/**
* @param {HTMLElement} content
* @param {BlockPreviewTransaction[]} transactions
* @template T
* @param {() => Promise<T>} load
*/
function renderPreview(content, transactions) {
const ordered = orderTransactions(transactions);
const ranges = createPreviewFeeRange(ordered);
const items = ordered.map((transaction) => {
return createPreviewItem(transaction, ranges);
});
const heatmap = createHeatmap(items, {
origin: "bottom",
shape: "square",
capacity: MAX_BLOCK_WEIGHT,
columns: 84,
});
function memoize(load) {
let promise = /** @type {Promise<T> | null} */ (null);
content.replaceChildren(createFigure(heatmap, createPreviewFilters(ordered, heatmap)));
return () => {
promise ??= load();
return promise;
};
}
/**
* @param {BlockPreviewTransaction[]} transactions
* @param {() => Promise<BlockPreviewFilterState>} loadFilters
* @param {AbortSignal} signal
*/
function createPreview(transactions, loadFilters, signal) {
const loadFilterState = memoize(loadFilters);
const inspector = createBlockPreviewInspector(signal, loadFilterState);
const heatmap = createBlockPreviewHeatmap(transactions, {
onInspect: inspector.inspect,
});
const filters = createPreviewFilters(loadFilterState, heatmap);
return {
destroy() {
inspector.destroy();
filters.destroy();
heatmap.destroy();
},
element: createFigure(heatmap.element, filters.element, inspector.element),
};
}
/**
@@ -75,9 +75,7 @@ function renderStatus(content, status) {
p.dataset.blockPreviewStatus = status;
p.textContent = status;
content.replaceChildren(createFigure(p, createPreviewFilters([], null, {
pending: true,
})));
content.replaceChildren(createFigure(p, createPendingPreviewFilters()));
}
/**
@@ -85,15 +83,24 @@ function renderStatus(content, status) {
*/
export function createBlockPreviewPane(block) {
const content = document.createElement("div");
const controller = new AbortController();
let destroyHeatmap = noop;
let live = true;
content.dataset.blockPreview = "";
renderStatus(content, "Loading");
void loadBlockPreview(block)
.then((transactions) => {
void loadBlockPreview(block, controller.signal)
.then(({ range, transactions }) => {
if (!live) return;
renderPreview(content, transactions);
const preview = createPreview(
transactions,
() => loadBlockPreviewFilters(range, controller.signal),
controller.signal,
);
destroyHeatmap = preview.destroy;
content.replaceChildren(preview.element);
})
.catch((error) => {
if (!live) return;
@@ -105,11 +112,18 @@ export function createBlockPreviewPane(block) {
element: content,
destroy() {
live = false;
for (const heatmap of content.querySelectorAll("[data-heatmap]")) {
heatmap.dispatchEvent(new Event("heatmap:destroy"));
}
controller.abort();
destroyHeatmap();
destroyHeatmap = noop;
},
};
}
/** @typedef {import("./fees.js").BlockPreviewTransaction} BlockPreviewTransaction */
/** @typedef {import("./data.js").BlockPreviewTransaction} BlockPreviewTransaction */
/** @typedef {import("./data.js").BlockPreviewFilterState} BlockPreviewFilterState */
/**
* @typedef {Object} BlockPreview
* @property {() => void} destroy
* @property {HTMLElement} element
*/
@@ -0,0 +1,236 @@
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";
const TXID_CACHE_LIMIT = 64;
const TXID_DWELL_MS = 120;
const OVERLAY_MARGIN = 8;
/**
* @param {HTMLElement} parent
* @param {string} label
*/
function appendField(parent, label) {
const row = document.createElement("div");
const term = document.createElement("dt");
const value = document.createElement("dd");
term.textContent = label;
row.append(term, value);
parent.append(row);
return value;
}
/**
* @param {HTMLElement} element
* @param {string} value
* @param {boolean} loading
*/
function setField(element, value, loading = false) {
element.textContent = value;
if (loading) element.setAttribute("aria-busy", "true");
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
*/
export function createBlockPreviewInspector(parentSignal, loadFilters) {
const element = document.createElement("dl");
const txid = appendField(element, "txid");
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 cache = /** @type {Map<number, string>} */ (new Map());
let controller = /** @type {AbortController | null} */ (null);
let inspected = /** @type {BlockPreviewTransaction | null} */ (null);
let point = /** @type {BlockPreviewPointer | null} */ (null);
let timer = 0;
let version = 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);
}
function setTraitsLoading() {
for (const { value } of traitFields) {
value.removeAttribute("title");
setField(value, "loading", true);
}
}
/** @param {number} mask */
function setTraits(mask) {
for (const { key, value } of traitFields) {
const labels = FILTERS
.filter(({ bit, group }) => group === key && mask & bit)
.map(({ label }) => label);
const text = labels.join(" · ") || "none";
setField(value, text);
value.title = text;
}
}
/**
* @param {BlockPreviewTransaction} transaction
*/
function loadTraits(transaction) {
const current = version;
setTraitsLoading();
void 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);
})
.catch((error) => {
if (current !== version || parentSignal.aborted) return;
for (const { value } of traitFields) setField(value, "unavailable");
console.error(error);
});
}
/**
* @param {BlockPreviewTransaction | null} transaction
* @param {BlockPreviewPointer | null} nextPoint
* @param {boolean} eager
*/
function inspect(transaction, nextPoint, eager) {
if (transaction === null || nextPoint === null) {
version += 1;
abortPending();
inspected = null;
point = null;
element.hidden = true;
return;
}
element.hidden = false;
point = nextPoint;
placeReadout(element, nextPoint);
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);
if (cachedTxid !== undefined) {
setField(txid, cachedTxid);
txid.title = cachedTxid;
return;
}
txid.removeAttribute("title");
setField(txid, "loading", true);
fetchTxid(transaction, eager);
}
return /** @type {const} */ ({
destroy: abortPending,
element,
inspect,
});
}
/** @typedef {import("./data.js").BlockPreviewTransaction} BlockPreviewTransaction */
/** @typedef {import("./data.js").BlockPreviewFilterState} BlockPreviewFilterState */
/**
* @typedef {Object} BlockPreviewPointer
* @property {number} clientX
* @property {number} clientY
*/
+69 -23
View File
@@ -1,34 +1,80 @@
[data-block-preview] {
display: grid;
min-width: 0;
}
[data-block-preview-figure] {
display: grid;
gap: 0.75rem;
min-width: 0;
}
[data-block-preview-figure] {
position: relative;
display: grid;
gap: 0.75rem;
min-width: 0;
}
[data-block-preview-legend] {
display: grid;
min-width: 0;
[data-block-preview-legend] {
display: grid;
gap: 0.5rem;
min-width: 0;
}
> h5 {
color: var(--white);
font-size: var(--font-size-sm);
[data-block-preview-transaction] {
position: absolute;
top: var(--tx-y, 50%);
left: var(--tx-x, 50%);
z-index: 2;
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
column-gap: 0.5rem;
width: min(24rem, calc(100% - 1rem));
padding: 0.375rem 0.5rem;
border-radius: var(--control-radius);
color: var(--gray);
background: color-mix(in oklch, var(--black) 88%, transparent);
backdrop-filter: blur(0.25rem);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-xs);
pointer-events: none;
transform: translate(-50%, calc(-100% - 0.5rem));
&[data-below] {
transform: translate(-50%, 0.5rem);
}
> div {
display: contents;
}
dt,
dd {
min-width: 0;
}
dt {
color: var(--gray);
text-transform: uppercase;
}
dd {
overflow: hidden;
margin: 0;
color: var(--white);
text-overflow: ellipsis;
white-space: nowrap;
&[aria-busy="true"] {
color: var(--gray);
font-style: italic;
}
}
}
[data-block-preview-status] {
display: grid;
aspect-ratio: 1;
margin: 0;
place-items: center;
color: var(--gray);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
text-transform: uppercase;
}
}
[data-block-preview-status] {
display: grid;
aspect-ratio: 1;
margin: 0;
place-items: center;
color: var(--gray);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
text-transform: uppercase;
}