global: opreturn part 3

This commit is contained in:
nym21
2026-07-18 21:34:21 +02:00
parent 10c16335ed
commit 5161ae2012
16 changed files with 912 additions and 412 deletions
+3 -157
View File
@@ -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<number, number>} */ ({}));
/**
* @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
*/
@@ -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<string, BlockPreviewFilterSource>} */ (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<string, BlockPreviewFilter>} */ (
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<Uint32Array> | 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<unknown[]>} */ (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<Uint8Array>} */ (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<typeof createBlockPreviewFilterData>} 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
*/
@@ -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<string>} 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<BlockPreviewFilterState>} 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
*/
@@ -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<string, number>} */ (
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 */
@@ -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<OpReturnRows> | 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 */
@@ -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,
],
]);
@@ -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<Cell>[]}
*/
function resolveCapacityCells(cells, capacity, columns) {
const resolved = /** @type {ResolvedCapacityCell<Cell>[]} */ (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<Cell>[]} */ (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<typeof layouts>} */ (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
@@ -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 */
@@ -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 };
}
@@ -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
@@ -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 };
}
@@ -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 };
}
}
}
+8 -32
View File
@@ -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<T>} load
*/
function memoize(load) {
let promise = /** @type {Promise<T> | null} */ (null);
return () => {
promise ??= load().catch((error) => {
promise = null;
throw error;
});
return promise;
};
}
/**
* @param {BlockPreviewData} data
* @param {() => Promise<BlockPreviewFilterState>} 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
+30 -41
View File
@@ -36,9 +36,9 @@ function setField(element, value, loading = false) {
/**
* @param {AbortSignal} parentSignal
* @param {() => Promise<BlockPreviewFilterState>} 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<void> | 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
+1 -1
View File
@@ -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");
+56
View File
@@ -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(),
},
});