mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-10 02:28:13 -07:00
website_next: part 9
This commit is contained in:
@@ -46,6 +46,7 @@ export function createXyChart({
|
||||
let currentSeries = [];
|
||||
/** @type {ChartFrame | undefined} */
|
||||
let currentFrame;
|
||||
let renderFrame = 0;
|
||||
const pointer = createChartPointer(
|
||||
svg,
|
||||
() => currentFrame?.height,
|
||||
@@ -133,12 +134,17 @@ export function createXyChart({
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
cancelAnimationFrame(renderFrame);
|
||||
renderFrame = 0;
|
||||
pointer.cancel();
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
render();
|
||||
requestAnimationFrame(render);
|
||||
renderFrame = requestAnimationFrame(() => {
|
||||
renderFrame = 0;
|
||||
render();
|
||||
});
|
||||
resizeObserver.observe(svg);
|
||||
svg.addEventListener("pointerenter", pointer.measure);
|
||||
svg.addEventListener("pointermove", pointer.update);
|
||||
@@ -146,9 +152,11 @@ export function createXyChart({
|
||||
pointer.cancel();
|
||||
hideMarker();
|
||||
});
|
||||
figure.addEventListener("chart:destroy", disconnect, { once: true });
|
||||
|
||||
return figure;
|
||||
return /** @type {const} */ ({
|
||||
destroy: disconnect,
|
||||
element: figure,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ function closeOnBackdrop(event) {
|
||||
* @param {HTMLElement} host
|
||||
*/
|
||||
export function openDialog(dialog, host) {
|
||||
dialog.dataset.dialog = "";
|
||||
host.append(dialog);
|
||||
dialog.addEventListener("close", async () => {
|
||||
await waitForTransition();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
dialog {
|
||||
dialog[data-dialog] {
|
||||
--dialog-space: 1.5rem;
|
||||
|
||||
color-scheme: light;
|
||||
|
||||
+2
-2
@@ -4,9 +4,9 @@ import {
|
||||
formatEpoch,
|
||||
formatNumber,
|
||||
getEpochProgress,
|
||||
} from "./format.js";
|
||||
} from "../format.js";
|
||||
|
||||
/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */
|
||||
/** @typedef {import("../../../modules/brk-client/index.js").BlockInfoV1} Block */
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
@@ -1,11 +1,9 @@
|
||||
#block-details section[data-group="difficulty"] {
|
||||
[data-metric-list] {
|
||||
--section-color: var(--orange);
|
||||
|
||||
[data-metric-list] {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
|
||||
[data-metric-stat] {
|
||||
display: grid;
|
||||
|
||||
+9
-9
@@ -1,9 +1,9 @@
|
||||
import { createXyChart } from "../../chart/xy/index.js";
|
||||
import { createChartPoint, createChartPoints } from "../../chart/points.js";
|
||||
import { createLinearXScale } from "../../chart/x.js";
|
||||
import { createValueYScale } from "../../chart/y.js";
|
||||
import { formatFeeRate } from "../../utils/fee-rate.js";
|
||||
import { FEE_RATE_PERCENTILES, FEE_RATE_STOPS } from "./fee-rates.js";
|
||||
import { createXyChart } from "../../../chart/xy/index.js";
|
||||
import { createChartPoint, createChartPoints } from "../../../chart/points.js";
|
||||
import { createLinearXScale } from "../../../chart/x.js";
|
||||
import { createValueYScale } from "../../../chart/y.js";
|
||||
import { formatFeeRate } from "../../../utils/fee-rate.js";
|
||||
import { FEE_RATE_PERCENTILES, FEE_RATE_STOPS } from "../fee-rates.js";
|
||||
|
||||
const VIEWBOX_HEIGHT = 180;
|
||||
const FEE_AVERAGE_COLOR = "var(--white)";
|
||||
@@ -136,7 +136,7 @@ function plotSeries(percentileSamples, entries, frame) {
|
||||
export function createFeeChart(values, averageRate) {
|
||||
const percentileSamples = createPercentileSamples(values);
|
||||
const entries = createEntries(percentileSamples, averageRate);
|
||||
const figure = createXyChart({
|
||||
const chart = createXyChart({
|
||||
title: "Fees",
|
||||
unit: {
|
||||
id: "sat/vB",
|
||||
@@ -152,9 +152,9 @@ export function createFeeChart(values, averageRate) {
|
||||
marker: false,
|
||||
});
|
||||
|
||||
figure.dataset.feeChart = "";
|
||||
chart.element.dataset.feeChart = "";
|
||||
|
||||
return figure;
|
||||
return chart;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1,5 +1,3 @@
|
||||
#block-details section[data-group="fees"] {
|
||||
figure[data-fee-chart] {
|
||||
--chart-xy-height: 7.5rem;
|
||||
}
|
||||
figure[data-fee-chart] {
|
||||
--chart-xy-height: 7.5rem;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createUsdAmount, renderUsdAmount } from "../../usd/index.js";
|
||||
import { formatDateTime } from "./format.js";
|
||||
import { createBlockTitle } from "./title.js";
|
||||
import { createUsdAmount, renderUsdAmount } from "../../../usd/index.js";
|
||||
import { formatDateTime } from "../format.js";
|
||||
import { createBlockTitle } from "../title/index.js";
|
||||
|
||||
/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */
|
||||
/** @typedef {import("../../../modules/brk-client/index.js").BlockInfoV1} Block */
|
||||
|
||||
/** @param {string} hash */
|
||||
function createHashElement(hash) {
|
||||
@@ -35,6 +35,8 @@ export function createBlockHeader(actions = []) {
|
||||
tone: "positive",
|
||||
});
|
||||
|
||||
element.dataset.blockHeader = "";
|
||||
title.dataset.blockTitleText = "";
|
||||
main.dataset.blockMain = "";
|
||||
titleRow.dataset.blockTitle = "";
|
||||
side.dataset.blockSide = "";
|
||||
@@ -0,0 +1,70 @@
|
||||
[data-block-header] {
|
||||
--block-title-height-color: var(--gray);
|
||||
--block-title-height-line-height: var(--line-height-lg);
|
||||
--block-title-height-size: var(--font-size-lg);
|
||||
--block-title-label-size: 2.5rem;
|
||||
|
||||
display: grid;
|
||||
padding-bottom: 1.25rem;
|
||||
|
||||
[data-dim] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
[data-block-title] {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.35rem 1rem;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-block-main] {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-block-date],
|
||||
[data-block-hash-line] {
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
[data-block-date] {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
[data-block-side] {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
justify-items: end;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
[data-block-actions] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
|
||||
button {
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-receipt-button] {
|
||||
padding: 0.25rem 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-block-hash-line] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createBlockHeader } from "./header.js";
|
||||
import { createMinerPane } from "./miner.js";
|
||||
import { createDifficultyPane } from "./difficulty.js";
|
||||
import { createRewardsPane } from "./rewards.js";
|
||||
import { createTransactionPane } from "./transactions.js";
|
||||
import { createFeeChart } from "./fee-chart.js";
|
||||
import { createBlockHeader } from "./header/index.js";
|
||||
import { createMinerPane } from "./miner/index.js";
|
||||
import { createDifficultyPane } from "./difficulty/index.js";
|
||||
import { createRewardsPane } from "./rewards/index.js";
|
||||
import { createTransactionPane } from "./transactions/index.js";
|
||||
import { createFeeChart } from "./fee-chart/index.js";
|
||||
import { createBlockPreviewPane } from "./preview/index.js";
|
||||
import { appendPane } from "./pane.js";
|
||||
import { createBlockReceipt } from "./receipt.js";
|
||||
import { createBlockReceipt } from "./receipt/index.js";
|
||||
|
||||
function noop() {}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function createBlockDetails() {
|
||||
const header = createBlockHeader([receipt.button]);
|
||||
const content = document.createElement("div");
|
||||
let destroyPreview = noop;
|
||||
let destroyFeeChart = noop;
|
||||
|
||||
element.id = "block-details";
|
||||
element.hidden = true;
|
||||
@@ -33,10 +34,9 @@ export function createBlockDetails() {
|
||||
function clearContent() {
|
||||
destroyPreview();
|
||||
destroyPreview = noop;
|
||||
destroyFeeChart();
|
||||
destroyFeeChart = noop;
|
||||
|
||||
for (const chart of content.querySelectorAll("[data-fee-chart]")) {
|
||||
chart.dispatchEvent(new Event("chart:destroy"));
|
||||
}
|
||||
content.textContent = "";
|
||||
}
|
||||
|
||||
@@ -51,10 +51,12 @@ export function createBlockDetails() {
|
||||
clearContent();
|
||||
|
||||
const preview = createBlockPreviewPane(block);
|
||||
const feeChart = createFeeChart(extras.feeRange, extras.avgFeeRate);
|
||||
const left = createColumn("main");
|
||||
const right = createColumn("side");
|
||||
|
||||
destroyPreview = preview.destroy;
|
||||
destroyFeeChart = feeChart.destroy;
|
||||
appendPane(left, "preview", [
|
||||
createTransactionPane(block),
|
||||
preview.element,
|
||||
@@ -62,9 +64,7 @@ export function createBlockDetails() {
|
||||
appendPane(right, "mining", [createMinerPane(block)]);
|
||||
appendPane(right, "rewards", [createRewardsPane(extras)]);
|
||||
appendPane(right, "difficulty", [createDifficultyPane(block)]);
|
||||
appendPane(right, "fees", [
|
||||
createFeeChart(extras.feeRange, extras.avgFeeRate),
|
||||
]);
|
||||
appendPane(right, "fees", [feeChart.element]);
|
||||
content.append(left, right);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createPoolLogo } from "../../pools/index.js";
|
||||
import { formatPoolBlockNumber, getCoinbaseMessage } from "./format.js";
|
||||
import { createPoolLogo } from "../../../pools/index.js";
|
||||
import { formatPoolBlockNumber, getCoinbaseMessage } from "../format.js";
|
||||
|
||||
/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */
|
||||
/** @typedef {import("../../../modules/brk-client/index.js").BlockInfoV1} Block */
|
||||
|
||||
/** @param {string} raw */
|
||||
function createCoinbaseMessage(raw) {
|
||||
@@ -31,11 +31,6 @@ export function createMinerPane(block) {
|
||||
const coinbaseMessage = createCoinbaseMessage(block.extras.coinbaseSignatureAscii);
|
||||
|
||||
pane.dataset.minerPane = "";
|
||||
head.dataset.minerHead = "";
|
||||
identity.dataset.minerIdentity = "";
|
||||
title.dataset.minerTitle = "";
|
||||
slug.dataset.minerSlug = "";
|
||||
logo.dataset.minerLogo = "";
|
||||
|
||||
name.textContent = pool.name;
|
||||
blockNumber.textContent = formatPoolBlockNumber(pool.blockNumber);
|
||||
@@ -1,64 +1,54 @@
|
||||
#block-details section[data-group="mining"] {
|
||||
[data-miner-pane] {
|
||||
--section-color: var(--orange);
|
||||
|
||||
[data-miner-pane] {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
|
||||
[data-miner-head] {
|
||||
> div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-miner-identity] {
|
||||
display: grid;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-miner-title] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
|
||||
> strong {
|
||||
> div {
|
||||
display: grid;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--white);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-strong);
|
||||
line-height: var(--line-height-sm);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--white);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-strong);
|
||||
line-height: var(--line-height-sm);
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--gray);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
}
|
||||
}
|
||||
|
||||
> span {
|
||||
color: var(--gray);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
img {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
[data-miner-slug] {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--gray);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
}
|
||||
|
||||
[data-miner-logo] {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
[data-coinbase-message] {
|
||||
> p {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--white);
|
||||
|
||||
@@ -1,43 +1,237 @@
|
||||
import { brk } from "../../../utils/client.js";
|
||||
import { FILTERS, getFilterBit, getTypeBit } from "./filters/model.js";
|
||||
|
||||
const VERSION_BITS = new Map([
|
||||
[1, getFilterBit("version:1")],
|
||||
[2, getFilterBit("version:2")],
|
||||
[3, getFilterBit("version:3")],
|
||||
]);
|
||||
|
||||
const RBF_YES_BIT = getFilterBit("rbf:yes");
|
||||
const RBF_NO_BIT = getFilterBit("rbf:no");
|
||||
const INPUT_ONE_BIT = getFilterBit("input:one");
|
||||
const INPUT_MULTI_BIT = getFilterBit("input:multi");
|
||||
const OUTPUT_ONE_BIT = getFilterBit("output:one");
|
||||
const OUTPUT_MULTI_BIT = getFilterBit("output:multi");
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {{
|
||||
* slice: (
|
||||
* start: number,
|
||||
* end: number,
|
||||
* ) => {
|
||||
* fetch: (
|
||||
* options: { signal: AbortSignal, memCache: false },
|
||||
* ) => Promise<{ data: T[] }>,
|
||||
* },
|
||||
* }} series
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @param {AbortSignal} signal
|
||||
* @returns {Promise<{ data: T[] }>}
|
||||
*/
|
||||
function fetchSeriesSlice(series, start, end, signal) {
|
||||
return series.slice(start, end).fetch({ signal, memCache: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../../../modules/brk-client/index.js").BlockInfoV1} block
|
||||
* @param {AbortSignal} signal
|
||||
*/
|
||||
export async function loadBlockPreview(block) {
|
||||
async function loadBlockPreviewRange(block, signal) {
|
||||
const firstTxIndex = (
|
||||
await brk.series.transactions.raw.firstTxIndex.by.height
|
||||
.get(block.height)
|
||||
.fetch()
|
||||
await fetchSeriesSlice(
|
||||
brk.series.transactions.raw.firstTxIndex.by.height,
|
||||
block.height,
|
||||
block.height + 1,
|
||||
signal,
|
||||
)
|
||||
).data[0];
|
||||
|
||||
signal.throwIfAborted();
|
||||
|
||||
const start = /** @type {number} */ (firstTxIndex);
|
||||
const end = start + block.txCount;
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../../../modules/brk-client/index.js").BlockInfoV1} block
|
||||
* @param {AbortSignal} signal
|
||||
*/
|
||||
export async function loadBlockPreview(block, signal) {
|
||||
const { start, end } = await loadBlockPreviewRange(block, signal);
|
||||
const tx = brk.series.transactions;
|
||||
const [weights, feeRates] = await Promise.all([
|
||||
fetchSeriesSlice(tx.size.weight.txIndex.by.tx_index, start, end, signal),
|
||||
fetchSeriesSlice(tx.fees.feeRate.by.tx_index, start, end, signal),
|
||||
]);
|
||||
|
||||
signal.throwIfAborted();
|
||||
|
||||
return {
|
||||
range: { start, end },
|
||||
transactions: weights.data.map((weight, index) => ({
|
||||
txIndex: start + index,
|
||||
weight: /** @type {number} */ (weight),
|
||||
feeRate: /** @type {number} */ (feeRates.data[index]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} txIndex
|
||||
* @param {AbortSignal} signal
|
||||
*/
|
||||
export async function loadBlockPreviewTxid(txIndex, signal) {
|
||||
const txid = (
|
||||
await brk.series.transactions.raw.txid.by.tx_index
|
||||
.get(txIndex)
|
||||
.fetch({ signal, memCache: false })
|
||||
).data[0];
|
||||
|
||||
signal.throwIfAborted();
|
||||
|
||||
return /** @type {string} */ (txid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[]} starts
|
||||
* @param {number[]} counts
|
||||
*/
|
||||
function rangeEnd(starts, counts) {
|
||||
const last = starts.length - 1;
|
||||
|
||||
return starts[last] + counts[last];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint32Array} counts
|
||||
* @param {number} mask
|
||||
*/
|
||||
function countMask(counts, mask) {
|
||||
for (const { bit, index } of FILTERS) {
|
||||
if (mask & bit) counts[index] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {readonly string[]} types
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
*/
|
||||
function getTypesMask(types, start, end) {
|
||||
let mask = 0;
|
||||
|
||||
for (let index = start; index < end; index += 1) {
|
||||
mask |= getTypeBit(types[index]);
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BlockPreviewRange} range
|
||||
* @param {AbortSignal} signal
|
||||
*/
|
||||
export async function loadBlockPreviewFilters(range, signal) {
|
||||
const { start, end } = range;
|
||||
const tx = brk.series.transactions;
|
||||
const indexes = brk.series.indexes.txIndex;
|
||||
const [
|
||||
txids,
|
||||
versions,
|
||||
weights,
|
||||
feeRates,
|
||||
rbfs,
|
||||
inputCounts,
|
||||
outputCounts,
|
||||
firstInputs,
|
||||
firstOutputs,
|
||||
] = await Promise.all([
|
||||
tx.raw.txid.by.tx_index.slice(start, end).fetch(),
|
||||
tx.raw.txVersion.by.tx_index.slice(start, end).fetch(),
|
||||
tx.size.weight.txIndex.by.tx_index.slice(start, end).fetch(),
|
||||
tx.fees.feeRate.by.tx_index.slice(start, end).fetch(),
|
||||
tx.raw.isExplicitlyRbf.by.tx_index.slice(start, end).fetch(),
|
||||
indexes.inputCount.by.tx_index.slice(start, end).fetch(),
|
||||
indexes.outputCount.by.tx_index.slice(start, end).fetch(),
|
||||
fetchSeriesSlice(tx.raw.txVersion.by.tx_index, start, end, signal),
|
||||
fetchSeriesSlice(tx.raw.isExplicitlyRbf.by.tx_index, start, end, signal),
|
||||
fetchSeriesSlice(indexes.inputCount.by.tx_index, start, end, signal),
|
||||
fetchSeriesSlice(indexes.outputCount.by.tx_index, start, end, signal),
|
||||
fetchSeriesSlice(tx.raw.firstTxinIndex.by.tx_index, start, end, signal),
|
||||
fetchSeriesSlice(tx.raw.firstTxoutIndex.by.tx_index, start, end, signal),
|
||||
]);
|
||||
|
||||
return txids.data.map((txid, index) => ({
|
||||
txid,
|
||||
version: versions.data[index],
|
||||
weight: weights.data[index],
|
||||
feeRate: feeRates.data[index],
|
||||
rbf: rbfs.data[index],
|
||||
inputCount: inputCounts.data[index],
|
||||
outputCount: outputCounts.data[index],
|
||||
}));
|
||||
signal.throwIfAborted();
|
||||
|
||||
const inputStart = /** @type {number} */ (firstInputs.data[0]);
|
||||
const outputStart = /** @type {number} */ (firstOutputs.data[0]);
|
||||
const [inputTypes, outputTypes] = await Promise.all([
|
||||
fetchSeriesSlice(
|
||||
brk.series.inputs.raw.outputType.by.txin_index,
|
||||
inputStart,
|
||||
rangeEnd(
|
||||
/** @type {number[]} */ (firstInputs.data),
|
||||
/** @type {number[]} */ (inputCounts.data),
|
||||
),
|
||||
signal,
|
||||
),
|
||||
fetchSeriesSlice(
|
||||
brk.series.outputs.raw.outputType.by.txout_index,
|
||||
outputStart,
|
||||
rangeEnd(
|
||||
/** @type {number[]} */ (firstOutputs.data),
|
||||
/** @type {number[]} */ (outputCounts.data),
|
||||
),
|
||||
signal,
|
||||
),
|
||||
]);
|
||||
|
||||
signal.throwIfAborted();
|
||||
|
||||
const masks = new Uint32Array(end - start);
|
||||
const counts = new Uint32Array(FILTERS.length);
|
||||
|
||||
versions.data.forEach((version, index) => {
|
||||
const inputCount = /** @type {number} */ (inputCounts.data[index]);
|
||||
const outputCount = /** @type {number} */ (outputCounts.data[index]);
|
||||
const inputTypeStart = /** @type {number} */ (firstInputs.data[index]);
|
||||
const inputTypeEnd = inputTypeStart + inputCount;
|
||||
const outputTypeStart = /** @type {number} */ (firstOutputs.data[index]);
|
||||
const outputTypeEnd = outputTypeStart + outputCount;
|
||||
const mask =
|
||||
(VERSION_BITS.get(/** @type {number} */ (version)) ?? 0) |
|
||||
(rbfs.data[index] ? RBF_YES_BIT : RBF_NO_BIT) |
|
||||
(inputCount === 1 ? INPUT_ONE_BIT : INPUT_MULTI_BIT) |
|
||||
(outputCount === 1 ? OUTPUT_ONE_BIT : OUTPUT_MULTI_BIT) |
|
||||
getTypesMask(
|
||||
/** @type {string[]} */ (inputTypes.data),
|
||||
inputTypeStart - inputStart,
|
||||
inputTypeEnd - inputStart,
|
||||
) |
|
||||
getTypesMask(
|
||||
/** @type {string[]} */ (outputTypes.data),
|
||||
outputTypeStart - outputStart,
|
||||
outputTypeEnd - outputStart,
|
||||
);
|
||||
|
||||
masks[index] = mask;
|
||||
countMask(counts, mask);
|
||||
});
|
||||
|
||||
return { counts, masks, start };
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} BlockPreviewRange
|
||||
* @property {number} start
|
||||
* @property {number} end
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BlockPreviewTransaction
|
||||
* @property {number} txIndex
|
||||
* @property {number} weight
|
||||
* @property {number} feeRate
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BlockPreviewFilterState
|
||||
* @property {number} start
|
||||
* @property {Uint32Array} masks
|
||||
* @property {Uint32Array} counts
|
||||
*/
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import {
|
||||
appendLegendListItem,
|
||||
createLegendItem,
|
||||
createLegendList,
|
||||
} from "../../../legend/index.js";
|
||||
import { formatNumber } from "../format.js";
|
||||
|
||||
/** @param {BlockPreviewTransaction} tx */
|
||||
function isVersion1(tx) {
|
||||
return tx.version === 1;
|
||||
}
|
||||
|
||||
/** @param {BlockPreviewTransaction} tx */
|
||||
function isVersion2(tx) {
|
||||
return tx.version === 2;
|
||||
}
|
||||
|
||||
/** @param {BlockPreviewTransaction} tx */
|
||||
function isVersion3(tx) {
|
||||
return tx.version === 3;
|
||||
}
|
||||
|
||||
/** @param {BlockPreviewTransaction} tx */
|
||||
function isRbf(tx) {
|
||||
return tx.rbf;
|
||||
}
|
||||
|
||||
/** @param {BlockPreviewTransaction} tx */
|
||||
function isNotRbf(tx) {
|
||||
return !tx.rbf;
|
||||
}
|
||||
|
||||
/** @param {BlockPreviewTransaction} tx */
|
||||
function hasOneInput(tx) {
|
||||
return tx.inputCount === 1;
|
||||
}
|
||||
|
||||
/** @param {BlockPreviewTransaction} tx */
|
||||
function hasManyInputs(tx) {
|
||||
return tx.inputCount !== 1;
|
||||
}
|
||||
|
||||
/** @param {BlockPreviewTransaction} tx */
|
||||
function hasOneOutput(tx) {
|
||||
return tx.outputCount === 1;
|
||||
}
|
||||
|
||||
/** @param {BlockPreviewTransaction} tx */
|
||||
function hasManyOutputs(tx) {
|
||||
return tx.outputCount !== 1;
|
||||
}
|
||||
|
||||
const FILTERS = /** @type {const} */ ([
|
||||
{ group: "version", label: "tx v1", key: "version:1", match: isVersion1 },
|
||||
{ group: "version", label: "tx v2", key: "version:2", match: isVersion2 },
|
||||
{ group: "version", label: "tx v3", key: "version:3", match: isVersion3 },
|
||||
{ group: "rbf", label: "rbf", key: "rbf:yes", match: isRbf },
|
||||
{ group: "rbf", label: "no rbf", key: "rbf:no", match: isNotRbf },
|
||||
{ group: "input", label: "1 in", key: "input:one", match: hasOneInput },
|
||||
{ group: "input", label: "multi in", key: "input:multi", match: hasManyInputs },
|
||||
{ group: "output", label: "1 out", key: "output:one", match: hasOneOutput },
|
||||
{ group: "output", label: "multi out", key: "output:multi", match: hasManyOutputs },
|
||||
]);
|
||||
|
||||
const FILTER_GROUPS = /** @type {const} */ ([
|
||||
"version",
|
||||
"rbf",
|
||||
"input",
|
||||
"output",
|
||||
]);
|
||||
|
||||
/** @param {number} version */
|
||||
export function getVersionKey(version) {
|
||||
return `version:${version}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BlockPreviewTransaction} transaction
|
||||
*/
|
||||
export function getFilterKeys(transaction) {
|
||||
return FILTERS
|
||||
.filter(({ match }) => match(transaction))
|
||||
.map(({ key }) => key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BlockPreviewTransaction[]} transactions
|
||||
*/
|
||||
function countFilters(transactions) {
|
||||
return new Map(FILTERS.map(({ key, match }) => {
|
||||
return [key, transactions.filter(match).length];
|
||||
}));
|
||||
}
|
||||
|
||||
function createActiveFilters() {
|
||||
return new Map(FILTER_GROUPS.map((group) => {
|
||||
const keys = FILTERS
|
||||
.filter((filter) => filter.group === group)
|
||||
.map(({ key }) => String(key));
|
||||
|
||||
return [group, new Set(keys)];
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} keys
|
||||
* @param {Map<string, Set<string>>} activeFilters
|
||||
*/
|
||||
function matchesActiveFilters(keys, activeFilters) {
|
||||
for (const group of FILTER_GROUPS) {
|
||||
const activeKeys = activeFilters.get(group);
|
||||
|
||||
if ([...activeKeys ?? []].some((key) => keys.includes(key))) continue;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLButtonElement} button
|
||||
* @param {boolean} active
|
||||
*/
|
||||
function setActive(button, active) {
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
button.toggleAttribute("data-muted", !active);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLButtonElement} button
|
||||
*/
|
||||
function setPending(button) {
|
||||
button.disabled = true;
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
button.removeAttribute("data-muted");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BlockPreviewTransaction[]} transactions
|
||||
* @param {HTMLElement | null} heatmap
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.pending]
|
||||
*/
|
||||
export function createPreviewFilters(transactions, heatmap, options = {}) {
|
||||
const list = createLegendList({ scroll: true });
|
||||
const counts = countFilters(transactions);
|
||||
const pending = options.pending === true;
|
||||
const activeFilters = createActiveFilters();
|
||||
|
||||
function updateCells() {
|
||||
const cells = /** @type {HTMLElement[]} */ ([
|
||||
...heatmap?.querySelectorAll("[data-heatmap-cell]") ?? [],
|
||||
]);
|
||||
|
||||
for (const cell of cells) {
|
||||
const keys = cell.dataset.heatmapGroups?.split(" ") ?? [];
|
||||
const active = matchesActiveFilters(keys, activeFilters);
|
||||
|
||||
cell.toggleAttribute("data-muted", !active);
|
||||
}
|
||||
}
|
||||
|
||||
for (const filter of FILTERS) {
|
||||
const count = counts.get(filter.key) ?? 0;
|
||||
const { button, value } = createLegendItem({
|
||||
label: filter.label,
|
||||
color: "var(--white)",
|
||||
ariaLabel: filter.label,
|
||||
});
|
||||
|
||||
value.textContent = pending ? "..." : formatNumber(count);
|
||||
if (pending) {
|
||||
setPending(button);
|
||||
} else {
|
||||
button.addEventListener("click", () => {
|
||||
const active = button.getAttribute("aria-pressed") !== "true";
|
||||
const activeKeys = /** @type {Set<string>} */ (
|
||||
activeFilters.get(filter.group)
|
||||
);
|
||||
|
||||
setActive(button, active);
|
||||
if (active) activeKeys.add(filter.key);
|
||||
else activeKeys.delete(filter.key);
|
||||
updateCells();
|
||||
});
|
||||
setActive(button, true);
|
||||
}
|
||||
appendLegendListItem(list, button);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/** @typedef {import("./fees.js").BlockPreviewTransaction} BlockPreviewTransaction */
|
||||
@@ -0,0 +1,235 @@
|
||||
import { createLegendItem } from "../../../../legend/index.js";
|
||||
import { formatNumber } from "../../format.js";
|
||||
import { FILTER_GROUP_LABELS, FILTER_GROUPS, FILTERS } from "./model.js";
|
||||
|
||||
const SUMMARY_LABEL_COUNT = 3;
|
||||
const CAN_HOVER = matchMedia("(hover: hover) and (pointer: fine)");
|
||||
|
||||
/**
|
||||
* @param {HTMLButtonElement} button
|
||||
* @param {boolean} active
|
||||
*/
|
||||
function setActive(button, active) {
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
button.toggleAttribute("data-muted", !active);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLButtonElement} button
|
||||
*/
|
||||
function setPending(button) {
|
||||
button.disabled = true;
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
button.removeAttribute("data-muted");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} labels
|
||||
*/
|
||||
function formatSummaryValue(labels) {
|
||||
const visible = labels.slice(0, SUMMARY_LABEL_COUNT);
|
||||
const hidden = labels.length - visible.length;
|
||||
const suffix = hidden > 0 ? ` ... +${hidden}` : "";
|
||||
|
||||
return `${visible.join(", ")}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} disabledMask
|
||||
* @param {HTMLElement} value
|
||||
*/
|
||||
function updateSummaryValue(disabledMask, value) {
|
||||
const labels = FILTERS
|
||||
.filter(({ bit }) => disabledMask & bit)
|
||||
.map(({ group, label }) => {
|
||||
return `${FILTER_GROUP_LABELS.get(group)} ${label}`;
|
||||
});
|
||||
|
||||
value.textContent = labels.length > 0
|
||||
? `hidden ${formatSummaryValue(labels)}`
|
||||
: "NONE";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
*/
|
||||
function createFilterGroup(label) {
|
||||
const group = document.createElement("div");
|
||||
const title = document.createElement("span");
|
||||
|
||||
title.textContent = label;
|
||||
group.append(title);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} panel
|
||||
* @param {HTMLElement} summary
|
||||
*/
|
||||
function clearGroups(panel, summary) {
|
||||
panel.replaceChildren(summary);
|
||||
}
|
||||
|
||||
function createFilterPanel() {
|
||||
const panel = document.createElement("details");
|
||||
const summary = document.createElement("summary");
|
||||
const prefix = document.createElement("span");
|
||||
const value = document.createElement("span");
|
||||
|
||||
prefix.textContent = "filters:";
|
||||
panel.dataset.blockPreviewFilters = "";
|
||||
summary.append(prefix, value);
|
||||
panel.append(summary);
|
||||
|
||||
return { panel, summary, summaryValue: value };
|
||||
}
|
||||
|
||||
export function createPendingPreviewFilters() {
|
||||
const { panel, summaryValue } = createFilterPanel();
|
||||
|
||||
summaryValue.textContent = "loading";
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {() => Promise<BlockPreviewFilterState>} loadFilters
|
||||
* @param {BlockPreviewHeatmap} heatmap
|
||||
*/
|
||||
export function createPreviewFilters(loadFilters, heatmap) {
|
||||
const { panel, summary, summaryValue } = createFilterPanel();
|
||||
let disabledMask = 0;
|
||||
let live = true;
|
||||
let loading = false;
|
||||
let state = /** @type {BlockPreviewFilterState | null} */ (null);
|
||||
let previewButton = /** @type {HTMLButtonElement | null} */ (null);
|
||||
|
||||
updateSummaryValue(disabledMask, summaryValue);
|
||||
|
||||
function resetPreview() {
|
||||
previewButton?.removeAttribute("data-preview");
|
||||
previewButton = null;
|
||||
heatmap.previewDisabledMask(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLButtonElement} button
|
||||
* @param {number} nextMask
|
||||
*/
|
||||
function preview(button, nextMask) {
|
||||
resetPreview();
|
||||
previewButton = button;
|
||||
button.dataset.preview = "";
|
||||
heatmap.previewDisabledMask(nextMask);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BlockPreviewFilterState | null} filterState
|
||||
*/
|
||||
function renderFilters(filterState) {
|
||||
clearGroups(panel, summary);
|
||||
|
||||
for (const { key, label } of FILTER_GROUPS) {
|
||||
const group = createFilterGroup(label);
|
||||
|
||||
for (const filter of FILTERS.filter((item) => item.group === key)) {
|
||||
const { button, value } = createLegendItem({
|
||||
ariaLabel: filter.label,
|
||||
color: filter.color,
|
||||
label: filter.label,
|
||||
});
|
||||
|
||||
value.textContent = filterState === null
|
||||
? "..."
|
||||
: formatNumber(filterState.counts[filter.index]);
|
||||
|
||||
if (filterState === null) {
|
||||
setPending(button);
|
||||
} else {
|
||||
const canPreview = CAN_HOVER.matches;
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
const active = (disabledMask & filter.bit) === 0;
|
||||
|
||||
resetPreview();
|
||||
disabledMask = active
|
||||
? disabledMask | filter.bit
|
||||
: disabledMask & ~filter.bit;
|
||||
setActive(button, !active);
|
||||
updateSummaryValue(disabledMask, summaryValue);
|
||||
heatmap.setDisabledMask(disabledMask);
|
||||
});
|
||||
|
||||
if (canPreview) {
|
||||
button.addEventListener("pointerenter", () => {
|
||||
const active = (disabledMask & filter.bit) === 0;
|
||||
const nextMask = active
|
||||
? disabledMask | filter.bit
|
||||
: disabledMask & ~filter.bit;
|
||||
|
||||
preview(button, nextMask);
|
||||
});
|
||||
button.addEventListener("pointerleave", resetPreview);
|
||||
}
|
||||
|
||||
button.addEventListener("focus", () => {
|
||||
const active = (disabledMask & filter.bit) === 0;
|
||||
const nextMask = active
|
||||
? disabledMask | filter.bit
|
||||
: disabledMask & ~filter.bit;
|
||||
|
||||
preview(button, nextMask);
|
||||
});
|
||||
button.addEventListener("blur", resetPreview);
|
||||
setActive(button, (disabledMask & filter.bit) === 0);
|
||||
}
|
||||
|
||||
group.append(button);
|
||||
}
|
||||
|
||||
panel.append(group);
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (loading || state !== null) return;
|
||||
|
||||
loading = true;
|
||||
summaryValue.textContent = "loading";
|
||||
renderFilters(null);
|
||||
void loadFilters()
|
||||
.then((nextState) => {
|
||||
if (!live) return;
|
||||
|
||||
loading = false;
|
||||
state = nextState;
|
||||
heatmap.setFilterState(nextState);
|
||||
updateSummaryValue(disabledMask, summaryValue);
|
||||
renderFilters(nextState);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!live) return;
|
||||
|
||||
loading = false;
|
||||
summaryValue.textContent = "unavailable";
|
||||
clearGroups(panel, summary);
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
panel.addEventListener("toggle", () => {
|
||||
if (panel.open) load();
|
||||
});
|
||||
|
||||
return /** @type {const} */ ({
|
||||
destroy() {
|
||||
live = false;
|
||||
resetPreview();
|
||||
},
|
||||
element: panel,
|
||||
});
|
||||
}
|
||||
|
||||
/** @typedef {import("../data.js").BlockPreviewFilterState} BlockPreviewFilterState */
|
||||
/** @typedef {ReturnType<import("../heatmap/index.js").createBlockPreviewHeatmap>} BlockPreviewHeatmap */
|
||||
@@ -0,0 +1,74 @@
|
||||
import { txColors } from "../../../../utils/colors.js";
|
||||
|
||||
export const FILTER_GROUPS = /** @type {const} */ ([
|
||||
{ key: "version", label: "version" },
|
||||
{ key: "rbf", label: "rbf" },
|
||||
{ key: "input", label: "input" },
|
||||
{ key: "output", label: "output" },
|
||||
{ key: "type", label: "type" },
|
||||
]);
|
||||
|
||||
const FILTER_DEFS = /** @type {const} */ ([
|
||||
["version", "v1", "version:1", txColors.v1],
|
||||
["version", "v2", "version:2", txColors.v2],
|
||||
["version", "v3", "version:3", txColors.v3],
|
||||
["rbf", "yes", "rbf:yes", txColors.rbf],
|
||||
["rbf", "no", "rbf:no", txColors.noRbf],
|
||||
["input", "1", "input:one", txColors.oneInput],
|
||||
["input", "multi", "input:multi", txColors.multiInput],
|
||||
["output", "1", "output:one", txColors.oneOutput],
|
||||
["output", "multi", "output:multi", txColors.multiOutput],
|
||||
["type", "p2pk", "type:p2pk", txColors.p2pk],
|
||||
["type", "p2pkh", "type:p2pkh", txColors.p2pkh],
|
||||
["type", "p2sh", "type:p2sh", txColors.p2sh],
|
||||
["type", "p2wpkh", "type:p2wpkh", txColors.p2wpkh],
|
||||
["type", "p2wsh", "type:p2wsh", txColors.p2wsh],
|
||||
["type", "taproot", "type:taproot", txColors.taproot],
|
||||
["type", "p2a", "type:p2a", txColors.p2a],
|
||||
["type", "baremult", "type:multisig", txColors.baremult],
|
||||
["type", "op ret", "type:op_return", txColors.opReturn],
|
||||
["type", "empty", "type:empty", txColors.empty],
|
||||
["type", "unknown", "type:unknown", txColors.unknown],
|
||||
]);
|
||||
|
||||
export const FILTERS = FILTER_DEFS.map(([group, label, key, color], index) => {
|
||||
return /** @type {const} */ ({ bit: 1 << index, color, group, index, key, label });
|
||||
});
|
||||
|
||||
export const FILTER_GROUP_LABELS = new Map(FILTER_GROUPS.map(({ key, label }) => {
|
||||
return [key, label];
|
||||
}));
|
||||
|
||||
const FILTER_BITS = /** @type {Map<string, number>} */ (
|
||||
new Map(FILTERS.map(({ bit, key }) => [key, bit]))
|
||||
);
|
||||
|
||||
const TYPE_KEYS = /** @type {const} */ ({
|
||||
empty: "type:empty",
|
||||
multisig: "type:multisig",
|
||||
op_return: "type:op_return",
|
||||
p2a: "type:p2a",
|
||||
p2pk: "type:p2pk",
|
||||
p2pkh: "type:p2pkh",
|
||||
p2sh: "type:p2sh",
|
||||
unknown: "type:unknown",
|
||||
v0_p2wpkh: "type:p2wpkh",
|
||||
v0_p2wsh: "type:p2wsh",
|
||||
v1_p2tr: "type:taproot",
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
export function getFilterBit(key) {
|
||||
return FILTER_BITS.get(key) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
*/
|
||||
export function getTypeBit(type) {
|
||||
const key = TYPE_KEYS[/** @type {keyof typeof TYPE_KEYS} */ (type)];
|
||||
|
||||
return key === undefined ? 0 : getFilterBit(key);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
[data-block-preview-filters] {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
|
||||
> summary {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
color: var(--white);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-strong);
|
||||
line-height: var(--line-height-xs);
|
||||
list-style: none;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
|
||||
> span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
> span:first-child {
|
||||
flex: 0 0 auto;
|
||||
padding-right: 0.5rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
> span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
> summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&[open] > summary {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
&:not([open]) > div {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
|
||||
> span {
|
||||
flex: 0 0 auto;
|
||||
color: var(--gray);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-xs);
|
||||
padding-right: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
> [data-legend-item] {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
min-width: max-content;
|
||||
|
||||
> [data-legend-value] {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
margin-top: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-9
@@ -7,9 +7,8 @@ import { packCells } from "./pack.js";
|
||||
*/
|
||||
function weightToSpan(weight, capacity, columns) {
|
||||
const cellWeight = capacity / (columns * columns);
|
||||
const span = Math.sqrt(weight / cellWeight);
|
||||
|
||||
return Math.max(1, Math.round(span));
|
||||
return Math.max(1, Math.round(Math.sqrt(weight / cellWeight)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,7 +20,7 @@ function weightToSpan(weight, capacity, columns) {
|
||||
function resolveCapacityCells(cells, capacity, columns) {
|
||||
return cells.map((cell) => ({
|
||||
...cell,
|
||||
span: weightToSpan(cell.weight ?? 0, capacity, columns),
|
||||
span: weightToSpan(cell.weight, capacity, columns),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -47,7 +46,17 @@ function fitCapacityCells(cells, capacity, columns) {
|
||||
layouts = packCells(resolvedCells, columns, columns);
|
||||
}
|
||||
|
||||
return { resolvedCells, layouts };
|
||||
if (layouts === null) {
|
||||
resolvedCells = resolvedCells.slice(0, columns * columns);
|
||||
layouts = /** @type {NonNullable<typeof layouts>} */ (
|
||||
packCells(resolvedCells, columns, columns)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
layouts: /** @type {NonNullable<typeof layouts>} */ (layouts),
|
||||
resolvedCells,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,13 +66,10 @@ function fitCapacityCells(cells, capacity, columns) {
|
||||
* @param {number} columns
|
||||
*/
|
||||
export function createSquareLayout(cells, capacity, columns) {
|
||||
const { resolvedCells, layouts } = fitCapacityCells(cells, capacity, columns);
|
||||
|
||||
return { columns, resolvedCells, layouts: /** @type {NonNullable<typeof layouts>} */ (layouts) };
|
||||
return { columns, ...fitCapacityCells(cells, capacity, columns) };
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} CapacityCell
|
||||
* @property {number} span
|
||||
* @property {number | undefined} weight
|
||||
* @property {number} weight
|
||||
*/
|
||||
@@ -0,0 +1,120 @@
|
||||
import { FEE_RATE_STOPS } from "../../fee-rates.js";
|
||||
|
||||
let stops = /** @type {OklabColor[] | null} */ (null);
|
||||
|
||||
/** @param {number} value */
|
||||
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
||||
|
||||
/**
|
||||
* @param {string} css
|
||||
*/
|
||||
function parseOklch(css) {
|
||||
const [, rawLightness, percent, rawChroma, rawHue] = /** @type {RegExpMatchArray} */ (
|
||||
css.match(/oklch\(\s*([\d.]+)(%)?\s+([\d.]+)\s+([\d.]+)/)
|
||||
);
|
||||
const lightness = Number(rawLightness);
|
||||
const hue = Number(rawHue) * Math.PI / 180;
|
||||
const chroma = Number(rawChroma);
|
||||
|
||||
return {
|
||||
a: chroma * Math.cos(hue),
|
||||
b: chroma * Math.sin(hue),
|
||||
l: percent ? lightness / 100 : lightness,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} color
|
||||
*/
|
||||
function resolveColor(color) {
|
||||
const variable = color.match(/var\((--[^)]+)\)/)?.[1];
|
||||
|
||||
return variable === undefined
|
||||
? color
|
||||
: getComputedStyle(document.documentElement).getPropertyValue(variable);
|
||||
}
|
||||
|
||||
function getStops() {
|
||||
stops ??= FEE_RATE_STOPS.map(({ color }) => parseOklch(resolveColor(color)));
|
||||
|
||||
return stops;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} color
|
||||
*/
|
||||
export function getCanvasColor(color) {
|
||||
return `rgb(${toRgb(parseOklch(resolveColor(color))).join(" ")})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} linear
|
||||
*/
|
||||
function encodeRgb(linear) {
|
||||
const value = linear <= 0.0031308
|
||||
? 12.92 * linear
|
||||
: 1.055 * linear ** (1 / 2.4) - 0.055;
|
||||
|
||||
return Math.round(clamp01(value) * 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {OklabColor} color
|
||||
*/
|
||||
function toRgb(color) {
|
||||
const l = color.l + 0.3963377774 * color.a + 0.2158037573 * color.b;
|
||||
const m = color.l - 0.1055613458 * color.a - 0.0638541728 * color.b;
|
||||
const s = color.l - 0.0894841775 * color.a - 1.2914855480 * color.b;
|
||||
const l3 = l ** 3;
|
||||
const m3 = m ** 3;
|
||||
const s3 = s ** 3;
|
||||
|
||||
return [
|
||||
encodeRgb(+4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3),
|
||||
encodeRgb(-1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3),
|
||||
encodeRgb(-0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {OklabColor} start
|
||||
* @param {OklabColor} end
|
||||
* @param {number} ratio
|
||||
*/
|
||||
function mix(start, end, ratio) {
|
||||
return {
|
||||
a: start.a + (end.a - start.a) * ratio,
|
||||
b: start.b + (end.b - start.b) * ratio,
|
||||
l: start.l + (end.l - start.l) * ratio,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} feeRate
|
||||
* @param {number[]} ranges
|
||||
*/
|
||||
export function getCanvasFeeRateColor(feeRate, ranges) {
|
||||
const colors = getStops();
|
||||
|
||||
if (feeRate <= ranges[0]) return `rgb(${toRgb(colors[0]).join(" ")})`;
|
||||
|
||||
for (let index = 1; index < ranges.length; index += 1) {
|
||||
if (feeRate > ranges[index]) continue;
|
||||
|
||||
const previousRate = ranges[index - 1];
|
||||
const nextRate = ranges[index];
|
||||
const span = nextRate - previousRate;
|
||||
const ratio = span ? (feeRate - previousRate) / span : 0;
|
||||
|
||||
return `rgb(${toRgb(mix(colors[index - 1], colors[index], ratio)).join(" ")})`;
|
||||
}
|
||||
|
||||
return `rgb(${toRgb(colors[colors.length - 1]).join(" ")})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} OklabColor
|
||||
* @property {number} a
|
||||
* @property {number} b
|
||||
* @property {number} l
|
||||
*/
|
||||
+2
-11
@@ -1,4 +1,4 @@
|
||||
import { createFeeRateRange } from "../fee-rates.js";
|
||||
import { createFeeRateRange } from "../../fee-rates.js";
|
||||
|
||||
/**
|
||||
* @param {BlockPreviewTransaction[]} transactions
|
||||
@@ -15,13 +15,4 @@ export function orderTransactions(transactions) {
|
||||
.toSorted((a, b) => b.feeRate - a.feeRate || b.weight - a.weight);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} BlockPreviewTransaction
|
||||
* @property {string} txid
|
||||
* @property {number} version
|
||||
* @property {number} weight
|
||||
* @property {number} feeRate
|
||||
* @property {boolean} rbf
|
||||
* @property {number} inputCount
|
||||
* @property {number} outputCount
|
||||
*/
|
||||
/** @typedef {import("../data.js").BlockPreviewTransaction} BlockPreviewTransaction */
|
||||
@@ -0,0 +1,328 @@
|
||||
import { MAX_BLOCK_WEIGHT } from "../../format.js";
|
||||
import { createPreviewFeeRange, orderTransactions } from "./fees.js";
|
||||
import { createSquareLayout } from "./capacity.js";
|
||||
import { getCanvasColor, getCanvasFeeRateColor } from "./color.js";
|
||||
|
||||
const COLUMNS = 84;
|
||||
const MUTED_ALPHA = 0.12;
|
||||
const GAP_REFERENCE_WIDTH = 640;
|
||||
const HOVER_FILL_ALPHA = 0.18;
|
||||
const HOVER_MARKER_MIN_SIZE = 10;
|
||||
|
||||
/**
|
||||
* @param {number} count
|
||||
* @param {number} cell
|
||||
* @param {number} gap
|
||||
*/
|
||||
function unitsToPixels(count, cell, gap) {
|
||||
return count * cell + Math.max(0, count - 1) * gap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {number} width
|
||||
*/
|
||||
function getGap(canvas, width) {
|
||||
const maxGap = Number.parseFloat(
|
||||
getComputedStyle(canvas).getPropertyValue("--block-preview-heatmap-gap"),
|
||||
) || 0;
|
||||
|
||||
return Math.max(1, maxGap * Math.min(1, width / GAP_REFERENCE_WIDTH));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BlockPreviewTransaction} transaction
|
||||
* @param {BlockPreviewFilterState | null} filterState
|
||||
*/
|
||||
function getTransactionMask(transaction, filterState) {
|
||||
return filterState?.masks[transaction.txIndex - filterState.start] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} alpha
|
||||
* @param {string} color
|
||||
* @param {PreviewRect} rect
|
||||
*/
|
||||
function drawRect(context, alpha, color, rect) {
|
||||
context.globalAlpha = alpha;
|
||||
context.fillStyle = color;
|
||||
context.fillRect(rect.x, rect.y, rect.size, rect.size);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {PreviewRect} rect
|
||||
* @param {CanvasColors} colors
|
||||
*/
|
||||
function drawHover(context, rect, colors) {
|
||||
const size = Math.max(rect.size, HOVER_MARKER_MIN_SIZE);
|
||||
const x = rect.x + rect.size / 2 - size / 2;
|
||||
const y = rect.y + rect.size / 2 - size / 2;
|
||||
const width = Math.max(1, Math.min(3, size / 5));
|
||||
|
||||
context.globalAlpha = 1;
|
||||
context.fillStyle = colors.white;
|
||||
context.globalAlpha = HOVER_FILL_ALPHA;
|
||||
context.fillRect(x, y, size, size);
|
||||
context.globalAlpha = 1;
|
||||
context.lineJoin = "miter";
|
||||
context.lineWidth = width + 2;
|
||||
context.strokeStyle = colors.black;
|
||||
context.strokeRect(x, y, size, size);
|
||||
context.lineWidth = width;
|
||||
context.strokeStyle = colors.white;
|
||||
context.strokeRect(x, y, size, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PreviewRect[]} rects
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
function hitTest(rects, x, y) {
|
||||
for (let index = rects.length - 1; index >= 0; index -= 1) {
|
||||
const rect = rects[index];
|
||||
|
||||
if (
|
||||
x >= rect.x &&
|
||||
x <= rect.x + rect.size &&
|
||||
y >= rect.y &&
|
||||
y <= rect.y + rect.size
|
||||
) {
|
||||
return rect.transaction;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BlockPreviewTransaction[]} transactions
|
||||
* @param {Object} [options]
|
||||
* @param {(transaction: BlockPreviewTransaction | null, point: BlockPreviewPointer | null, eager: boolean) => void} [options.onInspect]
|
||||
*/
|
||||
export function createBlockPreviewHeatmap(transactions, options = {}) {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = /** @type {CanvasRenderingContext2D} */ (
|
||||
canvas.getContext("2d")
|
||||
);
|
||||
const ordered = orderTransactions(transactions);
|
||||
const ranges = createPreviewFeeRange(ordered);
|
||||
const cells = ordered.map((transaction) => ({
|
||||
color: getCanvasFeeRateColor(transaction.feeRate, ranges),
|
||||
transaction,
|
||||
weight: transaction.weight,
|
||||
}));
|
||||
const square = createSquareLayout(cells, MAX_BLOCK_WEIGHT, COLUMNS);
|
||||
const colors = {
|
||||
black: getCanvasColor("var(--black)"),
|
||||
white: getCanvasColor("var(--white)"),
|
||||
};
|
||||
let disabledMask = 0;
|
||||
let filterState = /** @type {BlockPreviewFilterState | null} */ (null);
|
||||
let frame = 0;
|
||||
let inspected = /** @type {BlockPreviewTransaction | null} */ (null);
|
||||
let previewMask = /** @type {number | null} */ (null);
|
||||
let rects = /** @type {PreviewRect[]} */ ([]);
|
||||
let capturedPointer = /** @type {number | null} */ (null);
|
||||
|
||||
canvas.dataset.blockPreviewHeatmap = "";
|
||||
|
||||
function draw() {
|
||||
const width = canvas.getBoundingClientRect().width;
|
||||
|
||||
if (width <= 0) return;
|
||||
|
||||
const dpr = globalThis.devicePixelRatio || 1;
|
||||
const size = Math.round(width * dpr);
|
||||
|
||||
if (canvas.width !== size || canvas.height !== size) {
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
}
|
||||
|
||||
const gap = getGap(canvas, width);
|
||||
const cell = Math.max(1, (width - gap * (square.columns - 1)) / square.columns);
|
||||
const unit = cell + gap;
|
||||
const activeMask = previewMask ?? disabledMask;
|
||||
let inspectedRect = /** @type {PreviewRect | null} */ (null);
|
||||
|
||||
rects = square.layouts.map((layout, index) => {
|
||||
const transaction = square.resolvedCells[index].transaction;
|
||||
const rectSize = unitsToPixels(layout.span, cell, gap);
|
||||
|
||||
return {
|
||||
color: square.resolvedCells[index].color,
|
||||
size: rectSize,
|
||||
transaction,
|
||||
x: layout.x * unit,
|
||||
y: width - layout.y * unit - rectSize,
|
||||
};
|
||||
});
|
||||
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
context.clearRect(0, 0, width, width);
|
||||
|
||||
for (const rect of rects) {
|
||||
const mask = getTransactionMask(rect.transaction, filterState);
|
||||
const alpha = mask & activeMask ? MUTED_ALPHA : 1;
|
||||
|
||||
drawRect(context, alpha, rect.color, rect);
|
||||
if (rect.transaction === inspected) inspectedRect = rect;
|
||||
}
|
||||
|
||||
if (inspectedRect !== null) drawHover(context, inspectedRect, colors);
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
/** @param {BlockPreviewTransaction | null} transaction */
|
||||
function setInspected(transaction) {
|
||||
if (transaction === inspected) return;
|
||||
|
||||
inspected = transaction;
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} event
|
||||
* @param {boolean} eager
|
||||
*/
|
||||
function inspectAt(event, eager) {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const transaction = hitTest(
|
||||
rects,
|
||||
event.clientX - bounds.left,
|
||||
event.clientY - bounds.top,
|
||||
);
|
||||
|
||||
if (transaction === null && inspected !== null) {
|
||||
options.onInspect?.(
|
||||
inspected,
|
||||
{ clientX: event.clientX, clientY: event.clientY },
|
||||
eager,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setInspected(transaction);
|
||||
options.onInspect?.(
|
||||
transaction,
|
||||
{ clientX: event.clientX, clientY: event.clientY },
|
||||
eager,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} event */
|
||||
function startInspect(event) {
|
||||
capturedPointer = event.pointerId;
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
inspectAt(event, true);
|
||||
if (event.pointerType !== "mouse") event.preventDefault();
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} event */
|
||||
function moveInspect(event) {
|
||||
if (capturedPointer !== null || event.pointerType === "mouse") {
|
||||
inspectAt(event, false);
|
||||
if (capturedPointer !== null && event.pointerType !== "mouse") {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} event */
|
||||
function stopInspect(event) {
|
||||
if (capturedPointer !== event.pointerId) return;
|
||||
|
||||
capturedPointer = null;
|
||||
canvas.releasePointerCapture(event.pointerId);
|
||||
if (event.pointerType !== "mouse") event.preventDefault();
|
||||
}
|
||||
|
||||
function clearInspect() {
|
||||
setInspected(null);
|
||||
options.onInspect?.(null, null, false);
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} event */
|
||||
function clearOnOutsidePointer(event) {
|
||||
if (event.target instanceof Node && canvas.contains(event.target)) return;
|
||||
|
||||
clearInspect();
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(scheduleDraw);
|
||||
|
||||
canvas.addEventListener("pointermove", moveInspect);
|
||||
canvas.addEventListener("pointerdown", startInspect);
|
||||
canvas.addEventListener("pointerup", stopInspect);
|
||||
canvas.addEventListener("pointercancel", (event) => {
|
||||
stopInspect(event);
|
||||
clearInspect();
|
||||
});
|
||||
canvas.addEventListener("pointerleave", (event) => {
|
||||
if (capturedPointer === null && event.pointerType === "mouse") {
|
||||
clearInspect();
|
||||
}
|
||||
});
|
||||
document.addEventListener("pointerdown", clearOnOutsidePointer);
|
||||
window.addEventListener("blur", clearInspect);
|
||||
observer.observe(canvas);
|
||||
scheduleDraw();
|
||||
|
||||
return /** @type {const} */ ({
|
||||
element: canvas,
|
||||
ordered,
|
||||
destroy() {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener("pointerdown", clearOnOutsidePointer);
|
||||
window.removeEventListener("blur", clearInspect);
|
||||
observer.disconnect();
|
||||
},
|
||||
/** @param {number | null} mask */
|
||||
previewDisabledMask(mask) {
|
||||
previewMask = mask;
|
||||
scheduleDraw();
|
||||
},
|
||||
/** @param {number} mask */
|
||||
setDisabledMask(mask) {
|
||||
disabledMask = mask;
|
||||
scheduleDraw();
|
||||
},
|
||||
/** @param {BlockPreviewFilterState} state */
|
||||
setFilterState(state) {
|
||||
filterState = state;
|
||||
scheduleDraw();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** @typedef {import("../data.js").BlockPreviewTransaction} BlockPreviewTransaction */
|
||||
/** @typedef {import("../data.js").BlockPreviewFilterState} BlockPreviewFilterState */
|
||||
|
||||
/**
|
||||
* @typedef {Object} PreviewRect
|
||||
* @property {string} color
|
||||
* @property {number} size
|
||||
* @property {BlockPreviewTransaction} transaction
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CanvasColors
|
||||
* @property {string} black
|
||||
* @property {string} white
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BlockPreviewPointer
|
||||
* @property {number} clientX
|
||||
* @property {number} clientY
|
||||
*/
|
||||
@@ -17,7 +17,6 @@ export function packCells(cells, columns, rows) {
|
||||
|
||||
fillCells(occupied, position.x, position.y, span);
|
||||
usedRows = Math.max(usedRows, position.y + span);
|
||||
|
||||
layouts.push({ ...position, span, rows: rows ?? usedRows });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[data-block-preview-heatmap] {
|
||||
--block-preview-heatmap-gap: 2px;
|
||||
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
min-width: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
@@ -1,69 +1,69 @@
|
||||
import { createHeatmap } from "../../../heatmap/index.js";
|
||||
import { formatFeeRate } from "../../../utils/fee-rate.js";
|
||||
import { formatWeight, MAX_BLOCK_WEIGHT } from "../format.js";
|
||||
import { getFeeRateColor } from "../fee-rates.js";
|
||||
import { loadBlockPreview } from "./data.js";
|
||||
import { createPreviewFeeRange, orderTransactions } from "./fees.js";
|
||||
import { createPreviewFilters, getFilterKeys, getVersionKey } from "./filters.js";
|
||||
import {
|
||||
loadBlockPreview,
|
||||
loadBlockPreviewFilters,
|
||||
} from "./data.js";
|
||||
import {
|
||||
createPendingPreviewFilters,
|
||||
createPreviewFilters,
|
||||
} from "./filters/index.js";
|
||||
import { createBlockPreviewHeatmap } from "./heatmap/index.js";
|
||||
import { createBlockPreviewInspector } from "./inspector.js";
|
||||
|
||||
/**
|
||||
* @param {BlockPreviewTransaction} transaction
|
||||
* @param {number[]} ranges
|
||||
*/
|
||||
function createPreviewItem(transaction, ranges) {
|
||||
return {
|
||||
color: getFeeRateColor(transaction.feeRate, ranges),
|
||||
group: getVersionKey(transaction.version),
|
||||
groups: getFilterKeys(transaction),
|
||||
weight: transaction.weight,
|
||||
title: [
|
||||
transaction.txid,
|
||||
`v${transaction.version}`,
|
||||
transaction.rbf ? "RBF" : "no RBF",
|
||||
`${transaction.inputCount} in`,
|
||||
`${transaction.outputCount} out`,
|
||||
`${formatFeeRate(transaction.feeRate)} sat/vB`,
|
||||
formatWeight(transaction.weight),
|
||||
].join(" · "),
|
||||
};
|
||||
}
|
||||
function noop() {}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} body
|
||||
* @param {HTMLElement} filters
|
||||
* @param {HTMLElement} [inspector]
|
||||
*/
|
||||
function createFigure(body, filters) {
|
||||
function createFigure(body, filters, inspector) {
|
||||
const figure = document.createElement("figure");
|
||||
const caption = document.createElement("figcaption");
|
||||
const title = document.createElement("h5");
|
||||
|
||||
figure.dataset.blockPreviewFigure = "";
|
||||
caption.dataset.blockPreviewLegend = "";
|
||||
title.append("Filters");
|
||||
caption.append(title, filters);
|
||||
caption.append(filters);
|
||||
figure.append(caption, body);
|
||||
if (inspector) figure.append(inspector);
|
||||
|
||||
return figure;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} content
|
||||
* @param {BlockPreviewTransaction[]} transactions
|
||||
* @template T
|
||||
* @param {() => Promise<T>} load
|
||||
*/
|
||||
function renderPreview(content, transactions) {
|
||||
const ordered = orderTransactions(transactions);
|
||||
const ranges = createPreviewFeeRange(ordered);
|
||||
const items = ordered.map((transaction) => {
|
||||
return createPreviewItem(transaction, ranges);
|
||||
});
|
||||
const heatmap = createHeatmap(items, {
|
||||
origin: "bottom",
|
||||
shape: "square",
|
||||
capacity: MAX_BLOCK_WEIGHT,
|
||||
columns: 84,
|
||||
});
|
||||
function memoize(load) {
|
||||
let promise = /** @type {Promise<T> | null} */ (null);
|
||||
|
||||
content.replaceChildren(createFigure(heatmap, createPreviewFilters(ordered, heatmap)));
|
||||
return () => {
|
||||
promise ??= load();
|
||||
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BlockPreviewTransaction[]} transactions
|
||||
* @param {() => Promise<BlockPreviewFilterState>} loadFilters
|
||||
* @param {AbortSignal} signal
|
||||
*/
|
||||
function createPreview(transactions, loadFilters, signal) {
|
||||
const loadFilterState = memoize(loadFilters);
|
||||
const inspector = createBlockPreviewInspector(signal, loadFilterState);
|
||||
const heatmap = createBlockPreviewHeatmap(transactions, {
|
||||
onInspect: inspector.inspect,
|
||||
});
|
||||
const filters = createPreviewFilters(loadFilterState, heatmap);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
inspector.destroy();
|
||||
filters.destroy();
|
||||
heatmap.destroy();
|
||||
},
|
||||
element: createFigure(heatmap.element, filters.element, inspector.element),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,9 +75,7 @@ function renderStatus(content, status) {
|
||||
|
||||
p.dataset.blockPreviewStatus = status;
|
||||
p.textContent = status;
|
||||
content.replaceChildren(createFigure(p, createPreviewFilters([], null, {
|
||||
pending: true,
|
||||
})));
|
||||
content.replaceChildren(createFigure(p, createPendingPreviewFilters()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,15 +83,24 @@ function renderStatus(content, status) {
|
||||
*/
|
||||
export function createBlockPreviewPane(block) {
|
||||
const content = document.createElement("div");
|
||||
const controller = new AbortController();
|
||||
let destroyHeatmap = noop;
|
||||
let live = true;
|
||||
|
||||
content.dataset.blockPreview = "";
|
||||
renderStatus(content, "Loading");
|
||||
|
||||
void loadBlockPreview(block)
|
||||
.then((transactions) => {
|
||||
void loadBlockPreview(block, controller.signal)
|
||||
.then(({ range, transactions }) => {
|
||||
if (!live) return;
|
||||
renderPreview(content, transactions);
|
||||
const preview = createPreview(
|
||||
transactions,
|
||||
() => loadBlockPreviewFilters(range, controller.signal),
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
destroyHeatmap = preview.destroy;
|
||||
content.replaceChildren(preview.element);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!live) return;
|
||||
@@ -105,11 +112,18 @@ export function createBlockPreviewPane(block) {
|
||||
element: content,
|
||||
destroy() {
|
||||
live = false;
|
||||
for (const heatmap of content.querySelectorAll("[data-heatmap]")) {
|
||||
heatmap.dispatchEvent(new Event("heatmap:destroy"));
|
||||
}
|
||||
controller.abort();
|
||||
destroyHeatmap();
|
||||
destroyHeatmap = noop;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** @typedef {import("./fees.js").BlockPreviewTransaction} BlockPreviewTransaction */
|
||||
/** @typedef {import("./data.js").BlockPreviewTransaction} BlockPreviewTransaction */
|
||||
/** @typedef {import("./data.js").BlockPreviewFilterState} BlockPreviewFilterState */
|
||||
|
||||
/**
|
||||
* @typedef {Object} BlockPreview
|
||||
* @property {() => void} destroy
|
||||
* @property {HTMLElement} element
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { formatFeeRate } from "../../../utils/fee-rate.js";
|
||||
import { formatNumber, formatWeight } from "../format.js";
|
||||
import { loadBlockPreviewTxid } from "./data.js";
|
||||
import { FILTER_GROUPS, FILTERS } from "./filters/model.js";
|
||||
|
||||
const TXID_CACHE_LIMIT = 64;
|
||||
const TXID_DWELL_MS = 120;
|
||||
const OVERLAY_MARGIN = 8;
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} parent
|
||||
* @param {string} label
|
||||
*/
|
||||
function appendField(parent, label) {
|
||||
const row = document.createElement("div");
|
||||
const term = document.createElement("dt");
|
||||
const value = document.createElement("dd");
|
||||
|
||||
term.textContent = label;
|
||||
row.append(term, value);
|
||||
parent.append(row);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
* @param {string} value
|
||||
* @param {boolean} loading
|
||||
*/
|
||||
function setField(element, value, loading = false) {
|
||||
element.textContent = value;
|
||||
|
||||
if (loading) element.setAttribute("aria-busy", "true");
|
||||
else element.removeAttribute("aria-busy");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
* @param {BlockPreviewPointer} point
|
||||
*/
|
||||
function placeReadout(element, point) {
|
||||
const figure = /** @type {HTMLElement} */ (element.parentElement);
|
||||
const bounds = figure.getBoundingClientRect();
|
||||
const width = element.offsetWidth;
|
||||
const height = element.offsetHeight;
|
||||
const minX = Math.min(bounds.width / 2, width / 2 + OVERLAY_MARGIN);
|
||||
const maxX = Math.max(minX, bounds.width - minX);
|
||||
const rawX = point.clientX - bounds.left;
|
||||
const rawY = point.clientY - bounds.top;
|
||||
const x = Math.min(maxX, Math.max(minX, rawX));
|
||||
const showBelow = rawY < height + OVERLAY_MARGIN * 2;
|
||||
const minY = showBelow ? OVERLAY_MARGIN : height + OVERLAY_MARGIN;
|
||||
const maxY = showBelow
|
||||
? Math.max(minY, bounds.height - height - OVERLAY_MARGIN)
|
||||
: Math.max(minY, bounds.height - OVERLAY_MARGIN);
|
||||
const y = Math.min(maxY, Math.max(minY, rawY));
|
||||
|
||||
element.style.setProperty("--tx-x", `${x}px`);
|
||||
element.style.setProperty("--tx-y", `${y}px`);
|
||||
element.toggleAttribute("data-below", showBelow);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} txIndex
|
||||
* @param {string} txid
|
||||
* @param {Map<number, string>} cache
|
||||
*/
|
||||
function rememberTxid(txIndex, txid, cache) {
|
||||
if (cache.has(txIndex)) cache.delete(txIndex);
|
||||
else if (cache.size >= TXID_CACHE_LIMIT) {
|
||||
cache.delete(/** @type {number} */ (cache.keys().next().value));
|
||||
}
|
||||
|
||||
cache.set(txIndex, txid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbortSignal} parentSignal
|
||||
* @param {() => Promise<BlockPreviewFilterState>} loadFilters
|
||||
*/
|
||||
export function createBlockPreviewInspector(parentSignal, loadFilters) {
|
||||
const element = document.createElement("dl");
|
||||
const txid = appendField(element, "txid");
|
||||
const tx = appendField(element, "tx");
|
||||
const fee = appendField(element, "fee");
|
||||
const weight = appendField(element, "weight");
|
||||
const traitFields = FILTER_GROUPS.map(({ key, label }) => {
|
||||
return /** @type {const} */ ({ key, value: appendField(element, label) });
|
||||
});
|
||||
const cache = /** @type {Map<number, string>} */ (new Map());
|
||||
let controller = /** @type {AbortController | null} */ (null);
|
||||
let inspected = /** @type {BlockPreviewTransaction | null} */ (null);
|
||||
let point = /** @type {BlockPreviewPointer | null} */ (null);
|
||||
let timer = 0;
|
||||
let version = 0;
|
||||
|
||||
element.dataset.blockPreviewTransaction = "";
|
||||
element.hidden = true;
|
||||
|
||||
function abortPending() {
|
||||
clearTimeout(timer);
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BlockPreviewTransaction} transaction
|
||||
* @param {boolean} eager
|
||||
*/
|
||||
function fetchTxid(transaction, eager) {
|
||||
const current = version;
|
||||
const txidController = new AbortController();
|
||||
|
||||
controller = txidController;
|
||||
timer = setTimeout(() => {
|
||||
const signal = AbortSignal.any([parentSignal, txidController.signal]);
|
||||
|
||||
void loadBlockPreviewTxid(transaction.txIndex, signal)
|
||||
.then((loadedTxid) => {
|
||||
if (current !== version || inspected !== transaction) return;
|
||||
|
||||
rememberTxid(transaction.txIndex, loadedTxid, cache);
|
||||
setField(txid, loadedTxid);
|
||||
txid.title = loadedTxid;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (current !== version || signal.aborted) return;
|
||||
|
||||
console.error(error);
|
||||
});
|
||||
}, eager ? 0 : TXID_DWELL_MS);
|
||||
}
|
||||
|
||||
function setTraitsLoading() {
|
||||
for (const { value } of traitFields) {
|
||||
value.removeAttribute("title");
|
||||
setField(value, "loading", true);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {number} mask */
|
||||
function setTraits(mask) {
|
||||
for (const { key, value } of traitFields) {
|
||||
const labels = FILTERS
|
||||
.filter(({ bit, group }) => group === key && mask & bit)
|
||||
.map(({ label }) => label);
|
||||
const text = labels.join(" · ") || "none";
|
||||
|
||||
setField(value, text);
|
||||
value.title = text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BlockPreviewTransaction} transaction
|
||||
*/
|
||||
function loadTraits(transaction) {
|
||||
const current = version;
|
||||
|
||||
setTraitsLoading();
|
||||
void loadFilters()
|
||||
.then((state) => {
|
||||
if (current !== version || inspected !== transaction) return;
|
||||
|
||||
const mask = state.masks[transaction.txIndex - state.start];
|
||||
|
||||
setTraits(mask);
|
||||
if (point !== null) placeReadout(element, point);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (current !== version || parentSignal.aborted) return;
|
||||
|
||||
for (const { value } of traitFields) setField(value, "unavailable");
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BlockPreviewTransaction | null} transaction
|
||||
* @param {BlockPreviewPointer | null} nextPoint
|
||||
* @param {boolean} eager
|
||||
*/
|
||||
function inspect(transaction, nextPoint, eager) {
|
||||
if (transaction === null || nextPoint === null) {
|
||||
version += 1;
|
||||
abortPending();
|
||||
inspected = null;
|
||||
point = null;
|
||||
element.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
element.hidden = false;
|
||||
point = nextPoint;
|
||||
placeReadout(element, nextPoint);
|
||||
|
||||
if (transaction === inspected) return;
|
||||
|
||||
version += 1;
|
||||
abortPending();
|
||||
inspected = transaction;
|
||||
|
||||
const cachedTxid = cache.get(transaction.txIndex);
|
||||
|
||||
setField(tx, `#${formatNumber(transaction.txIndex)}`);
|
||||
setField(fee, `${formatFeeRate(transaction.feeRate)} sat/vB`);
|
||||
setField(weight, formatWeight(transaction.weight));
|
||||
loadTraits(transaction);
|
||||
|
||||
if (cachedTxid !== undefined) {
|
||||
setField(txid, cachedTxid);
|
||||
txid.title = cachedTxid;
|
||||
return;
|
||||
}
|
||||
|
||||
txid.removeAttribute("title");
|
||||
setField(txid, "loading", true);
|
||||
fetchTxid(transaction, eager);
|
||||
}
|
||||
|
||||
return /** @type {const} */ ({
|
||||
destroy: abortPending,
|
||||
element,
|
||||
inspect,
|
||||
});
|
||||
}
|
||||
|
||||
/** @typedef {import("./data.js").BlockPreviewTransaction} BlockPreviewTransaction */
|
||||
/** @typedef {import("./data.js").BlockPreviewFilterState} BlockPreviewFilterState */
|
||||
|
||||
/**
|
||||
* @typedef {Object} BlockPreviewPointer
|
||||
* @property {number} clientX
|
||||
* @property {number} clientY
|
||||
*/
|
||||
@@ -1,34 +1,80 @@
|
||||
[data-block-preview] {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-block-preview-figure] {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
[data-block-preview-figure] {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-block-preview-legend] {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
[data-block-preview-legend] {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
> h5 {
|
||||
color: var(--white);
|
||||
font-size: var(--font-size-sm);
|
||||
[data-block-preview-transaction] {
|
||||
position: absolute;
|
||||
top: var(--tx-y, 50%);
|
||||
left: var(--tx-x, 50%);
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
column-gap: 0.5rem;
|
||||
width: min(24rem, calc(100% - 1rem));
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: var(--control-radius);
|
||||
color: var(--gray);
|
||||
background: color-mix(in oklch, var(--black) 88%, transparent);
|
||||
backdrop-filter: blur(0.25rem);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-xs);
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, calc(-100% - 0.5rem));
|
||||
|
||||
&[data-below] {
|
||||
transform: translate(-50%, 0.5rem);
|
||||
}
|
||||
|
||||
> div {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
dt,
|
||||
dd {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--gray);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
dd {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--white);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&[aria-busy="true"] {
|
||||
color: var(--gray);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-block-preview-status] {
|
||||
display: grid;
|
||||
aspect-ratio: 1;
|
||||
margin: 0;
|
||||
place-items: center;
|
||||
color: var(--gray);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
[data-block-preview-status] {
|
||||
display: grid;
|
||||
aspect-ratio: 1;
|
||||
margin: 0;
|
||||
place-items: center;
|
||||
color: var(--gray);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
+10
-9
@@ -1,13 +1,13 @@
|
||||
import { createBrand } from "../../brand/index.js";
|
||||
import { openDialog } from "../../dialog/index.js";
|
||||
import { getCoinbaseMessage } from "./format.js";
|
||||
import { createBlockUrl, getReceiptSections } from "./receipt/data.js";
|
||||
import { createReceiptQr } from "./receipt/qr.js";
|
||||
import { createBlockTitle } from "./title.js";
|
||||
import { createBrand } from "../../../brand/index.js";
|
||||
import { openDialog } from "../../../dialog/index.js";
|
||||
import { getCoinbaseMessage } from "../format.js";
|
||||
import { createBlockUrl, getReceiptSections } from "./data.js";
|
||||
import { createReceiptQr } from "./qr.js";
|
||||
import { createBlockTitle } from "../title/index.js";
|
||||
|
||||
/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */
|
||||
/** @typedef {import("./receipt/data.js").ReceiptRow} ReceiptRow */
|
||||
/** @typedef {import("./receipt/data.js").ReceiptSection} ReceiptSection */
|
||||
/** @typedef {import("../../../modules/brk-client/index.js").BlockInfoV1} Block */
|
||||
/** @typedef {import("./data.js").ReceiptRow} ReceiptRow */
|
||||
/** @typedef {import("./data.js").ReceiptSection} ReceiptSection */
|
||||
|
||||
/** @param {ReceiptRow} data */
|
||||
function createReceiptRow(data) {
|
||||
@@ -57,6 +57,7 @@ function createReceiptHead(block) {
|
||||
const title = document.createElement("h2");
|
||||
|
||||
head.dataset.receiptHead = "";
|
||||
title.dataset.blockTitleText = "";
|
||||
title.append(...createBlockTitle(block.height));
|
||||
head.append(title);
|
||||
|
||||
@@ -23,7 +23,6 @@ dialog[data-block-receipt] {
|
||||
translate: none;
|
||||
|
||||
:is(
|
||||
[data-receipt-head] > h2,
|
||||
[data-receipt-section] > h3,
|
||||
[data-receipt-row] > span,
|
||||
[data-receipt-row] > strong,
|
||||
@@ -60,9 +59,11 @@ dialog[data-block-receipt] {
|
||||
}
|
||||
|
||||
[data-receipt-head] {
|
||||
--block-title-color: var(--black);
|
||||
--block-title-height-color: color-mix(in oklch, var(--black) 62%, transparent);
|
||||
--block-title-height-line-height: var(--line-height-base);
|
||||
--block-title-height-size: var(--font-size-base);
|
||||
--block-title-justify: center;
|
||||
--block-title-label-size: 1.375rem;
|
||||
|
||||
display: grid;
|
||||
@@ -71,19 +72,6 @@ dialog[data-block-receipt] {
|
||||
justify-items: center;
|
||||
margin-block-start: 0.5rem;
|
||||
text-align: center;
|
||||
|
||||
> h2 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35em;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
color: var(--black);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-receipt-section] {
|
||||
@@ -303,7 +291,6 @@ dialog[data-block-receipt] {
|
||||
}
|
||||
|
||||
dialog[data-block-receipt] :is(
|
||||
[data-receipt-head] > h2,
|
||||
[data-receipt-row] > span,
|
||||
[data-receipt-row] > strong,
|
||||
[data-receipt-row] small,
|
||||
|
||||
+34
-26
@@ -1,12 +1,12 @@
|
||||
import { createBtcAmount, satsToUsd } from "../../btc/index.js";
|
||||
import { createBtcAmount, satsToUsd } from "../../../btc/index.js";
|
||||
import {
|
||||
appendLegendListItem,
|
||||
createLegendItem,
|
||||
createLegendList,
|
||||
} from "../../legend/index.js";
|
||||
import { createUsdAmount } from "../../usd/index.js";
|
||||
} from "../../../legend/index.js";
|
||||
import { createUsdAmount } from "../../../usd/index.js";
|
||||
|
||||
/** @typedef {import("../../modules/brk-client/index.js").BlockExtras} BlockExtras */
|
||||
/** @typedef {import("../../../modules/brk-client/index.js").BlockExtras} BlockExtras */
|
||||
|
||||
const REWARD_COLORS = /** @type {const} */ ({
|
||||
subsidy: "var(--orange)",
|
||||
@@ -108,13 +108,11 @@ function getRewardKey(target) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} rewards
|
||||
* @param {HTMLElement[]} elements
|
||||
* @param {string | null} activeKey
|
||||
*/
|
||||
function setRewardPreview(rewards, activeKey) {
|
||||
for (const element of rewards.querySelectorAll("[data-reward-key]")) {
|
||||
if (!(element instanceof HTMLElement)) continue;
|
||||
|
||||
function setRewardPreview(elements, activeKey) {
|
||||
for (const element of elements) {
|
||||
if (element.dataset.rewardKey === activeKey) {
|
||||
element.dataset.preview = "";
|
||||
delete element.dataset.muted;
|
||||
@@ -134,32 +132,42 @@ export function createRewardsPane(extras) {
|
||||
const rewards = document.createElement("div");
|
||||
const bar = document.createElement("div");
|
||||
const split = createLegendList({ fill: true });
|
||||
const subsidyPart = createRewardPart(
|
||||
"subsidy",
|
||||
"Subsidy",
|
||||
subsidy,
|
||||
extras.reward,
|
||||
extras.price,
|
||||
);
|
||||
const feesPart = createRewardPart(
|
||||
"fees",
|
||||
"Fees",
|
||||
extras.totalFees,
|
||||
extras.reward,
|
||||
extras.price,
|
||||
);
|
||||
const subsidySegment = createRewardSegment("subsidy", subsidy, extras.reward);
|
||||
const feesSegment = createRewardSegment("fees", extras.totalFees, extras.reward);
|
||||
const previewElements = [subsidyPart, feesPart, subsidySegment, feesSegment];
|
||||
|
||||
rewards.dataset.rewardsPane = "";
|
||||
appendLegendListItem(
|
||||
split,
|
||||
createRewardPart("subsidy", "Subsidy", subsidy, extras.reward, extras.price),
|
||||
);
|
||||
appendLegendListItem(
|
||||
split,
|
||||
createRewardPart("fees", "Fees", extras.totalFees, extras.reward, extras.price),
|
||||
);
|
||||
appendLegendListItem(split, subsidyPart);
|
||||
appendLegendListItem(split, feesPart);
|
||||
bar.dataset.rewardBar = "";
|
||||
bar.append(
|
||||
createRewardSegment("subsidy", subsidy, extras.reward),
|
||||
createRewardSegment("fees", extras.totalFees, extras.reward),
|
||||
);
|
||||
bar.append(subsidySegment, feesSegment);
|
||||
rewards.append(createRewardTotal("Rewards", extras.reward, extras.price), bar, split);
|
||||
|
||||
rewards.addEventListener("pointerenter", (event) => {
|
||||
setRewardPreview(rewards, getRewardKey(event.target));
|
||||
setRewardPreview(previewElements, getRewardKey(event.target));
|
||||
}, true);
|
||||
rewards.addEventListener("pointerleave", () => setRewardPreview(rewards, null));
|
||||
rewards.addEventListener("pointerleave", () => setRewardPreview(previewElements, null));
|
||||
rewards.addEventListener("pointerdown", (event) => {
|
||||
setRewardPreview(rewards, getRewardKey(event.target));
|
||||
setRewardPreview(previewElements, getRewardKey(event.target));
|
||||
});
|
||||
rewards.addEventListener("pointerup", () => setRewardPreview(previewElements, null));
|
||||
rewards.addEventListener("pointercancel", () => {
|
||||
setRewardPreview(previewElements, null);
|
||||
});
|
||||
rewards.addEventListener("pointerup", () => setRewardPreview(rewards, null));
|
||||
rewards.addEventListener("pointercancel", () => setRewardPreview(rewards, null));
|
||||
|
||||
return rewards;
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
#block-details section[data-group="rewards"] {
|
||||
[data-rewards-pane] {
|
||||
--section-color: var(--orange);
|
||||
|
||||
[data-rewards-pane] {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
|
||||
[data-reward-total] {
|
||||
display: grid;
|
||||
|
||||
@@ -10,93 +10,6 @@
|
||||
line-height: var(--line-height-sm);
|
||||
scrollbar-width: none;
|
||||
|
||||
[data-dim] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
:is(h1, p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> header {
|
||||
--block-title-height-color: var(--gray);
|
||||
--block-title-height-line-height: var(--line-height-lg);
|
||||
--block-title-height-size: var(--font-size-lg);
|
||||
--block-title-label-size: 2.5rem;
|
||||
|
||||
display: grid;
|
||||
padding-bottom: 1.25rem;
|
||||
|
||||
[data-block-title] {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.35rem 1rem;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-block-main] {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35em;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-block-date],
|
||||
[data-block-hash-line] {
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
[data-block-date] {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
[data-block-side] {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
justify-items: end;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
[data-block-actions] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
|
||||
button {
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-receipt-button] {
|
||||
padding: 0.25rem 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-block-hash-line] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -110,7 +23,7 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
section {
|
||||
[data-block-column] > section {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.75rem;
|
||||
@@ -118,30 +31,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
:is(#block-details > header, dialog[data-block-receipt] [data-receipt-head]) {
|
||||
[data-title-label] {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--block-title-label-size);
|
||||
font-style: italic;
|
||||
line-height: 0.9;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
[data-title-height] {
|
||||
color: var(--block-title-height-color);
|
||||
font-size: var(--block-title-height-size);
|
||||
line-height: var(--block-title-height-line-height);
|
||||
}
|
||||
|
||||
[data-title-height] [data-dim] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
#block-details {
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 1rem var(--page-x) var(--page-x);
|
||||
|
||||
> div {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[data-block-title-text] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35em;
|
||||
align-items: baseline;
|
||||
justify-content: var(--block-title-justify, flex-start);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--block-title-color, inherit);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: 1;
|
||||
|
||||
[data-title-label] {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--block-title-label-size);
|
||||
font-style: italic;
|
||||
line-height: 0.9;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
[data-title-height] {
|
||||
color: var(--block-title-height-color);
|
||||
font-size: var(--block-title-height-size);
|
||||
line-height: var(--block-title-height-line-height);
|
||||
|
||||
[data-dim] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-9
@@ -1,18 +1,16 @@
|
||||
import { formatBlockFill, formatBytes, formatNumber } from "./format.js";
|
||||
import { formatBlockFill, formatBytes, formatNumber } from "../format.js";
|
||||
|
||||
/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */
|
||||
/** @typedef {import("../../../modules/brk-client/index.js").BlockInfoV1} Block */
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {string | Node} value
|
||||
* @param {string} type
|
||||
*/
|
||||
function createBlockMetaItem(label, value, type) {
|
||||
function createBlockMetaItem(label, value) {
|
||||
const item = document.createElement("div");
|
||||
const name = document.createElement("span");
|
||||
const data = document.createElement("strong");
|
||||
|
||||
item.dataset.blockMetaItem = type;
|
||||
name.textContent = label;
|
||||
data.append(value);
|
||||
item.append(name, data);
|
||||
@@ -30,11 +28,10 @@ export function createTransactionPane(block) {
|
||||
createBlockMetaItem(
|
||||
"Block",
|
||||
`${formatBytes(block.size)} · ${formatBlockFill(block.weight)}`,
|
||||
"block",
|
||||
),
|
||||
createBlockMetaItem("Tx", formatNumber(block.txCount), "tx"),
|
||||
createBlockMetaItem("Input", formatNumber(extras.totalInputs), "input"),
|
||||
createBlockMetaItem("Output", formatNumber(extras.totalOutputs), "output"),
|
||||
createBlockMetaItem("Tx", formatNumber(block.txCount)),
|
||||
createBlockMetaItem("Input", formatNumber(extras.totalInputs)),
|
||||
createBlockMetaItem("Output", formatNumber(extras.totalOutputs)),
|
||||
);
|
||||
|
||||
return meta;
|
||||
@@ -1,12 +1,10 @@
|
||||
#block-details section[data-group="preview"] {
|
||||
[data-block-meta] {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) repeat(3, max-content);
|
||||
gap: 1.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
[data-block-meta] {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) repeat(3, max-content);
|
||||
gap: 1.5rem;
|
||||
min-width: 0;
|
||||
|
||||
[data-block-meta-item] {
|
||||
> div {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
@@ -34,9 +32,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
#block-details section[data-group="preview"] {
|
||||
[data-block-meta] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
[data-block-meta] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,8 +142,9 @@
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-x;
|
||||
overscroll-behavior-x: contain;
|
||||
overscroll-behavior-y: auto;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
[data-chain-blocks] {
|
||||
|
||||
@@ -14,11 +14,8 @@ export function createExplorePage() {
|
||||
const syncChain = () => chain.setActive(!main.hidden && !document.hidden);
|
||||
|
||||
main.addEventListener("pageactive", syncChain);
|
||||
main.addEventListener("pageinactive", syncChain);
|
||||
document.addEventListener("visibilitychange", syncChain);
|
||||
new MutationObserver(syncChain).observe(main, {
|
||||
attributes: true,
|
||||
attributeFilter: ["hidden"],
|
||||
});
|
||||
|
||||
return main;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@ main[data-page="explore"] {
|
||||
@media (max-width: 48rem) {
|
||||
main[data-page="explore"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 100dvh;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { createSquareLayout } from "./capacity.js";
|
||||
import { packCells } from "./pack.js";
|
||||
|
||||
/**
|
||||
* @param {HeatmapItem} item
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
function createCell(item) {
|
||||
const cell = document.createElement("span");
|
||||
|
||||
cell.dataset.heatmapCell = item.group;
|
||||
if (item.groups !== undefined) cell.dataset.heatmapGroups = item.groups.join(" ");
|
||||
cell.style.setProperty("--color", item.color);
|
||||
cell.title = item.title;
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} parent
|
||||
* @param {string} width
|
||||
*/
|
||||
function createMeasure(parent, width) {
|
||||
const measure = document.createElement("i");
|
||||
|
||||
measure.dataset.heatmapMeasure = "";
|
||||
measure.style.setProperty("width", width);
|
||||
parent.append(measure);
|
||||
|
||||
return measure;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} count
|
||||
* @param {number} cell
|
||||
* @param {number} gap
|
||||
*/
|
||||
function unitsToPixels(count, cell, gap) {
|
||||
return count * cell + Math.max(0, count - 1) * gap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} map
|
||||
* @param {HTMLElement} cellMeasure
|
||||
* @param {HTMLElement} gapMeasure
|
||||
* @param {readonly HeatmapCell[]} cells
|
||||
* @param {HeatmapOptions} options
|
||||
*/
|
||||
function layoutHeatmap(map, cellMeasure, gapMeasure, cells, options) {
|
||||
const baseCell = cellMeasure.getBoundingClientRect().width;
|
||||
const gap = gapMeasure.getBoundingClientRect().width - baseCell;
|
||||
const width = map.getBoundingClientRect().width;
|
||||
let cell = baseCell;
|
||||
let columns = Math.max(1, Math.floor((width + gap) / (baseCell + gap)));
|
||||
let resolvedCells = cells;
|
||||
let layouts = packCells(resolvedCells, columns);
|
||||
let rows;
|
||||
|
||||
if (options.shape === "square" && options.capacity !== undefined) {
|
||||
const square = createSquareLayout(
|
||||
cells,
|
||||
options.capacity,
|
||||
options.columns ?? columns,
|
||||
);
|
||||
|
||||
columns = square.columns;
|
||||
resolvedCells = square.resolvedCells;
|
||||
layouts = square.layouts;
|
||||
cell = Math.max(1, (width - gap * (columns - 1)) / columns);
|
||||
rows = columns;
|
||||
}
|
||||
|
||||
if (layouts === null) return;
|
||||
|
||||
rows ??= Math.max(...layouts.map(({ rows }) => rows), 0);
|
||||
const gridWidth = unitsToPixels(columns, cell, gap);
|
||||
const offset = options.shape === "square"
|
||||
? 0
|
||||
: Math.max(0, (width - gridWidth) / 2);
|
||||
|
||||
map.style.setProperty("height", `${unitsToPixels(rows, cell, gap)}px`);
|
||||
|
||||
layouts.forEach((layout, index) => {
|
||||
const node = resolvedCells[index].node;
|
||||
|
||||
node.style.setProperty("--x", `${offset + layout.x * (cell + gap)}px`);
|
||||
node.style.setProperty("--y", `${layout.y * (cell + gap)}px`);
|
||||
node.style.setProperty("--size", `${unitsToPixels(layout.span, cell, gap)}px`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} map
|
||||
* @param {HTMLElement} cellMeasure
|
||||
* @param {HTMLElement} gapMeasure
|
||||
* @param {readonly HeatmapCell[]} cells
|
||||
* @param {HeatmapOptions} options
|
||||
*/
|
||||
function observeLayout(map, cellMeasure, gapMeasure, cells, options) {
|
||||
let frame = 0;
|
||||
const layout = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
layoutHeatmap(map, cellMeasure, gapMeasure, cells, options);
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(layout);
|
||||
|
||||
observer.observe(map);
|
||||
map.addEventListener("heatmap:destroy", () => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
}, { once: true });
|
||||
layout();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HeatmapItem[]} items
|
||||
* @param {HeatmapOptions} [options]
|
||||
*/
|
||||
export function createHeatmap(items, options = {}) {
|
||||
const map = document.createElement("div");
|
||||
const cells = items.map((item) => ({
|
||||
node: createCell(item),
|
||||
span: Math.max(1, Math.round(item.span ?? 1)),
|
||||
weight: item.weight,
|
||||
}));
|
||||
|
||||
map.dataset.heatmap = options.origin ?? "";
|
||||
if (options.shape != null) map.dataset.heatmapShape = options.shape;
|
||||
map.append(...cells.map(({ node }) => node));
|
||||
observeLayout(
|
||||
map,
|
||||
createMeasure(map, "var(--heatmap-cell-size)"),
|
||||
createMeasure(map, "calc(var(--heatmap-cell-size) + var(--heatmap-gap))"),
|
||||
cells,
|
||||
options,
|
||||
);
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} HeatmapItem
|
||||
* @property {string} color
|
||||
* @property {string} group
|
||||
* @property {string[]} [groups]
|
||||
* @property {string} title
|
||||
* @property {number} [span]
|
||||
* @property {number} [weight]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} HeatmapOptions
|
||||
* @property {"bottom"} [origin]
|
||||
* @property {"square"} [shape]
|
||||
* @property {number} [capacity]
|
||||
* @property {number} [columns]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} HeatmapCell
|
||||
* @property {HTMLSpanElement} node
|
||||
* @property {number} span
|
||||
* @property {number | undefined} weight
|
||||
*/
|
||||
@@ -1,40 +0,0 @@
|
||||
[data-heatmap] {
|
||||
--heatmap-cell-size: 0.4rem;
|
||||
--heatmap-gap: 2px;
|
||||
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-heatmap-shape="square"] {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
[data-heatmap-cell] {
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
background: var(--color);
|
||||
transition: opacity 150ms;
|
||||
|
||||
&[data-muted] {
|
||||
opacity: 0.12;
|
||||
}
|
||||
}
|
||||
|
||||
[data-heatmap="bottom"] > [data-heatmap-cell] {
|
||||
bottom: var(--y);
|
||||
}
|
||||
|
||||
[data-heatmap=""] > [data-heatmap-cell] {
|
||||
top: var(--y);
|
||||
}
|
||||
|
||||
[data-heatmap-measure] {
|
||||
position: absolute;
|
||||
height: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -102,8 +102,6 @@
|
||||
<link rel="stylesheet" href="/btc/style.css" />
|
||||
<link rel="stylesheet" href="/usd/style.css" />
|
||||
<link rel="stylesheet" href="/legend/style.css" />
|
||||
<link rel="stylesheet" href="/treemap/style.css" />
|
||||
<link rel="stylesheet" href="/heatmap/style.css" />
|
||||
<link rel="stylesheet" href="/cube/style.css" />
|
||||
<link rel="stylesheet" href="/brand/style.css" />
|
||||
<link rel="stylesheet" href="/header/style.css" />
|
||||
@@ -114,12 +112,16 @@
|
||||
<link rel="stylesheet" href="/explore/chain/cube/style.css" />
|
||||
<link rel="stylesheet" href="/explore/chain/style.css" />
|
||||
<link rel="stylesheet" href="/explore/block/style.css" />
|
||||
<link rel="stylesheet" href="/explore/block/title/style.css" />
|
||||
<link rel="stylesheet" href="/explore/block/header/style.css" />
|
||||
<link rel="stylesheet" href="/explore/block/miner/style.css" />
|
||||
<link rel="stylesheet" href="/explore/block/difficulty/style.css" />
|
||||
<link rel="stylesheet" href="/explore/block/rewards/style.css" />
|
||||
<link rel="stylesheet" href="/explore/block/transactions/style.css" />
|
||||
<link rel="stylesheet" href="/explore/block/fee-chart/style.css" />
|
||||
<link rel="stylesheet" href="/explore/block/preview/style.css" />
|
||||
<link rel="stylesheet" href="/explore/block/preview/heatmap/style.css" />
|
||||
<link rel="stylesheet" href="/explore/block/preview/filters/style.css" />
|
||||
<link rel="stylesheet" href="/explore/block/receipt/style.css" />
|
||||
<link rel="stylesheet" href="/learn/style.css" />
|
||||
<link rel="stylesheet" href="/chart/style.css" />
|
||||
|
||||
@@ -34,6 +34,7 @@ export function createLegendItem(args) {
|
||||
* @param {Object} [args]
|
||||
* @param {boolean} [args.fill]
|
||||
* @param {boolean} [args.scroll]
|
||||
* @param {boolean} [args.tight]
|
||||
*/
|
||||
export function createLegendList(args = {}) {
|
||||
const list = document.createElement("menu");
|
||||
@@ -41,6 +42,7 @@ export function createLegendList(args = {}) {
|
||||
list.dataset.legendList = "";
|
||||
if (args.fill) list.dataset.legendFill = "";
|
||||
if (args.scroll) list.dataset.legendScroll = "";
|
||||
if (args.tight) list.dataset.legendTight = "";
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
&[data-legend-tight] {
|
||||
padding-block: 0 0.25rem;
|
||||
}
|
||||
|
||||
&[data-legend-fill] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8.5ch, 1fr));
|
||||
@@ -30,6 +34,7 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
[data-legend-item] {
|
||||
@@ -86,6 +91,7 @@
|
||||
display: block;
|
||||
color: var(--color);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
|
||||
@@ -32,9 +32,10 @@ function getPage(pathname) {
|
||||
|
||||
/** @param {HTMLElement} page */
|
||||
function activatePage(page) {
|
||||
if (currentPage) {
|
||||
if (currentPage && currentPage !== page) {
|
||||
currentPage.hidden = true;
|
||||
currentPage.inert = true;
|
||||
currentPage.dispatchEvent(new Event("pageinactive"));
|
||||
}
|
||||
|
||||
page.hidden = false;
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import { createSvgElement } from "../chart/svg.js";
|
||||
|
||||
const VIEWBOX_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* @param {TreeItem[]} items
|
||||
* @returns {TreeRect[]}
|
||||
*/
|
||||
function layout(items) {
|
||||
const area = VIEWBOX_SIZE * VIEWBOX_SIZE;
|
||||
const total = items.reduce((sum, item) => sum + item.weight, 0);
|
||||
const weighted = items
|
||||
.map((item, index) => ({
|
||||
item,
|
||||
index,
|
||||
area: (item.weight / total) * area,
|
||||
}))
|
||||
.sort((a, b) => b.area - a.area || a.index - b.index);
|
||||
|
||||
return squarify(weighted, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: VIEWBOX_SIZE,
|
||||
height: VIEWBOX_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ item: TreeItem, index: number, area: number }[]} items
|
||||
* @param {TreeBox} box
|
||||
* @returns {TreeRect[]}
|
||||
*/
|
||||
function squarify(items, box) {
|
||||
const rects = [];
|
||||
let remaining = items;
|
||||
let rest = box;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const row = pickRow(remaining, Math.min(rest.width, rest.height));
|
||||
|
||||
rects.push(...layoutRow(row, rest));
|
||||
rest = trimBox(rest, rowArea(row));
|
||||
remaining = remaining.slice(row.length);
|
||||
}
|
||||
|
||||
return rects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ item: TreeItem, index: number, area: number }[]} items
|
||||
* @param {number} side
|
||||
*/
|
||||
function pickRow(items, side) {
|
||||
let row = [items[0]];
|
||||
let ratio = worstRatio(row, side);
|
||||
|
||||
for (let index = 1; index < items.length; index += 1) {
|
||||
const next = [...row, items[index]];
|
||||
const nextRatio = worstRatio(next, side);
|
||||
|
||||
if (nextRatio > ratio) break;
|
||||
|
||||
row = next;
|
||||
ratio = nextRatio;
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ area: number }[]} row
|
||||
*/
|
||||
function rowArea(row) {
|
||||
return row.reduce((sum, item) => sum + item.area, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ area: number }[]} row
|
||||
* @param {number} side
|
||||
*/
|
||||
function worstRatio(row, side) {
|
||||
const areas = row.map(({ area }) => area);
|
||||
const sum = rowArea(row);
|
||||
const max = Math.max(...areas);
|
||||
const min = Math.min(...areas);
|
||||
const sideSquared = side * side;
|
||||
const sumSquared = sum * sum;
|
||||
|
||||
return Math.max(
|
||||
(sideSquared * max) / sumSquared,
|
||||
sumSquared / (sideSquared * min),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ item: TreeItem, index: number, area: number }[]} row
|
||||
* @param {TreeBox} box
|
||||
* @returns {TreeRect[]}
|
||||
*/
|
||||
function layoutRow(row, box) {
|
||||
const area = rowArea(row);
|
||||
|
||||
if (box.width >= box.height) {
|
||||
const width = area / box.height;
|
||||
let y = box.y;
|
||||
|
||||
return row.map(({ item, index, area: itemArea }) => {
|
||||
const height = itemArea / width;
|
||||
const rect = { item, index, x: box.x, y, width, height };
|
||||
|
||||
y += height;
|
||||
return rect;
|
||||
});
|
||||
}
|
||||
|
||||
const height = area / box.width;
|
||||
let x = box.x;
|
||||
|
||||
return row.map(({ item, index, area: itemArea }) => {
|
||||
const width = itemArea / height;
|
||||
const rect = { item, index, x, y: box.y, width, height };
|
||||
|
||||
x += width;
|
||||
return rect;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TreeBox} box
|
||||
* @param {number} area
|
||||
* @returns {TreeBox}
|
||||
*/
|
||||
function trimBox(box, area) {
|
||||
if (box.width >= box.height) {
|
||||
const width = area / box.height;
|
||||
|
||||
return {
|
||||
x: box.x + width,
|
||||
y: box.y,
|
||||
width: box.width - width,
|
||||
height: box.height,
|
||||
};
|
||||
}
|
||||
|
||||
const height = area / box.width;
|
||||
|
||||
return {
|
||||
x: box.x,
|
||||
y: box.y + height,
|
||||
width: box.width,
|
||||
height: box.height - height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TreeRect} rect
|
||||
* @returns {SVGRectElement}
|
||||
*/
|
||||
function createTile(rect) {
|
||||
const tile = createSvgElement("rect");
|
||||
const title = createSvgElement("title");
|
||||
|
||||
tile.dataset.treemapTile = rect.item.group;
|
||||
tile.setAttribute("x", rect.x.toFixed(3));
|
||||
tile.setAttribute("y", rect.y.toFixed(3));
|
||||
tile.setAttribute("width", rect.width.toFixed(3));
|
||||
tile.setAttribute("height", rect.height.toFixed(3));
|
||||
tile.style.setProperty("--color", rect.item.color);
|
||||
title.textContent = rect.item.title;
|
||||
tile.append(title);
|
||||
|
||||
return tile;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TreeItem[]} items
|
||||
*/
|
||||
export function createTreemap(items) {
|
||||
const svg = createSvgElement("svg");
|
||||
|
||||
svg.dataset.treemap = "";
|
||||
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`);
|
||||
svg.setAttribute("role", "img");
|
||||
svg.append(...layout(items).map(createTile));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} TreeItem
|
||||
* @property {number} weight
|
||||
* @property {string} color
|
||||
* @property {string} group
|
||||
* @property {string} title
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TreeRect
|
||||
* @property {TreeItem} item
|
||||
* @property {number} index
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
* @property {number} width
|
||||
* @property {number} height
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TreeBox
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
* @property {number} width
|
||||
* @property {number} height
|
||||
*/
|
||||
@@ -1,13 +0,0 @@
|
||||
[data-treemap] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background: var(--black);
|
||||
|
||||
[data-treemap-tile] {
|
||||
fill: var(--color);
|
||||
stroke: var(--black);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export const colors = {
|
||||
orange: () => "var(--orange)",
|
||||
white: () => "var(--white)",
|
||||
gray: () => "var(--gray)",
|
||||
sky: () => "var(--sky)",
|
||||
cyan: () => "var(--cyan)",
|
||||
teal: () => "var(--teal)",
|
||||
@@ -19,3 +20,26 @@ export const colors = {
|
||||
blue: () => "var(--blue)",
|
||||
red: () => "var(--red)",
|
||||
};
|
||||
|
||||
export const txColors = /** @type {const} */ ({
|
||||
v1: colors.blue(),
|
||||
v2: colors.violet(),
|
||||
v3: colors.fuchsia(),
|
||||
rbf: colors.orange(),
|
||||
noRbf: colors.gray(),
|
||||
oneInput: colors.yellow(),
|
||||
multiInput: colors.amber(),
|
||||
oneOutput: colors.emerald(),
|
||||
multiOutput: colors.green(),
|
||||
p2pk: colors.yellow(),
|
||||
p2pkh: colors.amber(),
|
||||
p2sh: colors.orange(),
|
||||
p2wpkh: colors.emerald(),
|
||||
p2wsh: colors.green(),
|
||||
taproot: colors.violet(),
|
||||
p2a: colors.cyan(),
|
||||
baremult: colors.fuchsia(),
|
||||
opReturn: colors.red(),
|
||||
empty: colors.gray(),
|
||||
unknown: colors.gray(),
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ export function createLayout() {
|
||||
const addDialog = document.createElement("dialog");
|
||||
|
||||
main.dataset.page = "wallets";
|
||||
addDialog.dataset.dialog = "";
|
||||
privacyButton.type = "button";
|
||||
sessionButton.type = "button";
|
||||
sessionButton.append("Lock");
|
||||
|
||||
Reference in New Issue
Block a user