From 5161ae2012f6691c7da4321e57d3dde5c1065ded Mon Sep 17 00:00:00 2001 From: nym21 Date: Sat, 18 Jul 2026 21:34:21 +0200 Subject: [PATCH] global: opreturn part 3 --- website_next/explore/block/preview/data.js | 160 +---------- .../explore/block/preview/filters/data.js | 266 ++++++++++++++++++ .../explore/block/preview/filters/index.js | 152 +++++----- .../explore/block/preview/filters/model.js | 98 +++++-- .../block/preview/filters/op-return/data.js | 168 +++++++++++ .../block/preview/filters/op-return/model.js | 49 ++++ .../explore/block/preview/heatmap/capacity.js | 108 ++++--- .../explore/block/preview/heatmap/draw.js | 30 +- .../explore/block/preview/heatmap/fees.js | 18 ++ .../explore/block/preview/heatmap/index.js | 53 ++-- .../explore/block/preview/heatmap/layout.js | 25 ++ .../explore/block/preview/heatmap/pack.js | 28 +- website_next/explore/block/preview/index.js | 40 +-- .../explore/block/preview/inspector.js | 71 ++--- website_next/utils/client.js | 2 +- website_next/utils/colors.js | 56 ++++ 16 files changed, 912 insertions(+), 412 deletions(-) create mode 100644 website_next/explore/block/preview/filters/data.js create mode 100644 website_next/explore/block/preview/filters/op-return/data.js create mode 100644 website_next/explore/block/preview/filters/op-return/model.js create mode 100644 website_next/explore/block/preview/heatmap/layout.js diff --git a/website_next/explore/block/preview/data.js b/website_next/explore/block/preview/data.js index 803366e62..accd9df96 100644 --- a/website_next/explore/block/preview/data.js +++ b/website_next/explore/block/preview/data.js @@ -1,29 +1,4 @@ import { brk } from "../../../utils/client.js"; -import { - FILTERS, - TYPE_BITS, - TYPE_FILTER_MASK, - getFilterBit, -} from "./filters/model.js"; - -const VERSION_BITS = [ - 0, - getFilterBit("version:1"), - getFilterBit("version:2"), - 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"); -const FILTER_INDEX_BY_BIT = FILTERS.reduce((indexes, { bit, index }) => { - indexes[bit] = index; - - return indexes; -}, /** @type {Record} */ ({})); /** * @template T @@ -76,13 +51,14 @@ 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.raw.weight.by.tx_index, start, end, signal), fetchSeriesSlice(tx.fees.feeRate.by.tx_index, start, end, signal), ]); signal.throwIfAborted(); return { + blockWeight: block.weight, range: { start, end }, feeRates: /** @type {number[]} */ (feeRates.data), weights: /** @type {number[]} */ (weights.data), @@ -105,130 +81,6 @@ export async function loadBlockPreviewTxid(txIndex, signal) { 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) { - while (mask) { - const bit = mask & -mask; - - counts[FILTER_INDEX_BY_BIT[bit]] += 1; - mask ^= bit; - } -} - -/** - * @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 |= TYPE_BITS[/** @type {keyof typeof TYPE_BITS} */ (types[index])] ?? 0; - if (mask === TYPE_FILTER_MASK) return mask; - } - - 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 [ - versions, - rbfs, - inputCounts, - outputCounts, - firstInputs, - firstOutputs, - ] = await Promise.all([ - 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), - ]); - - 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); - - for (let index = 0; index < versions.data.length; index += 1) { - const version = /** @type {number} */ (versions.data[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[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 @@ -237,6 +89,7 @@ export async function loadBlockPreviewFilters(range, signal) { /** * @typedef {Object} BlockPreviewData + * @property {number} blockWeight * @property {BlockPreviewRange} range * @property {number[]} weights * @property {number[]} feeRates @@ -248,10 +101,3 @@ export async function loadBlockPreviewFilters(range, signal) { * @property {number} weight * @property {number} feeRate */ - -/** - * @typedef {Object} BlockPreviewFilterState - * @property {number} start - * @property {Uint32Array} masks - * @property {Uint32Array} counts - */ diff --git a/website_next/explore/block/preview/filters/data.js b/website_next/explore/block/preview/filters/data.js new file mode 100644 index 000000000..fe137a870 --- /dev/null +++ b/website_next/explore/block/preview/filters/data.js @@ -0,0 +1,266 @@ +import { brk } from "../../../../utils/client.js"; +import { FILTERS } from "./model.js"; +import { + createOpReturnFilterData, + OP_RETURN_COUNT_SOURCES, +} from "./op-return/data.js"; + +const tx = brk.series.transactions; +const txIndexes = brk.series.indexes.txIndex; + +/** @param {unknown} expected */ +const equals = (expected) => (/** @type {unknown} */ value) => value === expected; + +/** @param {unknown} expected */ +const differs = (expected) => (/** @type {unknown} */ value) => value !== expected; + +/** @param {unknown} value */ +const isOtherVersion = (value) => value !== 1 && value !== 2 && value !== 3; + +const COUNT_SOURCES = [ + { key: "version:1", series: tx.features.count.v1 }, + { key: "version:2", series: tx.features.count.v2 }, + { key: "version:3", series: tx.features.count.v3 }, + { key: "version:other", series: tx.features.count.otherVersion }, + { key: "rbf:yes", series: tx.features.count.explicitlyRbf }, + { key: "input:one", series: tx.features.count.oneInput }, + { key: "output:one", series: tx.features.count.oneOutput }, + { key: "type:p2pk", series: tx.features.count.p2pk }, + { key: "type:p2pkh", series: tx.features.count.p2pkh }, + { key: "type:p2sh", series: tx.features.count.p2sh }, + { key: "type:p2wpkh", series: tx.features.count.p2wpkh }, + { key: "type:p2wsh", series: tx.features.count.p2wsh }, + { key: "type:taproot", series: tx.features.count.p2tr }, + { key: "type:p2a", series: tx.features.count.p2a }, + { key: "type:multisig", series: tx.features.count.p2ms }, + { key: "type:op_return", series: tx.features.count.opReturn }, + { key: "type:empty", series: tx.features.count.empty }, + { key: "type:unknown", series: tx.features.count.unknown }, + { key: "behavior:cpfp_parent", series: tx.fees.count.cpfpParent }, + { key: "behavior:cpfp_child", series: tx.fees.count.cpfpChild }, + { key: "behavior:coinjoin", series: tx.patterns.count.coinjoin }, + { key: "behavior:consolidation", series: tx.patterns.count.consolidation }, + { key: "behavior:batch", series: tx.patterns.count.batchPayout }, + { key: "data:fake_pubkey", series: tx.features.count.fakePubkey }, + { key: "data:fake_scripthash", series: tx.features.count.fakeScripthash }, + { key: "data:inscription", series: tx.features.count.inscription }, + { key: "data:annex", series: tx.features.count.annex }, + { key: "data:dust", series: tx.features.count.dustOutput }, + { key: "sighash:all", series: tx.features.count.sighashAll }, + { key: "sighash:none", series: tx.features.count.sighashNone }, + { key: "sighash:single", series: tx.features.count.sighashSingle }, + { key: "sighash:default", series: tx.features.count.sighashDefault }, + { + key: "sighash:anyone_can_pay", + series: tx.features.count.sighashAnyoneCanPay, + }, + { key: "policy:nonstandard", series: tx.policy.count }, + ...OP_RETURN_COUNT_SOURCES, +]; + +const MEMBERSHIP_SOURCES = /** @type {Map} */ (new Map([ + ["version:1", { matches: equals(1), series: tx.raw.txVersion }], + ["version:2", { matches: equals(2), series: tx.raw.txVersion }], + ["version:3", { matches: equals(3), series: tx.raw.txVersion }], + ["version:other", { matches: isOtherVersion, series: tx.raw.txVersion }], + ["rbf:yes", { matches: Boolean, series: tx.raw.isExplicitlyRbf }], + ["rbf:no", { matches: equals(false), series: tx.raw.isExplicitlyRbf }], + ["input:one", { matches: equals(1), series: txIndexes.inputCount }], + ["input:multi", { matches: differs(1), series: txIndexes.inputCount }], + ["output:one", { matches: equals(1), series: txIndexes.outputCount }], + ["output:multi", { matches: differs(1), series: txIndexes.outputCount }], + ["type:p2pk", { matches: Boolean, series: tx.features.hasP2pk }], + ["type:p2pkh", { matches: Boolean, series: tx.features.hasP2pkh }], + ["type:p2sh", { matches: Boolean, series: tx.features.hasP2sh }], + ["type:p2wpkh", { matches: Boolean, series: tx.features.hasP2wpkh }], + ["type:p2wsh", { matches: Boolean, series: tx.features.hasP2wsh }], + ["type:taproot", { matches: Boolean, series: tx.features.hasP2tr }], + ["type:p2a", { matches: Boolean, series: tx.features.hasP2a }], + ["type:multisig", { matches: Boolean, series: tx.features.hasP2ms }], + ["type:op_return", { matches: Boolean, series: tx.features.hasOpReturn }], + ["type:empty", { matches: Boolean, series: tx.features.hasEmpty }], + ["type:unknown", { matches: Boolean, series: tx.features.hasUnknown }], + [ + "behavior:cpfp_parent", + { matches: Boolean, series: tx.fees.isCpfpParent }, + ], + [ + "behavior:cpfp_child", + { matches: Boolean, series: tx.fees.isCpfpChild }, + ], + ["behavior:coinjoin", { matches: Boolean, series: tx.patterns.isCoinjoin }], + [ + "behavior:consolidation", + { matches: Boolean, series: tx.patterns.isConsolidation }, + ], + ["behavior:batch", { matches: Boolean, series: tx.patterns.isBatchPayout }], + ["data:fake_pubkey", { matches: Boolean, series: tx.features.hasFakePubkey }], + [ + "data:fake_scripthash", + { matches: Boolean, series: tx.features.hasFakeScripthash }, + ], + ["data:inscription", { matches: Boolean, series: tx.features.hasInscription }], + ["data:annex", { matches: Boolean, series: tx.features.hasAnnex }], + ["data:dust", { matches: Boolean, series: tx.features.hasDustOutput }], + ["sighash:all", { matches: Boolean, series: tx.features.hasSighashAll }], + ["sighash:none", { matches: Boolean, series: tx.features.hasSighashNone }], + ["sighash:single", { matches: Boolean, series: tx.features.hasSighashSingle }], + ["sighash:default", { matches: Boolean, series: tx.features.hasSighashDefault }], + [ + "sighash:anyone_can_pay", + { matches: Boolean, series: tx.features.hasSighashAnyoneCanPay }, + ], + ["policy:nonstandard", { matches: Boolean, series: tx.policy.isNonstandard }], +])); + +const FILTERS_BY_KEY = /** @type {Map} */ ( + new Map(FILTERS.map((filter) => [filter.key, filter])) +); +const INSPECT_SERIES = [...new Map( + [...MEMBERSHIP_SOURCES.values()].map(({ series }) => [series.name, series]), +).values()]; + +/** @param {string} key */ +function filterIndex(key) { + return /** @type {BlockPreviewFilter} */ (FILTERS_BY_KEY.get(key)).index; +} + +/** + * @param {number} height + * @param {BlockPreviewRange} range + * @param {AbortSignal} signal + */ +export function createBlockPreviewFilterData(height, range, signal) { + const opReturn = createOpReturnFilterData(height, range, signal); + const sourcePromises = new Map(); + const membershipPromises = new Map(); + let countsPromise = /** @type {Promise | null} */ (null); + + async function fetchCounts() { + const response = await Promise.all(COUNT_SOURCES.map(({ series }) => { + return series.by.height.get(height).fetch({ signal }); + })); + const counts = new Uint32Array(FILTERS.length); + + for (let index = 0; index < COUNT_SOURCES.length; index += 1) { + counts[filterIndex(COUNT_SOURCES[index].key)] = Number(response[index].data[0]); + } + + const txCount = range.end - range.start; + counts[filterIndex("rbf:no")] = txCount - counts[filterIndex("rbf:yes")]; + counts[filterIndex("input:multi")] = + txCount - counts[filterIndex("input:one")]; + counts[filterIndex("output:multi")] = + txCount - counts[filterIndex("output:one")]; + + signal.throwIfAborted(); + + return counts; + } + + function loadCounts() { + countsPromise ??= fetchCounts(); + + return countsPromise; + } + + /** @param {BlockPreviewFilterSource["series"]} series */ + function loadSource(series) { + let promise = sourcePromises.get(series.name); + + if (promise === undefined) { + promise = series.by.tx_index + .skip(range.start) + .take(range.end - range.start) + .fetch({ signal, memCache: false }) + .then(({ data }) => data); + sourcePromises.set(series.name, promise); + } + + return /** @type {Promise} */ (promise); + } + + /** @param {BlockPreviewFilter} filter */ + function loadMembership(filter) { + let promise = membershipPromises.get(filter.key); + + if (promise === undefined) { + if (filter.group === "op_return") { + promise = opReturn.loadMembership(filter.key); + } else { + const source = /** @type {BlockPreviewFilterSource} */ ( + MEMBERSHIP_SOURCES.get(filter.key) + ); + promise = loadSource(source.series).then((values) => { + const membership = new Uint8Array(values.length); + + for (let index = 0; index < values.length; index += 1) { + membership[index] = Number(source.matches(values[index])); + } + + return membership; + }); + } + + membershipPromises.set(filter.key, promise); + } + + return /** @type {Promise} */ (promise); + } + + /** + * @param {number} txIndex + * @param {AbortSignal} requestSignal + */ + async function loadTransactionFilters(txIndex, requestSignal) { + const response = await Promise.all(INSPECT_SERIES.map((series) => { + return series.by.tx_index.get(txIndex).fetch({ signal: requestSignal }); + })); + const values = new Map(INSPECT_SERIES.map((series, index) => { + return [series.name, response[index].data[0]]; + })); + const keys = /** @type {string[]} */ ([]); + + for (const [key, source] of MEMBERSHIP_SOURCES) { + if (source.matches(values.get(source.series.name))) keys.push(key); + } + + if (keys.includes("type:op_return")) { + keys.push(...await opReturn.loadTransactionKeys(txIndex)); + } + + requestSignal.throwIfAborted(); + + return keys; + } + + return /** @type {const} */ ({ + loadCounts, + loadMembership, + loadTransactionFilters, + }); +} + +/** @typedef {import("../data.js").BlockPreviewRange} BlockPreviewRange */ +/** @typedef {import("./model.js").BlockPreviewFilter} BlockPreviewFilter */ +/** @typedef {ReturnType} BlockPreviewFilterData */ + +/** + * @typedef {Object} BlockPreviewFilterSource + * @property {(value: any) => boolean} matches + * @property {{ + * name: string, + * by: { + * tx_index: { + * get: (index: number) => { + * fetch: (options: { signal: AbortSignal }) => Promise<{ data: unknown[] }>, + * }, + * skip: (start: number) => { + * take: (count: number) => { + * fetch: (options: { signal: AbortSignal, memCache: false }) => Promise<{ data: unknown[] }>, + * }, + * }, + * }, + * }, + * }} series + */ diff --git a/website_next/explore/block/preview/filters/index.js b/website_next/explore/block/preview/filters/index.js index 4105d41aa..8cb5f58d1 100644 --- a/website_next/explore/block/preview/filters/index.js +++ b/website_next/explore/block/preview/filters/index.js @@ -14,15 +14,6 @@ function setActive(button, 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 */ @@ -35,12 +26,12 @@ function formatSummaryValue(labels) { } /** - * @param {number} disabledMask + * @param {Set} hiddenKeys * @param {HTMLElement} value */ -function updateSummaryValue(disabledMask, value) { +function updateSummaryValue(hiddenKeys, value) { const labels = FILTERS - .filter(({ bit }) => disabledMask & bit) + .filter(({ key }) => hiddenKeys.has(key)) .map(({ group, label }) => { return `${FILTER_GROUP_LABELS.get(group)} ${label}`; }); @@ -94,109 +85,133 @@ export function createPendingPreviewFilters() { } /** - * @param {() => Promise} loadFilters + * @param {BlockPreviewFilterData} data * @param {BlockPreviewHeatmap} heatmap */ -export function createPreviewFilters(loadFilters, heatmap) { +export function createPreviewFilters(data, heatmap) { const { panel, summary, summaryValue } = createFilterPanel(); - let disabledMask = 0; + const appliedKeys = new Set(); + const hiddenKeys = new Set(); let live = true; let loading = false; - let state = /** @type {BlockPreviewFilterState | null} */ (null); + let counts = /** @type {Uint32Array | null} */ (null); let previewButton = /** @type {HTMLButtonElement | null} */ (null); - updateSummaryValue(disabledMask, summaryValue); + updateSummaryValue(hiddenKeys, summaryValue); function resetPreview() { previewButton?.removeAttribute("data-preview"); previewButton = null; - heatmap.setPreviewMask(null); + heatmap.setPreviewMembership(null); } /** * @param {HTMLButtonElement} button - * @param {number} nextMask + * @param {BlockPreviewFilter} filter */ - function preview(button, nextMask) { + function preview(button, filter) { resetPreview(); previewButton = button; - button.dataset.preview = ""; - heatmap.setPreviewMask(nextMask); + button.setAttribute("aria-busy", "true"); + + void data.loadMembership(filter) + .then((membership) => { + button.removeAttribute("aria-busy"); + if (!live || previewButton !== button) return; + + button.dataset.preview = ""; + heatmap.setPreviewMembership(membership); + }) + .catch((error) => { + button.removeAttribute("aria-busy"); + if (!live) return; + + resetPreview(); + console.error(error); + }); } /** - * @param {BlockPreviewFilterState | null} filterState + * @param {BlockPreviewFilter} filter + * @param {Uint8Array} membership */ - function renderFilters(filterState) { + function syncHidden(filter, membership) { + const hidden = hiddenKeys.has(filter.key); + const applied = appliedKeys.has(filter.key); + + if (hidden === applied) return; + + heatmap.setFilterHidden(membership, hidden); + if (hidden) appliedKeys.add(filter.key); + else appliedKeys.delete(filter.key); + } + + /** @param {Uint32Array} filterCounts */ + function renderFilters(filterCounts) { clearGroups(panel, summary); + const canPreview = CAN_HOVER.matches; for (const { label, filters } of FILTER_GROUP_FILTERS) { - const group = createFilterGroup(label); + let group = /** @type {HTMLDivElement | null} */ (null); for (const filter of filters) { + const count = filterCounts[filter.index]; + if (count === 0) continue; + + group ??= createFilterGroup(label); const { button, value } = createLegendItem({ ariaLabel: filter.label, color: filter.color, label: filter.label, }); - value.textContent = filterState === null - ? "..." - : formatNumber(filterState.counts[filter.index]); + value.textContent = formatNumber(count); + button.addEventListener("click", () => { + resetPreview(); + if (hiddenKeys.has(filter.key)) hiddenKeys.delete(filter.key); + else hiddenKeys.add(filter.key); - if (filterState === null) { - setPending(button); - } else { - const canPreview = CAN_HOVER.matches; + setActive(button, !hiddenKeys.has(filter.key)); + updateSummaryValue(hiddenKeys, summaryValue); + void data.loadMembership(filter) + .then((membership) => { + if (live) syncHidden(filter, membership); + }) + .catch(console.error); + }); - 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", () => { + preview(button, filter); }); - - if (canPreview) { - button.addEventListener("pointerenter", () => { - preview(button, filter.bit); - }); - button.addEventListener("pointerleave", resetPreview); - } - - button.addEventListener("focus", () => { - preview(button, filter.bit); - }); - button.addEventListener("blur", resetPreview); - setActive(button, (disabledMask & filter.bit) === 0); + button.addEventListener("pointerleave", resetPreview); } + button.addEventListener("focus", () => { + preview(button, filter); + }); + button.addEventListener("blur", resetPreview); + setActive(button, !hiddenKeys.has(filter.key)); group.append(button); } - panel.append(group); + if (group !== null) panel.append(group); } } function load() { - if (loading || state !== null) return; + if (loading || counts !== null) return; loading = true; summaryValue.textContent = "loading"; - renderFilters(null); - void loadFilters() - .then((nextState) => { + void data.loadCounts() + .then((nextCounts) => { if (!live) return; loading = false; - state = nextState; - heatmap.setFilterState(nextState); - updateSummaryValue(disabledMask, summaryValue); - renderFilters(nextState); + counts = nextCounts; + updateSummaryValue(hiddenKeys, summaryValue); + renderFilters(nextCounts); }) .catch((error) => { if (!live) return; @@ -210,6 +225,7 @@ export function createPreviewFilters(loadFilters, heatmap) { panel.addEventListener("toggle", () => { if (panel.open) load(); + else resetPreview(); }); return /** @type {const} */ ({ @@ -221,11 +237,11 @@ export function createPreviewFilters(loadFilters, heatmap) { }); } -/** @typedef {import("../data.js").BlockPreviewFilterState} BlockPreviewFilterState */ +/** @typedef {import("./data.js").BlockPreviewFilterData} BlockPreviewFilterData */ +/** @typedef {import("./model.js").BlockPreviewFilter} BlockPreviewFilter */ /** * @typedef {Object} BlockPreviewHeatmap - * @property {(mask: number | null) => void} setPreviewMask - * @property {(mask: number) => void} setDisabledMask - * @property {(state: BlockPreviewFilterState) => void} setFilterState + * @property {(membership: Uint8Array | null) => void} setPreviewMembership + * @property {(membership: Uint8Array, hidden: boolean) => void} setFilterHidden */ diff --git a/website_next/explore/block/preview/filters/model.js b/website_next/explore/block/preview/filters/model.js index 6b8e3e0a3..6cd1363e2 100644 --- a/website_next/explore/block/preview/filters/model.js +++ b/website_next/explore/block/preview/filters/model.js @@ -1,4 +1,8 @@ import { txColors } from "../../../../utils/colors.js"; +import { + OP_RETURN_KIND_FILTERS, + OP_RETURN_POLICY_FILTERS, +} from "./op-return/model.js"; export const FILTER_GROUPS = /** @type {const} */ ([ { key: "version", label: "version" }, @@ -6,12 +10,18 @@ export const FILTER_GROUPS = /** @type {const} */ ([ { key: "input", label: "input" }, { key: "output", label: "output" }, { key: "type", label: "type" }, + { key: "behavior", label: "behavior" }, + { key: "data", label: "data" }, + { key: "sighash", label: "sighash" }, + { key: "policy", label: "policy" }, + { key: "op_return", label: "op return" }, ]); const FILTER_DEFS = /** @type {const} */ ([ ["version", "v1", "version:1", txColors.v1], ["version", "v2", "version:2", txColors.v2], ["version", "v3", "version:3", txColors.v3], + ["version", "other", "version:other", txColors.otherVersion], ["rbf", "yes", "rbf:yes", txColors.rbf], ["rbf", "no", "rbf:no", txColors.noRbf], ["input", "1", "input:one", txColors.oneInput], @@ -29,10 +39,67 @@ const FILTER_DEFS = /** @type {const} */ ([ ["type", "op ret", "type:op_return", txColors.opReturn], ["type", "empty", "type:empty", txColors.empty], ["type", "unknown", "type:unknown", txColors.unknown], + [ + "behavior", + "paid by child", + "behavior:cpfp_parent", + txColors.behavior.cpfpParent, + ], + [ + "behavior", + "pays parent", + "behavior:cpfp_child", + txColors.behavior.cpfpChild, + ], + ["behavior", "coinjoin", "behavior:coinjoin", txColors.behavior.coinjoin], + [ + "behavior", + "consolidation", + "behavior:consolidation", + txColors.behavior.consolidation, + ], + ["behavior", "batch", "behavior:batch", txColors.behavior.batchPayout], + ["data", "fake pubkey", "data:fake_pubkey", txColors.data.fakePubkey], + [ + "data", + "fake scripthash", + "data:fake_scripthash", + txColors.data.fakeScripthash, + ], + ["data", "inscription", "data:inscription", txColors.data.inscription], + ["data", "annex", "data:annex", txColors.data.annex], + ["data", "dust", "data:dust", txColors.data.dust], + ["sighash", "all", "sighash:all", txColors.sighash.all], + ["sighash", "none", "sighash:none", txColors.sighash.none], + ["sighash", "single", "sighash:single", txColors.sighash.single], + ["sighash", "default", "sighash:default", txColors.sighash.default], + [ + "sighash", + "anyonecanpay", + "sighash:anyone_can_pay", + txColors.sighash.anyoneCanPay, + ], + [ + "policy", + "nonstandard", + "policy:nonstandard", + txColors.policy.nonstandard, + ], + ...OP_RETURN_KIND_FILTERS.map(([label, kind, , color]) => { + return /** @type {const} */ (["op_return", label, `op_return:${kind}`, color]); + }), + ...OP_RETURN_POLICY_FILTERS.map(([label, policy, , color]) => { + return /** @type {const} */ ([ + "op_return", + label, + `op_return_policy:${policy}`, + color, + ]); + }), ]); export const FILTERS = FILTER_DEFS.map(([group, label, key, color], index) => { - return /** @type {const} */ ({ bit: 1 << index, color, group, index, key, label }); + return /** @type {const} */ ({ color, group, index, key, label }); }); export const FILTER_GROUP_FILTERS = FILTER_GROUPS.map((group) => { @@ -46,31 +113,4 @@ export const FILTER_GROUP_LABELS = new Map(FILTER_GROUPS.map(({ key, label }) => return [key, label]; })); -const FILTER_BITS = /** @type {Map} */ ( - new Map(FILTERS.map(({ bit, key }) => [key, bit])) -); - -export const TYPE_FILTER_MASK = FILTERS - .filter(({ group }) => group === "type") - .reduce((mask, { bit }) => mask | bit, 0); - -export const TYPE_BITS = /** @type {const} */ ({ - empty: getFilterBit("type:empty"), - multisig: getFilterBit("type:multisig"), - op_return: getFilterBit("type:op_return"), - p2a: getFilterBit("type:p2a"), - p2pk: getFilterBit("type:p2pk"), - p2pkh: getFilterBit("type:p2pkh"), - p2sh: getFilterBit("type:p2sh"), - unknown: getFilterBit("type:unknown"), - v0_p2wpkh: getFilterBit("type:p2wpkh"), - v0_p2wsh: getFilterBit("type:p2wsh"), - v1_p2tr: getFilterBit("type:taproot"), -}); - -/** - * @param {string} key - */ -export function getFilterBit(key) { - return FILTER_BITS.get(key) ?? 0; -} +/** @typedef {(typeof FILTERS)[number]} BlockPreviewFilter */ diff --git a/website_next/explore/block/preview/filters/op-return/data.js b/website_next/explore/block/preview/filters/op-return/data.js new file mode 100644 index 000000000..95914cd83 --- /dev/null +++ b/website_next/explore/block/preview/filters/op-return/data.js @@ -0,0 +1,168 @@ +import { brk } from "../../../../../utils/client.js"; +import { + OP_RETURN_KIND_FILTERS, + OP_RETURN_POLICY_FILTERS, +} from "./model.js"; + +const MAX_STANDARD_BYTES = 82; +const byKind = brk.series.opReturn.byKind; +const policy = brk.series.opReturn.policy; + +export const OP_RETURN_COUNT_SOURCES = [ + ...OP_RETURN_KIND_FILTERS.map(([, kind, clientKey]) => { + return { + key: `op_return:${kind}`, + series: byKind[clientKey].carrierTxCount.block, + }; + }), + ...OP_RETURN_POLICY_FILTERS.map(([, filter, clientKey]) => { + return { + key: `op_return_policy:${filter}`, + series: policy[clientKey].carrierTxCount.block, + }; + }), +]; + +/** + * @param {string} policy + * @param {number} count + * @param {boolean} oversized + */ +function matchesPolicy(policy, count, oversized) { + if (policy === "standard") return count === 1 && !oversized; + if (policy === "oversized") return oversized; + if (policy === "multiple") return count > 1; + + return oversized || count > 1; +} + +/** + * @param {number} height + * @param {BlockPreviewRange} range + * @param {AbortSignal} signal + */ +export function createOpReturnFilterData(height, range, signal) { + const raw = brk.series.opReturn.raw; + let rawPromise = /** @type {Promise | null} */ (null); + + async function fetchRows() { + const indexes = await raw.firstIndex.by.height + .skip(height) + .take(2) + .fetch({ signal }); + const start = /** @type {number} */ (indexes.data[0]); + const end = indexes.data.length === 2 + ? /** @type {number} */ (indexes.data[1]) + : await raw.toTxIndex.by.op_return_index.len(); + + signal.throwIfAborted(); + if (start === end) return { bytes: [], kinds: [], txIndexes: [] }; + + const count = end - start; + const [txIndexes, kinds, bytes] = await Promise.all([ + raw.toTxIndex.by.op_return_index + .skip(start) + .take(count) + .fetch({ signal, memCache: false }), + raw.kind.by.op_return_index + .skip(start) + .take(count) + .fetch({ signal, memCache: false }), + raw.postOpReturnBytes.by.op_return_index + .skip(start) + .take(count) + .fetch({ signal, memCache: false }), + ]); + + signal.throwIfAborted(); + + return { + bytes: bytes.data, + kinds: kinds.data, + txIndexes: txIndexes.data, + }; + } + + function loadRows() { + rawPromise ??= fetchRows(); + + return rawPromise; + } + + /** @param {string} key */ + async function loadMembership(key) { + const rows = await loadRows(); + const membership = new Uint8Array(range.end - range.start); + const separator = key.indexOf(":"); + const group = key.slice(0, separator); + const value = key.slice(separator + 1); + + if (group === "op_return") { + for (let index = 0; index < rows.txIndexes.length; index += 1) { + if (rows.kinds[index] === value) { + membership[rows.txIndexes[index] - range.start] = 1; + } + } + + return membership; + } + + for (let index = 0; index < rows.txIndexes.length;) { + const txIndex = rows.txIndexes[index]; + let count = 0; + let oversized = false; + + while (index < rows.txIndexes.length && rows.txIndexes[index] === txIndex) { + oversized ||= rows.bytes[index] > MAX_STANDARD_BYTES; + count += 1; + index += 1; + } + + if (matchesPolicy(value, count, oversized)) { + membership[txIndex - range.start] = 1; + } + } + + return membership; + } + + /** @param {number} txIndex */ + async function loadTransactionKeys(txIndex) { + const rows = await loadRows(); + const keys = /** @type {string[]} */ ([]); + const kinds = new Set(); + let count = 0; + let oversized = false; + + for (let index = 0; index < rows.txIndexes.length; index += 1) { + const rowTxIndex = rows.txIndexes[index]; + + if (rowTxIndex < txIndex) continue; + if (rowTxIndex > txIndex) break; + + kinds.add(rows.kinds[index]); + oversized ||= rows.bytes[index] > MAX_STANDARD_BYTES; + count += 1; + } + + for (const kind of kinds) keys.push(`op_return:${kind}`); + for (const [, filter] of OP_RETURN_POLICY_FILTERS) { + if (matchesPolicy(filter, count, oversized)) { + keys.push(`op_return_policy:${filter}`); + } + } + + return keys; + } + + return /** @type {const} */ ({ loadMembership, loadTransactionKeys }); +} + +/** + * @typedef {Object} OpReturnRows + * @property {number[]} bytes + * @property {string[]} kinds + * @property {number[]} txIndexes + */ + +/** @typedef {import("../../data.js").BlockPreviewRange} BlockPreviewRange */ diff --git a/website_next/explore/block/preview/filters/op-return/model.js b/website_next/explore/block/preview/filters/op-return/model.js new file mode 100644 index 000000000..2555f1dce --- /dev/null +++ b/website_next/explore/block/preview/filters/op-return/model.js @@ -0,0 +1,49 @@ +import { txColors } from "../../../../../utils/colors.js"; + +export const OP_RETURN_KIND_FILTERS = /** @type {const} */ ([ + ["runes", "runes", "runes", txColors.opReturnKind.runes], + ["veri block", "veri_block", "veriBlock", txColors.opReturnKind.veriBlock], + ["omni", "omni", "omni", txColors.opReturnKind.omni], + ["stacks", "stacks", "stacks", txColors.opReturnKind.stacks], + ["blockstack", "blockstack", "blockstack", txColors.opReturnKind.blockstack], + ["colu", "colu", "colu", txColors.opReturnKind.colu], + ["open assets", "open_assets", "openAssets", txColors.opReturnKind.openAssets], + ["komodo", "komodo", "komodo", txColors.opReturnKind.komodo], + ["coin spark", "coin_spark", "coinSpark", txColors.opReturnKind.coinSpark], + ["poet", "poet", "poet", txColors.opReturnKind.poet], + ["docproof", "docproof", "docproof", txColors.opReturnKind.docproof], + [ + "open timestamps", + "open_timestamps", + "openTimestamps", + txColors.opReturnKind.openTimestamps, + ], + ["factom", "factom", "factom", txColors.opReturnKind.factom], + [ + "eternity wall", + "eternity_wall", + "eternityWall", + txColors.opReturnKind.eternityWall, + ], + ["memo", "memo", "memo", txColors.opReturnKind.memo], + ["bitproof", "bitproof", "bitproof", txColors.opReturnKind.bitproof], + ["ascribe", "ascribe", "ascribe", txColors.opReturnKind.ascribe], + ["stampery", "stampery", "stampery", txColors.opReturnKind.stampery], + ["epobc", "epobc", "epobc", txColors.opReturnKind.epobc], + ["bare hash", "bare_hash", "bareHash", txColors.opReturnKind.bareHash], + ["text", "text", "text", txColors.opReturnKind.text], + ["empty", "empty", "empty", txColors.opReturnKind.empty], + ["unknown", "unknown", "unknown", txColors.opReturnKind.unknown], +]); + +export const OP_RETURN_POLICY_FILTERS = /** @type {const} */ ([ + ["standard", "standard", "standard", txColors.opReturnPolicy.standard], + ["oversized", "oversized", "oversized", txColors.opReturnPolicy.oversized], + ["multiple", "multiple", "multiple", txColors.opReturnPolicy.multiple], + [ + "pre-v30 nonstandard", + "pre_v30_nonstandard", + "preV30Nonstandard", + txColors.opReturnPolicy.preV30Nonstandard, + ], +]); diff --git a/website_next/explore/block/preview/heatmap/capacity.js b/website_next/explore/block/preview/heatmap/capacity.js index 47e445784..6955b8ced 100644 --- a/website_next/explore/block/preview/heatmap/capacity.js +++ b/website_next/explore/block/preview/heatmap/capacity.js @@ -1,73 +1,95 @@ -import { packCells } from "./pack.js"; +const CALIBRATION_STEPS = 24; /** - * @param {number} weight - * @param {number} capacity - * @param {number} columns + * @param {Float64Array} spans + * @param {number} scale */ -function weightToSpan(weight, capacity, columns) { - const cellWeight = capacity / (columns * columns); +function scaledArea(spans, scale) { + let area = 0; - return Math.max(1, Math.round(Math.sqrt(weight / cellWeight))); -} + for (const span of spans) { + const resolved = Math.max(1, Math.round(span * scale)); -/** - * @template {CapacityCell} Cell - * @param {readonly Cell[]} cells - * @param {number} capacity - * @param {number} columns - * @returns {ResolvedCapacityCell[]} - */ -function resolveCapacityCells(cells, capacity, columns) { - const resolved = /** @type {ResolvedCapacityCell[]} */ (cells); - - for (const cell of resolved) { - cell.span = weightToSpan(cell.weight, capacity, columns); + area += resolved * resolved; } - return resolved; + return area; } /** * @template {CapacityCell} Cell * @param {readonly Cell[]} cells + * @param {number} blockWeight * @param {number} capacity * @param {number} columns */ -function fitCapacityCells(cells, capacity, columns) { - const resolvedCells = resolveCapacityCells(cells, capacity, columns); - let layouts = packCells(resolvedCells, columns, columns); +export function resolveCapacityCells(cells, blockWeight, capacity, columns) { + const gridArea = columns * columns; + const targetArea = Math.max( + cells.length, + Math.floor((gridArea * blockWeight) / capacity), + ); + const idealSpans = new Float64Array(cells.length); + const resolved = /** @type {ResolvedCapacityCell[]} */ (cells); - while (layouts === null) { - let largest = 0; + for (let index = 0; index < cells.length; index += 1) { + idealSpans[index] = Math.sqrt((cells[index].weight * gridArea) / capacity); + } - for (const { span } of resolvedCells) largest = Math.max(largest, span); + let lowerScale = 0; + let upperScale = 1; - if (largest <= 1) break; + while (scaledArea(idealSpans, upperScale) <= targetArea) { + upperScale *= 2; + } - for (const cell of resolvedCells) { - if (cell.span === largest) cell.span -= 1; + for (let step = 0; step < CALIBRATION_STEPS; step += 1) { + const scale = (lowerScale + upperScale) / 2; + + if (scaledArea(idealSpans, scale) <= targetArea) { + lowerScale = scale; + } else { + upperScale = scale; } + } - layouts = packCells(resolvedCells, columns, columns); + let area = 0; + let largestSpan = 1; + + for (let index = 0; index < resolved.length; index += 1) { + const idealSpan = idealSpans[index]; + const span = Math.max(1, Math.round(idealSpan * lowerScale)); + + resolved[index].span = span; + idealSpans[index] = (span + 0.5) / idealSpan; + largestSpan = Math.max(largestSpan, span); + area += span * span; + } + + const candidates = Uint32Array.from(resolved, (_, index) => index); + + candidates.sort((a, b) => { + return idealSpans[a] - idealSpans[b] || a - b; + }); + + for (const index of candidates) { + const cell = resolved[index]; + const growth = 2 * cell.span + 1; + + if (area + growth > targetArea) continue; + + cell.span += 1; + largestSpan = Math.max(largestSpan, cell.span); + area += growth; + if (targetArea - area < 3) break; } return { - layouts: /** @type {NonNullable} */ (layouts), - resolvedCells, + resolvedCells: resolved, + rows: Math.max(largestSpan, Math.ceil(targetArea / columns)), }; } -/** - * @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 diff --git a/website_next/explore/block/preview/heatmap/draw.js b/website_next/explore/block/preview/heatmap/draw.js index 4086fa9ca..63ac66d24 100644 --- a/website_next/explore/block/preview/heatmap/draw.js +++ b/website_next/explore/block/preview/heatmap/draw.js @@ -48,22 +48,28 @@ function drawHover(context, rect) { * @param {DrawPreviewArgs} args */ export function drawPreview(args) { - const { context, disabledMask, filterState, inspected, previewMask, rects } = args; + const { + context, + hasHidden, + hiddenCounts, + inspected, + previewMembership, + rects, + start, + } = args; let inspectedRect = /** @type {PreviewRect | null} */ (null); - if (filterState === null || (disabledMask === 0 && previewMask === null)) { + if (previewMembership === null && !hasHidden) { 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); + const index = rect.transaction.txIndex - start; + const alpha = previewMembership === null + ? (hiddenCounts[index] ? MUTED_ALPHA : 1) + : (previewMembership[index] ? 1 : MUTED_ALPHA); drawRect(context, alpha, rect.color, rect); if (rect.transaction === inspected) inspectedRect = rect; @@ -77,13 +83,13 @@ export function drawPreview(args) { /** * @typedef {Object} DrawPreviewArgs * @property {CanvasRenderingContext2D} context - * @property {number} disabledMask - * @property {BlockPreviewFilterState | null} filterState + * @property {boolean} hasHidden + * @property {Uint8Array} hiddenCounts * @property {BlockPreviewTransaction | null} inspected - * @property {number | null} previewMask + * @property {Uint8Array | null} previewMembership * @property {PreviewRect[]} rects + * @property {number} start */ -/** @typedef {import("../data.js").BlockPreviewFilterState} BlockPreviewFilterState */ /** @typedef {import("../data.js").BlockPreviewTransaction} BlockPreviewTransaction */ /** @typedef {import("./geometry.js").PreviewRect} PreviewRect */ diff --git a/website_next/explore/block/preview/heatmap/fees.js b/website_next/explore/block/preview/heatmap/fees.js index 62c1b722e..c7147b52c 100644 --- a/website_next/explore/block/preview/heatmap/fees.js +++ b/website_next/explore/block/preview/heatmap/fees.js @@ -1,4 +1,5 @@ import { FEE_RATE_PERCENTILES } from "../../fee-rates.js"; +import { packCells } from "./pack.js"; /** * @param {number[]} feeRates @@ -25,3 +26,20 @@ export function orderTransactions(weights, feeRates) { return order.sort((a, b) => feeRates[b] - feeRates[a] || weights[b] - weights[a]); } + +/** + * @template {{ span: number }} Cell + * @param {readonly Cell[]} cells + * @param {number} columns + * @param {number} rows + */ +export function packTransactions(cells, columns, rows) { + let layouts = packCells(cells, columns, rows); + + while (layouts === null) { + rows += 1; + layouts = packCells(cells, columns, rows); + } + + return { layouts, resolvedCells: cells }; +} diff --git a/website_next/explore/block/preview/heatmap/index.js b/website_next/explore/block/preview/heatmap/index.js index e8a8ebdeb..ab325a264 100644 --- a/website_next/explore/block/preview/heatmap/index.js +++ b/website_next/explore/block/preview/heatmap/index.js @@ -1,11 +1,11 @@ import { MAX_BLOCK_WEIGHT } from "../../format.js"; import { createPreviewFeeRange, orderTransactions } from "./fees.js"; -import { createSquareLayout } from "./capacity.js"; +import { createSquareLayout } from "./layout.js"; import { getCanvasFeeRateColor } from "./color.js"; import { createPreviewGeometry, hitTest } from "./geometry.js"; import { drawPreview } from "./draw.js"; -const COLUMNS = 84; +const COLUMNS = 86; const VISIBLE_CELLS = COLUMNS * COLUMNS; /** @@ -56,15 +56,20 @@ export function createBlockPreviewHeatmap(data, options = {}) { const order = orderTransactions(data.weights, data.feeRates); const ranges = createPreviewFeeRange(data.feeRates, order); const cells = createCapacityCells(data, order, ranges); - const square = createSquareLayout(cells, MAX_BLOCK_WEIGHT, COLUMNS); - let disabledMask = 0; - let filterState = /** @type {BlockPreviewFilterState | null} */ (null); + const square = createSquareLayout( + cells, + data.blockWeight, + MAX_BLOCK_WEIGHT, + COLUMNS, + ); + const hiddenCounts = new Uint8Array(data.range.end - data.range.start); + let hiddenFilterCount = 0; let frame = 0; let inspected = /** @type {BlockPreviewTransaction | null} */ (null); let inspectFrame = 0; let inspectPoint = /** @type {BlockPreviewPointer | null} */ (null); let bounds = /** @type {DOMRectReadOnly | null} */ (null); - let previewMask = /** @type {number | null} */ (null); + let previewMembership = /** @type {Uint8Array | null} */ (null); let geometry = /** @type {PreviewGeometry | null} */ (null); let rectWidth = 0; let capturedPointer = /** @type {number | null} */ (null); @@ -99,11 +104,12 @@ export function createBlockPreviewHeatmap(data, options = {}) { context.clearRect(0, 0, width, width); drawPreview({ context, - disabledMask, - filterState, + hasHidden: hiddenFilterCount > 0, + hiddenCounts, inspected, - previewMask, + previewMembership, rects: geometry?.rects ?? [], + start: data.range.start, }); } @@ -242,23 +248,25 @@ export function createBlockPreviewHeatmap(data, options = {}) { window.removeEventListener("blur", clearInspect); observer.disconnect(); }, - /** @param {number | null} mask */ - setPreviewMask(mask) { - if (previewMask === mask) return; + /** @param {Uint8Array | null} membership */ + setPreviewMembership(membership) { + if (previewMembership === membership) return; - previewMask = mask; + previewMembership = membership; scheduleDraw(); }, - /** @param {number} mask */ - setDisabledMask(mask) { - if (disabledMask === mask) return; + /** + * @param {Uint8Array} membership + * @param {boolean} hidden + */ + setFilterHidden(membership, hidden) { + const change = hidden ? 1 : -1; + + hiddenFilterCount += change; + for (let index = 0; index < hiddenCounts.length; index += 1) { + if (membership[index]) hiddenCounts[index] += change; + } - disabledMask = mask; - scheduleDraw(); - }, - /** @param {BlockPreviewFilterState} state */ - setFilterState(state) { - filterState = state; scheduleDraw(); }, }); @@ -266,7 +274,6 @@ export function createBlockPreviewHeatmap(data, options = {}) { /** @typedef {import("../data.js").BlockPreviewData} BlockPreviewData */ /** @typedef {import("../data.js").BlockPreviewTransaction} BlockPreviewTransaction */ -/** @typedef {import("../data.js").BlockPreviewFilterState} BlockPreviewFilterState */ /** * @typedef {Object} BlockPreviewPointer diff --git a/website_next/explore/block/preview/heatmap/layout.js b/website_next/explore/block/preview/heatmap/layout.js new file mode 100644 index 000000000..2357f3bbe --- /dev/null +++ b/website_next/explore/block/preview/heatmap/layout.js @@ -0,0 +1,25 @@ +import { resolveCapacityCells } from "./capacity.js"; +import { packTransactions } from "./fees.js"; + +/** + * @template {{ weight: number }} Cell + * @param {readonly Cell[]} cells + * @param {number} blockWeight + * @param {number} capacity + * @param {number} columns + */ +export function createSquareLayout(cells, blockWeight, capacity, columns) { + const capacityLayout = resolveCapacityCells( + cells, + blockWeight, + capacity, + columns, + ); + const packed = packTransactions( + capacityLayout.resolvedCells, + columns, + capacityLayout.rows, + ); + + return { columns, ...packed }; +} diff --git a/website_next/explore/block/preview/heatmap/pack.js b/website_next/explore/block/preview/heatmap/pack.js index 1392c50ca..75e4b9bc7 100644 --- a/website_next/explore/block/preview/heatmap/pack.js +++ b/website_next/explore/block/preview/heatmap/pack.js @@ -6,15 +6,23 @@ */ export function packCells(cells, columns, rows) { const occupied = new Uint8Array(columns * rows); + const cursors = new Uint32Array(columns + 1); const layouts = []; for (const cell of cells) { const span = Math.min(cell.span, columns); - const position = findPosition(occupied, columns, rows, span); + const position = findPosition( + occupied, + columns, + rows, + span, + cursors[span], + ); if (position === null) return null; fillCells(occupied, columns, position.x, position.y, span); + cursors[span] = position.index + span; layouts.push({ x: position.x, y: position.y, span }); } @@ -26,14 +34,22 @@ export function packCells(cells, columns, rows) { * @param {number} columns * @param {number} rows * @param {number} span - * @returns {{ x: number, y: number } | null} + * @param {number} start + * @returns {{ index: number, x: number, y: number } | null} */ -function findPosition(occupied, columns, rows, span) { +function findPosition(occupied, columns, rows, span, start) { const lastRow = rows - span; + const lastColumn = columns - span; + const startRow = Math.floor(start / columns); + const startColumn = start % columns; - for (let y = 0; y <= lastRow; y += 1) { - for (let x = 0; x <= columns - span; x += 1) { - if (canPlace(occupied, columns, x, y, span)) return { x, y }; + for (let y = startRow; y <= lastRow; y += 1) { + const firstColumn = y === startRow ? startColumn : 0; + + for (let x = firstColumn; x <= lastColumn; x += 1) { + if (canPlace(occupied, columns, x, y, span)) { + return { index: y * columns + x, x, y }; + } } } diff --git a/website_next/explore/block/preview/index.js b/website_next/explore/block/preview/index.js index 25f0c8f93..c62f8956c 100644 --- a/website_next/explore/block/preview/index.js +++ b/website_next/explore/block/preview/index.js @@ -1,7 +1,5 @@ -import { - loadBlockPreview, - loadBlockPreviewFilters, -} from "./data.js"; +import { loadBlockPreview } from "./data.js"; +import { createBlockPreviewFilterData } from "./filters/data.js"; import { createPendingPreviewFilters, createPreviewFilters, @@ -29,35 +27,18 @@ function createFigure(body, filters, inspector) { return figure; } -/** - * @template T - * @param {() => Promise} load - */ -function memoize(load) { - let promise = /** @type {Promise | null} */ (null); - - return () => { - promise ??= load().catch((error) => { - promise = null; - throw error; - }); - - return promise; - }; -} - /** * @param {BlockPreviewData} data - * @param {() => Promise} loadFilters + * @param {number} height * @param {AbortSignal} signal */ -function createPreview(data, loadFilters, signal) { - const loadFilterState = memoize(loadFilters); - const inspector = createBlockPreviewInspector(signal, loadFilterState); +function createPreview(data, height, signal) { + const filterData = createBlockPreviewFilterData(height, data.range, signal); + const inspector = createBlockPreviewInspector(signal, filterData); const heatmap = createBlockPreviewHeatmap(data, { onInspect: inspector.inspect, }); - const filters = createPreviewFilters(loadFilterState, heatmap); + const filters = createPreviewFilters(filterData, heatmap); return { destroy() { @@ -96,11 +77,7 @@ export function createBlockPreviewPane(block) { void loadBlockPreview(block, controller.signal) .then((data) => { if (!live) return; - const preview = createPreview( - data, - () => loadBlockPreviewFilters(data.range, controller.signal), - controller.signal, - ); + const preview = createPreview(data, block.height, controller.signal); destroyHeatmap = preview.destroy; content.replaceChildren(preview.element); @@ -123,7 +100,6 @@ export function createBlockPreviewPane(block) { } /** @typedef {import("./data.js").BlockPreviewData} BlockPreviewData */ -/** @typedef {import("./data.js").BlockPreviewFilterState} BlockPreviewFilterState */ /** * @typedef {Object} BlockPreview diff --git a/website_next/explore/block/preview/inspector.js b/website_next/explore/block/preview/inspector.js index f976e02e3..b12e458dc 100644 --- a/website_next/explore/block/preview/inspector.js +++ b/website_next/explore/block/preview/inspector.js @@ -36,9 +36,9 @@ function setField(element, value, loading = false) { /** * @param {AbortSignal} parentSignal - * @param {() => Promise} loadFilters + * @param {BlockPreviewFilterData} filterData */ -export function createBlockPreviewInspector(parentSignal, loadFilters) { +export function createBlockPreviewInspector(parentSignal, filterData) { const element = document.createElement("dl"); const txid = appendField(element, "txid"); const tx = appendField(element, "tx"); @@ -51,8 +51,7 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { let inspected = /** @type {BlockPreviewTransaction | null} */ (null); let point = /** @type {BlockPreviewPointer | null} */ (null); let readoutSize = /** @type {BlockPreviewReadoutSize | null} */ (null); - let filterState = /** @type {BlockPreviewFilterState | null} */ (null); - let filterPromise = /** @type {Promise | null} */ (null); + let traitsController = /** @type {AbortController | null} */ (null); let traitsTimer = 0; element.dataset.blockPreviewTransaction = ""; @@ -60,6 +59,8 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { function abortPending() { clearTimeout(traitsTimer); + traitsController?.abort(); + traitsController = null; txidLoader.abort(); } @@ -87,13 +88,14 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { } } - /** @param {number} mask */ - function setTraits(mask) { + /** @param {string[]} keys */ + function setTraits(keys) { invalidateReadoutSize(); + const activeKeys = new Set(keys); for (const { filters, value } of traitFields) { const labels = filters - .filter(({ bit }) => mask & bit) + .filter(({ key }) => activeKeys.has(key)) .map(({ label }) => label); const text = labels.join(" ยท ") || "none"; @@ -102,46 +104,33 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { } } - /** @param {BlockPreviewTransaction} transaction */ - function setTransactionTraits(transaction) { - const state = /** @type {BlockPreviewFilterState} */ (filterState); - const mask = state.masks[transaction.txIndex - state.start]; - - setTraits(mask); - if (point !== null) place(point); - } - - function loadFilterState() { - if (filterState !== null || filterPromise !== null) return; - - filterPromise = loadFilters() - .then((state) => { - filterState = state; - filterPromise = null; - if (inspected !== null) setTransactionTraits(inspected); - }) - .catch((error) => { - 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(); + const controller = new AbortController(); + const signal = AbortSignal.any([parentSignal, controller.signal]); + + traitsController = controller; + void filterData.loadTransactionFilters(transaction.txIndex, signal) + .then((keys) => { + if (inspected !== transaction) return; + + setTraits(keys); + if (point !== null) place(point); + }) + .catch((error) => { + if (signal.aborted || inspected !== transaction) return; + + for (const { value } of traitFields) setField(value, "unavailable"); + console.error(error); + }) + .finally(() => { + if (traitsController === controller) traitsController = null; + }); }, eager ? 0 : TRAITS_DWELL_MS); } @@ -210,7 +199,7 @@ export function createBlockPreviewInspector(parentSignal, loadFilters) { } /** @typedef {import("./data.js").BlockPreviewTransaction} BlockPreviewTransaction */ -/** @typedef {import("./data.js").BlockPreviewFilterState} BlockPreviewFilterState */ +/** @typedef {import("./filters/data.js").BlockPreviewFilterData} BlockPreviewFilterData */ /** * @typedef {Object} BlockPreviewPointer diff --git a/website_next/utils/client.js b/website_next/utils/client.js index 289ddeb05..dd3a6ff1e 100644 --- a/website_next/utils/client.js +++ b/website_next/utils/client.js @@ -1,3 +1,3 @@ import { BrkClient } from "../modules/brk-client/index.js"; -export const brk = new BrkClient("https://bitview.space"); +export const brk = new BrkClient("http://localhost:3110"); diff --git a/website_next/utils/colors.js b/website_next/utils/colors.js index e95a7b47d..d60f668a2 100644 --- a/website_next/utils/colors.js +++ b/website_next/utils/colors.js @@ -25,6 +25,7 @@ export const txColors = /** @type {const} */ ({ v1: colors.blue(), v2: colors.violet(), v3: colors.fuchsia(), + otherVersion: colors.gray(), rbf: colors.orange(), noRbf: colors.gray(), oneInput: colors.yellow(), @@ -42,4 +43,59 @@ export const txColors = /** @type {const} */ ({ opReturn: colors.red(), empty: colors.gray(), unknown: colors.gray(), + behavior: { + cpfpParent: colors.sky(), + cpfpChild: colors.blue(), + coinjoin: colors.violet(), + consolidation: colors.yellow(), + batchPayout: colors.green(), + }, + data: { + fakePubkey: colors.yellow(), + fakeScripthash: colors.amber(), + inscription: colors.fuchsia(), + annex: colors.violet(), + dust: colors.red(), + }, + sighash: { + all: colors.blue(), + none: colors.red(), + single: colors.orange(), + default: colors.violet(), + anyoneCanPay: colors.fuchsia(), + }, + policy: { + nonstandard: colors.red(), + }, + opReturnKind: { + runes: colors.orange(), + veriBlock: colors.blue(), + omni: colors.violet(), + stacks: colors.purple(), + blockstack: colors.indigo(), + colu: colors.cyan(), + openAssets: colors.sky(), + komodo: colors.amber(), + coinSpark: colors.yellow(), + poet: colors.fuchsia(), + docproof: colors.emerald(), + openTimestamps: colors.green(), + factom: colors.lime(), + eternityWall: colors.avocado(), + memo: colors.pink(), + bitproof: colors.rose(), + ascribe: colors.teal(), + stampery: colors.blue(), + epobc: colors.violet(), + bareHash: colors.white(), + text: colors.cyan(), + empty: colors.gray(), + unknown: colors.gray(), + }, + opReturnPolicy: { + standard: colors.green(), + oversized: colors.orange(), + multiple: colors.yellow(), + preV30Nonstandard: colors.red(), + }, });