diff --git a/website_next/explore/block/preview/data.js b/website_next/explore/block/preview/data.js index 1c6726f47..8c8173f3a 100644 --- a/website_next/explore/block/preview/data.js +++ b/website_next/explore/block/preview/data.js @@ -12,16 +12,23 @@ export async function loadBlockPreview(block) { const start = /** @type {number} */ (firstTxIndex); const end = start + block.txCount; const tx = brk.series.transactions; + const indexes = brk.series.indexes.txIndex; const [ txids, versions, weights, feeRates, + rbfs, + inputCounts, + outputCounts, ] = 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(), ]); return txids.data.map((txid, index) => ({ @@ -29,5 +36,8 @@ export async function loadBlockPreview(block) { version: versions.data[index], weight: weights.data[index], feeRate: feeRates.data[index], + rbf: rbfs.data[index], + inputCount: inputCounts.data[index], + outputCount: outputCounts.data[index], })); } diff --git a/website_next/explore/block/preview/fees.js b/website_next/explore/block/preview/fees.js index b213ba32b..d13741cb5 100644 --- a/website_next/explore/block/preview/fees.js +++ b/website_next/explore/block/preview/fees.js @@ -21,4 +21,7 @@ export function orderTransactions(transactions) { * @property {number} version * @property {number} weight * @property {number} feeRate + * @property {boolean} rbf + * @property {number} inputCount + * @property {number} outputCount */ diff --git a/website_next/explore/block/preview/filters.js b/website_next/explore/block/preview/filters.js index 346d96556..f07f25dca 100644 --- a/website_next/explore/block/preview/filters.js +++ b/website_next/explore/block/preview/filters.js @@ -5,50 +5,117 @@ import { } from "../../../legend/index.js"; import { formatNumber } from "../format.js"; -const VERSION_FILTERS = /** @type {const} */ ([1, 2, 3]); +/** @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 String(version); + return `version:${version}`; +} + +/** + * @param {BlockPreviewTransaction} transaction + */ +export function getFilterKeys(transaction) { + return FILTERS + .filter(({ match }) => match(transaction)) + .map(({ key }) => key); } /** * @param {BlockPreviewTransaction[]} transactions - * @returns {Map} */ -function countVersions(transactions) { - const counts = new Map(); +function countFilters(transactions) { + return new Map(FILTERS.map(({ key, match }) => { + return [key, transactions.filter(match).length]; + })); +} - for (const transaction of transactions) { - counts.set(transaction.version, (counts.get(transaction.version) ?? 0) + 1); - } +function createActiveFilters() { + return new Map(FILTER_GROUPS.map((group) => { + const keys = FILTERS + .filter((filter) => filter.group === group) + .map(({ key }) => String(key)); - return counts; + return [group, new Set(keys)]; + })); } /** - * @param {HTMLElement} heatmap - * @returns {Map} + * @param {string[]} keys + * @param {Map>} activeFilters */ -function groupCells(heatmap) { - const groups = new Map(); - const cells = /** @type {HTMLElement[]} */ ([ - ...heatmap.querySelectorAll("[data-heatmap-cell]"), - ]); +function matchesActiveFilters(keys, activeFilters) { + for (const group of FILTER_GROUPS) { + const activeKeys = activeFilters.get(group); - for (const cell of cells) { - const key = /** @type {string} */ (cell.dataset.heatmapCell); - let group = groups.get(key); + if ([...activeKeys ?? []].some((key) => keys.includes(key))) continue; - if (group === undefined) { - group = []; - groups.set(key, group); - } - - group.push(cell); + return false; } - return groups; + return true; } /** @@ -75,19 +142,31 @@ function setPending(button) { * @param {Object} [options] * @param {boolean} [options.pending] */ -export function createVersionFilters(transactions, heatmap, options = {}) { +export function createPreviewFilters(transactions, heatmap, options = {}) { const list = createLegendList({ scroll: true }); - const counts = countVersions(transactions); - const cells = heatmap === null ? new Map() : groupCells(heatmap); + const counts = countFilters(transactions); const pending = options.pending === true; + const activeFilters = createActiveFilters(); - for (const version of VERSION_FILTERS) { - const count = counts.get(version) ?? 0; - const key = getVersionKey(version); + 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: `tx v${version}`, + label: filter.label, color: "var(--white)", - ariaLabel: `Transaction version ${version}`, + ariaLabel: filter.label, }); value.textContent = pending ? "..." : formatNumber(count); @@ -96,11 +175,14 @@ export function createVersionFilters(transactions, heatmap, options = {}) { } else { button.addEventListener("click", () => { const active = button.getAttribute("aria-pressed") !== "true"; + const activeKeys = /** @type {Set} */ ( + activeFilters.get(filter.group) + ); setActive(button, active); - for (const cell of cells.get(key) ?? []) { - cell.toggleAttribute("data-muted", !active); - } + if (active) activeKeys.add(filter.key); + else activeKeys.delete(filter.key); + updateCells(); }); setActive(button, true); } diff --git a/website_next/explore/block/preview/index.js b/website_next/explore/block/preview/index.js index b97475266..392c94274 100644 --- a/website_next/explore/block/preview/index.js +++ b/website_next/explore/block/preview/index.js @@ -4,7 +4,7 @@ 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 { createVersionFilters, getVersionKey } from "./filters.js"; +import { createPreviewFilters, getFilterKeys, getVersionKey } from "./filters.js"; /** * @param {BlockPreviewTransaction} transaction @@ -14,10 +14,14 @@ 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(" ยท "), @@ -59,7 +63,7 @@ function renderPreview(content, transactions) { columns: 84, }); - content.replaceChildren(createFigure(heatmap, createVersionFilters(ordered, heatmap))); + content.replaceChildren(createFigure(heatmap, createPreviewFilters(ordered, heatmap))); } /** @@ -71,7 +75,7 @@ function renderStatus(content, status) { p.dataset.blockPreviewStatus = status; p.textContent = status; - content.replaceChildren(createFigure(p, createVersionFilters([], null, { + content.replaceChildren(createFigure(p, createPreviewFilters([], null, { pending: true, }))); } diff --git a/website_next/heatmap/index.js b/website_next/heatmap/index.js index 3e97837d7..e42c724cb 100644 --- a/website_next/heatmap/index.js +++ b/website_next/heatmap/index.js @@ -9,6 +9,7 @@ function createCell(item) { const cell = document.createElement("span"); cell.dataset.heatmapCell = item.group; + if (item.groups !== undefined) cell.dataset.heatmapGroups = item.groups.join(" "); cell.style.setProperty("--color", item.color); cell.title = item.title; @@ -143,6 +144,7 @@ export function createHeatmap(items, options = {}) { * @typedef {Object} HeatmapItem * @property {string} color * @property {string} group + * @property {string[]} [groups] * @property {string} title * @property {number} [span] * @property {number} [weight]