From a43b00c12c19b82660414baadc1b5d8c0ee33af7 Mon Sep 17 00:00:00 2001 From: nym21 Date: Wed, 8 Jul 2026 11:24:52 +0200 Subject: [PATCH] website_next: part 9 --- website_next/chart/xy/index.js | 14 +- website_next/dialog/index.js | 1 + website_next/dialog/style.css | 2 +- .../{difficulty.js => difficulty/index.js} | 4 +- .../explore/block/difficulty/style.css | 10 +- .../{fee-chart.js => fee-chart/index.js} | 18 +- .../explore/block/fee-chart/style.css | 6 +- .../block/{header.js => header/index.js} | 10 +- website_next/explore/block/header/style.css | 70 ++++ website_next/explore/block/index.js | 26 +- .../block/{miner.js => miner/index.js} | 11 +- website_next/explore/block/miner/style.css | 82 ++--- website_next/explore/block/preview/data.js | 240 +++++++++++-- website_next/explore/block/preview/filters.js | 195 ----------- .../explore/block/preview/filters/index.js | 235 +++++++++++++ .../explore/block/preview/filters/model.js | 74 ++++ .../explore/block/preview/filters/style.css | 78 +++++ .../block/preview}/heatmap/capacity.js | 24 +- .../explore/block/preview/heatmap/color.js | 120 +++++++ .../block/preview/{ => heatmap}/fees.js | 13 +- .../explore/block/preview/heatmap/index.js | 328 ++++++++++++++++++ .../block/preview}/heatmap/pack.js | 1 - .../explore/block/preview/heatmap/style.css | 9 + website_next/explore/block/preview/index.js | 128 ++++--- .../explore/block/preview/inspector.js | 236 +++++++++++++ website_next/explore/block/preview/style.css | 92 +++-- .../block/{receipt.js => receipt/index.js} | 19 +- website_next/explore/block/receipt/style.css | 17 +- .../block/{rewards.js => rewards/index.js} | 60 ++-- website_next/explore/block/rewards/style.css | 10 +- website_next/explore/block/style.css | 110 +----- .../block/{title.js => title/index.js} | 0 website_next/explore/block/title/style.css | 31 ++ .../index.js} | 15 +- .../explore/block/transactions/style.css | 20 +- website_next/explore/chain/style.css | 5 +- website_next/explore/index.js | 5 +- website_next/explore/style.css | 5 +- website_next/heatmap/index.js | 166 --------- website_next/heatmap/style.css | 40 --- website_next/index.html | 6 +- website_next/legend/index.js | 2 + website_next/legend/style.css | 6 + website_next/main.js | 3 +- website_next/treemap/index.js | 213 ------------ website_next/treemap/style.css | 13 - website_next/utils/colors.js | 24 ++ website_next/wallets/layout/index.js | 1 + 48 files changed, 1756 insertions(+), 1042 deletions(-) rename website_next/explore/block/{difficulty.js => difficulty/index.js} (95%) rename website_next/explore/block/{fee-chart.js => fee-chart/index.js} (89%) rename website_next/explore/block/{header.js => header/index.js} (85%) create mode 100644 website_next/explore/block/header/style.css rename website_next/explore/block/{miner.js => miner/index.js} (76%) delete mode 100644 website_next/explore/block/preview/filters.js create mode 100644 website_next/explore/block/preview/filters/index.js create mode 100644 website_next/explore/block/preview/filters/model.js create mode 100644 website_next/explore/block/preview/filters/style.css rename website_next/{ => explore/block/preview}/heatmap/capacity.js (72%) create mode 100644 website_next/explore/block/preview/heatmap/color.js rename website_next/explore/block/preview/{ => heatmap}/fees.js (55%) create mode 100644 website_next/explore/block/preview/heatmap/index.js rename website_next/{ => explore/block/preview}/heatmap/pack.js (99%) create mode 100644 website_next/explore/block/preview/heatmap/style.css create mode 100644 website_next/explore/block/preview/inspector.js rename website_next/explore/block/{receipt.js => receipt/index.js} (85%) rename website_next/explore/block/{rewards.js => rewards/index.js} (71%) rename website_next/explore/block/{title.js => title/index.js} (100%) create mode 100644 website_next/explore/block/title/style.css rename website_next/explore/block/{transactions.js => transactions/index.js} (67%) delete mode 100644 website_next/heatmap/index.js delete mode 100644 website_next/heatmap/style.css delete mode 100644 website_next/treemap/index.js delete mode 100644 website_next/treemap/style.css diff --git a/website_next/chart/xy/index.js b/website_next/chart/xy/index.js index f74fc7613..eaeb726eb 100644 --- a/website_next/chart/xy/index.js +++ b/website_next/chart/xy/index.js @@ -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, + }); } /** diff --git a/website_next/dialog/index.js b/website_next/dialog/index.js index 59eed0349..8bd6db215 100644 --- a/website_next/dialog/index.js +++ b/website_next/dialog/index.js @@ -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(); diff --git a/website_next/dialog/style.css b/website_next/dialog/style.css index b58514a2e..d0f868fa3 100644 --- a/website_next/dialog/style.css +++ b/website_next/dialog/style.css @@ -1,4 +1,4 @@ -dialog { +dialog[data-dialog] { --dialog-space: 1.5rem; color-scheme: light; diff --git a/website_next/explore/block/difficulty.js b/website_next/explore/block/difficulty/index.js similarity index 95% rename from website_next/explore/block/difficulty.js rename to website_next/explore/block/difficulty/index.js index 3c90d8319..896e7841b 100644 --- a/website_next/explore/block/difficulty.js +++ b/website_next/explore/block/difficulty/index.js @@ -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 diff --git a/website_next/explore/block/difficulty/style.css b/website_next/explore/block/difficulty/style.css index d21f0d8c9..c8f445ae9 100644 --- a/website_next/explore/block/difficulty/style.css +++ b/website_next/explore/block/difficulty/style.css @@ -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; diff --git a/website_next/explore/block/fee-chart.js b/website_next/explore/block/fee-chart/index.js similarity index 89% rename from website_next/explore/block/fee-chart.js rename to website_next/explore/block/fee-chart/index.js index 21a8bb01f..09c3047c3 100644 --- a/website_next/explore/block/fee-chart.js +++ b/website_next/explore/block/fee-chart/index.js @@ -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; } /** diff --git a/website_next/explore/block/fee-chart/style.css b/website_next/explore/block/fee-chart/style.css index d9ca2fc04..4f664b823 100644 --- a/website_next/explore/block/fee-chart/style.css +++ b/website_next/explore/block/fee-chart/style.css @@ -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; } diff --git a/website_next/explore/block/header.js b/website_next/explore/block/header/index.js similarity index 85% rename from website_next/explore/block/header.js rename to website_next/explore/block/header/index.js index 1c9ef26fb..765a773a6 100644 --- a/website_next/explore/block/header.js +++ b/website_next/explore/block/header/index.js @@ -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 = ""; diff --git a/website_next/explore/block/header/style.css b/website_next/explore/block/header/style.css new file mode 100644 index 000000000..db7187946 --- /dev/null +++ b/website_next/explore/block/header/style.css @@ -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; + } +} diff --git a/website_next/explore/block/index.js b/website_next/explore/block/index.js index a5522200c..c6d2cbbcb 100644 --- a/website_next/explore/block/index.js +++ b/website_next/explore/block/index.js @@ -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); } diff --git a/website_next/explore/block/miner.js b/website_next/explore/block/miner/index.js similarity index 76% rename from website_next/explore/block/miner.js rename to website_next/explore/block/miner/index.js index a5528b4cc..6684f9adc 100644 --- a/website_next/explore/block/miner.js +++ b/website_next/explore/block/miner/index.js @@ -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); diff --git a/website_next/explore/block/miner/style.css b/website_next/explore/block/miner/style.css index 989b7c9d4..3e19d4de6 100644 --- a/website_next/explore/block/miner/style.css +++ b/website_next/explore/block/miner/style.css @@ -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); diff --git a/website_next/explore/block/preview/data.js b/website_next/explore/block/preview/data.js index 8c8173f3a..7f3c1d742 100644 --- a/website_next/explore/block/preview/data.js +++ b/website_next/explore/block/preview/data.js @@ -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 + */ diff --git a/website_next/explore/block/preview/filters.js b/website_next/explore/block/preview/filters.js deleted file mode 100644 index f07f25dca..000000000 --- a/website_next/explore/block/preview/filters.js +++ /dev/null @@ -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>} 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} */ ( - 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 */ diff --git a/website_next/explore/block/preview/filters/index.js b/website_next/explore/block/preview/filters/index.js new file mode 100644 index 000000000..2f05632df --- /dev/null +++ b/website_next/explore/block/preview/filters/index.js @@ -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} 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} BlockPreviewHeatmap */ diff --git a/website_next/explore/block/preview/filters/model.js b/website_next/explore/block/preview/filters/model.js new file mode 100644 index 000000000..5287eec9d --- /dev/null +++ b/website_next/explore/block/preview/filters/model.js @@ -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} */ ( + 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); +} diff --git a/website_next/explore/block/preview/filters/style.css b/website_next/explore/block/preview/filters/style.css new file mode 100644 index 000000000..b972e8c44 --- /dev/null +++ b/website_next/explore/block/preview/filters/style.css @@ -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; + } + } + } +} diff --git a/website_next/heatmap/capacity.js b/website_next/explore/block/preview/heatmap/capacity.js similarity index 72% rename from website_next/heatmap/capacity.js rename to website_next/explore/block/preview/heatmap/capacity.js index 3414af308..fdaee9c4d 100644 --- a/website_next/heatmap/capacity.js +++ b/website_next/explore/block/preview/heatmap/capacity.js @@ -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} */ ( + packCells(resolvedCells, columns, columns) + ); + } + + return { + layouts: /** @type {NonNullable} */ (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} */ (layouts) }; + return { columns, ...fitCapacityCells(cells, capacity, columns) }; } /** * @typedef {Object} CapacityCell - * @property {number} span - * @property {number | undefined} weight + * @property {number} weight */ diff --git a/website_next/explore/block/preview/heatmap/color.js b/website_next/explore/block/preview/heatmap/color.js new file mode 100644 index 000000000..41ab697bb --- /dev/null +++ b/website_next/explore/block/preview/heatmap/color.js @@ -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 + */ diff --git a/website_next/explore/block/preview/fees.js b/website_next/explore/block/preview/heatmap/fees.js similarity index 55% rename from website_next/explore/block/preview/fees.js rename to website_next/explore/block/preview/heatmap/fees.js index d13741cb5..2fa7d65ba 100644 --- a/website_next/explore/block/preview/fees.js +++ b/website_next/explore/block/preview/heatmap/fees.js @@ -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 */ diff --git a/website_next/explore/block/preview/heatmap/index.js b/website_next/explore/block/preview/heatmap/index.js new file mode 100644 index 000000000..47ba2dca1 --- /dev/null +++ b/website_next/explore/block/preview/heatmap/index.js @@ -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 + */ diff --git a/website_next/heatmap/pack.js b/website_next/explore/block/preview/heatmap/pack.js similarity index 99% rename from website_next/heatmap/pack.js rename to website_next/explore/block/preview/heatmap/pack.js index dc4650c9c..218e713a8 100644 --- a/website_next/heatmap/pack.js +++ b/website_next/explore/block/preview/heatmap/pack.js @@ -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 }); } diff --git a/website_next/explore/block/preview/heatmap/style.css b/website_next/explore/block/preview/heatmap/style.css new file mode 100644 index 000000000..ba4ec6447 --- /dev/null +++ b/website_next/explore/block/preview/heatmap/style.css @@ -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; +} diff --git a/website_next/explore/block/preview/index.js b/website_next/explore/block/preview/index.js index 392c94274..dc05d63e3 100644 --- a/website_next/explore/block/preview/index.js +++ b/website_next/explore/block/preview/index.js @@ -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} 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 | null} */ (null); - content.replaceChildren(createFigure(heatmap, createPreviewFilters(ordered, heatmap))); + return () => { + promise ??= load(); + + return promise; + }; +} + +/** + * @param {BlockPreviewTransaction[]} transactions + * @param {() => Promise} 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 + */ diff --git a/website_next/explore/block/preview/inspector.js b/website_next/explore/block/preview/inspector.js new file mode 100644 index 000000000..8fb661ae0 --- /dev/null +++ b/website_next/explore/block/preview/inspector.js @@ -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} 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} 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} */ (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 + */ diff --git a/website_next/explore/block/preview/style.css b/website_next/explore/block/preview/style.css index 20c3a43c9..0b86bdacf 100644 --- a/website_next/explore/block/preview/style.css +++ b/website_next/explore/block/preview/style.css @@ -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; -} diff --git a/website_next/explore/block/receipt.js b/website_next/explore/block/receipt/index.js similarity index 85% rename from website_next/explore/block/receipt.js rename to website_next/explore/block/receipt/index.js index a883938fa..277ce6dbb 100644 --- a/website_next/explore/block/receipt.js +++ b/website_next/explore/block/receipt/index.js @@ -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); diff --git a/website_next/explore/block/receipt/style.css b/website_next/explore/block/receipt/style.css index 580580737..89fbd082b 100644 --- a/website_next/explore/block/receipt/style.css +++ b/website_next/explore/block/receipt/style.css @@ -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, diff --git a/website_next/explore/block/rewards.js b/website_next/explore/block/rewards/index.js similarity index 71% rename from website_next/explore/block/rewards.js rename to website_next/explore/block/rewards/index.js index 1bb53f2c4..c5d31a3f0 100644 --- a/website_next/explore/block/rewards.js +++ b/website_next/explore/block/rewards/index.js @@ -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; } diff --git a/website_next/explore/block/rewards/style.css b/website_next/explore/block/rewards/style.css index ec10663a1..5170592a5 100644 --- a/website_next/explore/block/rewards/style.css +++ b/website_next/explore/block/rewards/style.css @@ -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; diff --git a/website_next/explore/block/style.css b/website_next/explore/block/style.css index ea80b5ded..4c3c92542 100644 --- a/website_next/explore/block/style.css +++ b/website_next/explore/block/style.css @@ -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 { diff --git a/website_next/explore/block/title.js b/website_next/explore/block/title/index.js similarity index 100% rename from website_next/explore/block/title.js rename to website_next/explore/block/title/index.js diff --git a/website_next/explore/block/title/style.css b/website_next/explore/block/title/style.css new file mode 100644 index 000000000..65cf4fcdb --- /dev/null +++ b/website_next/explore/block/title/style.css @@ -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; + } + } +} diff --git a/website_next/explore/block/transactions.js b/website_next/explore/block/transactions/index.js similarity index 67% rename from website_next/explore/block/transactions.js rename to website_next/explore/block/transactions/index.js index 7f4c826b4..9831738a7 100644 --- a/website_next/explore/block/transactions.js +++ b/website_next/explore/block/transactions/index.js @@ -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; diff --git a/website_next/explore/block/transactions/style.css b/website_next/explore/block/transactions/style.css index bd2ad7e26..914c4f95f 100644 --- a/website_next/explore/block/transactions/style.css +++ b/website_next/explore/block/transactions/style.css @@ -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)); } } diff --git a/website_next/explore/chain/style.css b/website_next/explore/chain/style.css index 04edaf3af..49e81c3fc 100644 --- a/website_next/explore/chain/style.css +++ b/website_next/explore/chain/style.css @@ -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] { diff --git a/website_next/explore/index.js b/website_next/explore/index.js index 76e1ac122..4e94f81dc 100644 --- a/website_next/explore/index.js +++ b/website_next/explore/index.js @@ -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; } diff --git a/website_next/explore/style.css b/website_next/explore/style.css index c134204b2..f0f6817ce 100644 --- a/website_next/explore/style.css +++ b/website_next/explore/style.css @@ -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; } } diff --git a/website_next/heatmap/index.js b/website_next/heatmap/index.js deleted file mode 100644 index e42c724cb..000000000 --- a/website_next/heatmap/index.js +++ /dev/null @@ -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 - */ diff --git a/website_next/heatmap/style.css b/website_next/heatmap/style.css deleted file mode 100644 index 0e14553a4..000000000 --- a/website_next/heatmap/style.css +++ /dev/null @@ -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; -} diff --git a/website_next/index.html b/website_next/index.html index e2cdfe0da..a685b04b1 100644 --- a/website_next/index.html +++ b/website_next/index.html @@ -102,8 +102,6 @@ - - @@ -114,12 +112,16 @@ + + + + diff --git a/website_next/legend/index.js b/website_next/legend/index.js index cde2036ec..570474e65 100644 --- a/website_next/legend/index.js +++ b/website_next/legend/index.js @@ -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; } diff --git a/website_next/legend/style.css b/website_next/legend/style.css index 0b67ddf92..83132800d 100644 --- a/website_next/legend/style.css +++ b/website_next/legend/style.css @@ -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: ""; diff --git a/website_next/main.js b/website_next/main.js index 91b9608e5..bedf524f7 100644 --- a/website_next/main.js +++ b/website_next/main.js @@ -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; diff --git a/website_next/treemap/index.js b/website_next/treemap/index.js deleted file mode 100644 index 761bff6eb..000000000 --- a/website_next/treemap/index.js +++ /dev/null @@ -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 - */ diff --git a/website_next/treemap/style.css b/website_next/treemap/style.css deleted file mode 100644 index abdf08dc4..000000000 --- a/website_next/treemap/style.css +++ /dev/null @@ -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; - } -} diff --git a/website_next/utils/colors.js b/website_next/utils/colors.js index e4bc97df8..e95a7b47d 100644 --- a/website_next/utils/colors.js +++ b/website_next/utils/colors.js @@ -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(), +}); diff --git a/website_next/wallets/layout/index.js b/website_next/wallets/layout/index.js index af38c6c1d..2d3f8979f 100644 --- a/website_next/wallets/layout/index.js +++ b/website_next/wallets/layout/index.js @@ -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");