diff --git a/website_next/dialog/index.js b/website_next/dialog/index.js
index 41fe5ad61..59eed0349 100644
--- a/website_next/dialog/index.js
+++ b/website_next/dialog/index.js
@@ -1,3 +1,5 @@
+import { waitForTransition } from "../utils/transition.js";
+
/** @param {MouseEvent} event */
function closeOnBackdrop(event) {
const dialog = /** @type {HTMLDialogElement} */ (event.currentTarget);
@@ -11,7 +13,10 @@ function closeOnBackdrop(event) {
*/
export function openDialog(dialog, host) {
host.append(dialog);
- dialog.addEventListener("close", () => dialog.remove(), { once: true });
+ dialog.addEventListener("close", async () => {
+ await waitForTransition();
+ dialog.remove();
+ }, { once: true });
dialog.addEventListener("click", closeOnBackdrop);
dialog.showModal();
}
diff --git a/website_next/dialog/style.css b/website_next/dialog/style.css
index 8b3ce3a02..b58514a2e 100644
--- a/website_next/dialog/style.css
+++ b/website_next/dialog/style.css
@@ -8,8 +8,40 @@ dialog {
padding: var(--dialog-space);
color: var(--black);
background: var(--white);
+ opacity: 0;
+ translate: 0 0.5rem;
+ transition:
+ opacity var(--transition-duration) ease,
+ translate var(--transition-duration) ease,
+ display var(--transition-duration) allow-discrete,
+ overlay var(--transition-duration) allow-discrete;
+
+ &:open {
+ opacity: 1;
+ translate: 0;
+ }
&::backdrop {
background: color-mix(in oklch, var(--black) 72%, transparent);
+ opacity: 0;
+ transition:
+ opacity var(--transition-duration) ease,
+ display var(--transition-duration) allow-discrete,
+ overlay var(--transition-duration) allow-discrete;
+ }
+
+ &:open::backdrop {
+ opacity: 1;
+ }
+
+ @starting-style {
+ &:open {
+ opacity: 0;
+ translate: 0 0.5rem;
+ }
+
+ &:open::backdrop {
+ opacity: 0;
+ }
}
}
diff --git a/website_next/explore/block/format.js b/website_next/explore/block/format.js
index f3e6b752b..00f78aeb7 100644
--- a/website_next/explore/block/format.js
+++ b/website_next/explore/block/format.js
@@ -1,7 +1,7 @@
export const DIFFICULTY_EPOCH_BLOCKS = 2_016;
export const HALVING_EPOCH_BLOCKS = 210_000;
-const MAX_BLOCK_WEIGHT = 4_000_000;
+export const MAX_BLOCK_WEIGHT = 4_000_000;
/** @param {number} value */
export function formatNumber(value) {
diff --git a/website_next/explore/block/index.js b/website_next/explore/block/index.js
index f2420c27b..220523cac 100644
--- a/website_next/explore/block/index.js
+++ b/website_next/explore/block/index.js
@@ -4,19 +4,33 @@ import { createDifficultyPane } from "./difficulty.js";
import { createRewardsPane } from "./rewards.js";
import { createTransactionPane } from "./transactions.js";
import { createFeeChart } from "./fee-chart.js";
+import { createBlockPreviewPane } from "./preview/index.js";
import { appendPane } from "./pane.js";
import { createBlockReceipt } from "./receipt.js";
+function noop() {}
+
export function createBlockDetails() {
const element = document.createElement("section");
const receipt = createBlockReceipt();
const header = createBlockHeader([receipt.button]);
const content = document.createElement("div");
+ let destroyPreview = noop;
element.id = "block-details";
element.hidden = true;
element.append(header.element, content);
+ function clearContent() {
+ destroyPreview();
+ destroyPreview = noop;
+
+ for (const chart of content.querySelectorAll("[data-fee-chart]")) {
+ chart.dispatchEvent(new Event("chart:destroy"));
+ }
+ content.textContent = "";
+ }
+
/** @param {import("../../modules/brk-client/index.js").BlockInfoV1} block */
function update(block) {
const extras = block.extras;
@@ -25,15 +39,16 @@ export function createBlockDetails() {
header.update(block);
receipt.update(block);
- for (const chart of content.querySelectorAll("[data-fee-chart]")) {
- chart.dispatchEvent(new Event("chart:destroy"));
- }
- content.textContent = "";
+ clearContent();
+ const preview = createBlockPreviewPane(block);
+
+ destroyPreview = preview.destroy;
appendPane(content, "mining", [createMinerPane(block)]);
appendPane(content, "difficulty", [createDifficultyPane(block)]);
appendPane(content, "rewards", [createRewardsPane(extras)]);
appendPane(content, "block", [createTransactionPane(block)]);
+ appendPane(content, "preview", [preview.element]);
appendPane(content, "fees", [
createFeeChart(extras.feeRange, extras.avgFeeRate),
]);
diff --git a/website_next/explore/block/preview/data.js b/website_next/explore/block/preview/data.js
new file mode 100644
index 000000000..1c6726f47
--- /dev/null
+++ b/website_next/explore/block/preview/data.js
@@ -0,0 +1,33 @@
+import { brk } from "../../../utils/client.js";
+
+/**
+ * @param {import("../../../modules/brk-client/index.js").BlockInfoV1} block
+ */
+export async function loadBlockPreview(block) {
+ const firstTxIndex = (
+ await brk.series.transactions.raw.firstTxIndex.by.height
+ .get(block.height)
+ .fetch()
+ ).data[0];
+ const start = /** @type {number} */ (firstTxIndex);
+ const end = start + block.txCount;
+ const tx = brk.series.transactions;
+ const [
+ txids,
+ versions,
+ weights,
+ feeRates,
+ ] = 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(),
+ ]);
+
+ return txids.data.map((txid, index) => ({
+ txid,
+ version: versions.data[index],
+ weight: weights.data[index],
+ feeRate: feeRates.data[index],
+ }));
+}
diff --git a/website_next/explore/block/preview/fees.js b/website_next/explore/block/preview/fees.js
new file mode 100644
index 000000000..e79f52e37
--- /dev/null
+++ b/website_next/explore/block/preview/fees.js
@@ -0,0 +1,53 @@
+export const FEE_BUCKETS = /** @type {const} */ ([
+ { label: "min", color: "var(--cyan)" },
+ { label: "10%", color: "var(--blue)" },
+ { label: "25%", color: "var(--violet)" },
+ { label: "50%", color: "var(--white)" },
+ { label: "75%", color: "var(--yellow)" },
+ { label: "90%", color: "var(--orange)" },
+ { label: "max", color: "var(--red)" },
+]);
+
+/**
+ * @param {number} feeRate
+ * @param {number[]} ranges
+ */
+export function getFeeBucket(feeRate, ranges) {
+ for (let index = 0; index < ranges.length; index += 1) {
+ if (feeRate <= ranges[index]) return FEE_BUCKETS[index];
+ }
+
+ return FEE_BUCKETS[FEE_BUCKETS.length - 1];
+}
+
+/**
+ * @param {BlockPreviewTransaction[]} transactions
+ * @param {number[]} ranges
+ */
+export function countFees(transactions, ranges) {
+ const counts = new Map();
+
+ for (const transaction of transactions) {
+ const bucket = getFeeBucket(transaction.feeRate, ranges);
+
+ counts.set(bucket.label, (counts.get(bucket.label) ?? 0) + 1);
+ }
+
+ return counts;
+}
+
+/**
+ * @param {BlockPreviewTransaction[]} transactions
+ */
+export function orderTransactions(transactions) {
+ return 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
+ */
diff --git a/website_next/explore/block/preview/index.js b/website_next/explore/block/preview/index.js
new file mode 100644
index 000000000..8f5b524d0
--- /dev/null
+++ b/website_next/explore/block/preview/index.js
@@ -0,0 +1,102 @@
+import { createHeatmap } from "../../../heatmap/index.js";
+import { formatFeeRate } from "../../../utils/fee-rate.js";
+import { formatWeight, MAX_BLOCK_WEIGHT } from "../format.js";
+import { loadBlockPreview } from "./data.js";
+import { getFeeBucket, orderTransactions } from "./fees.js";
+import { createPreviewLegend } from "./legend.js";
+
+/**
+ * @param {BlockPreviewTransaction} transaction
+ * @param {number[]} ranges
+ */
+function createPreviewItem(transaction, ranges) {
+ const bucket = getFeeBucket(transaction.feeRate, ranges);
+
+ return {
+ color: bucket.color,
+ group: bucket.label,
+ weight: transaction.weight,
+ title: [
+ transaction.txid,
+ `v${transaction.version}`,
+ `${formatFeeRate(transaction.feeRate)} sat/vB`,
+ formatWeight(transaction.weight),
+ ].join(" ยท "),
+ };
+}
+
+/**
+ * @param {HTMLElement} content
+ * @param {number[]} ranges
+ * @param {BlockPreviewTransaction[]} transactions
+ */
+function renderPreview(content, ranges, transactions) {
+ const figure = document.createElement("figure");
+ const caption = document.createElement("figcaption");
+ const title = document.createElement("h5");
+ const ordered = orderTransactions(transactions);
+ const items = ordered.map((transaction) => {
+ return createPreviewItem(transaction, ranges);
+ });
+
+ figure.dataset.blockPreviewFigure = "";
+ caption.dataset.blockPreviewLegend = "";
+ title.append("Fees");
+ caption.append(title, createPreviewLegend(ordered, ranges));
+ figure.append(
+ caption,
+ createHeatmap(items, {
+ origin: "bottom",
+ shape: "square",
+ capacity: MAX_BLOCK_WEIGHT,
+ columns: 100,
+ }),
+ );
+ content.replaceChildren(figure);
+}
+
+/**
+ * @param {HTMLElement} content
+ * @param {string} status
+ */
+function renderStatus(content, status) {
+ const p = document.createElement("p");
+
+ p.dataset.blockPreviewStatus = status;
+ p.textContent = status;
+ content.replaceChildren(p);
+}
+
+/**
+ * @param {import("../../../modules/brk-client/index.js").BlockInfoV1} block
+ */
+export function createBlockPreviewPane(block) {
+ const content = document.createElement("div");
+ let live = true;
+
+ content.dataset.blockPreview = "";
+ renderStatus(content, "Loading");
+
+ void loadBlockPreview(block)
+ .then((transactions) => {
+ if (!live) return;
+ renderPreview(content, block.extras.feeRange, transactions);
+ })
+ .catch((error) => {
+ if (!live) return;
+ console.error(error);
+ renderStatus(content, "Unavailable");
+ });
+
+ return {
+ element: content,
+ destroy() {
+ live = false;
+ for (const heatmap of content.querySelectorAll("[data-heatmap]")) {
+ heatmap.dispatchEvent(new Event("heatmap:destroy"));
+ }
+ },
+ };
+}
+
+/** @typedef {import("./fees.js").BlockPreviewTransaction} BlockPreviewTransaction */
diff --git a/website_next/explore/block/preview/legend.js b/website_next/explore/block/preview/legend.js
new file mode 100644
index 000000000..0d5601d48
--- /dev/null
+++ b/website_next/explore/block/preview/legend.js
@@ -0,0 +1,36 @@
+import {
+ appendLegendListItem,
+ createLegendItem,
+ createLegendList,
+} from "../../../legend/index.js";
+import { formatFeeRate } from "../../../utils/fee-rate.js";
+import { formatNumber } from "../format.js";
+import { countFees, FEE_BUCKETS } from "./fees.js";
+
+/**
+ * @param {BlockPreviewTransaction[]} transactions
+ * @param {number[]} ranges
+ */
+export function createPreviewLegend(transactions, ranges) {
+ const counts = countFees(transactions, ranges);
+ const list = createLegendList({ scroll: true });
+
+ for (let index = 0; index < FEE_BUCKETS.length; index += 1) {
+ const bucket = FEE_BUCKETS[index];
+ const count = counts.get(bucket.label) ?? 0;
+ const { button, value } = createLegendItem({
+ label: bucket.label,
+ color: bucket.color,
+ ariaLabel: `${bucket.label} fee rate bucket`,
+ detail: `${formatFeeRate(ranges[index])} sat/vB`,
+ });
+
+ if (count === 0) button.dataset.muted = "";
+ value.textContent = formatNumber(count);
+ appendLegendListItem(list, button);
+ }
+
+ return list;
+}
+
+/** @typedef {import("./fees.js").BlockPreviewTransaction} BlockPreviewTransaction */
diff --git a/website_next/explore/block/preview/style.css b/website_next/explore/block/preview/style.css
new file mode 100644
index 000000000..ff44677b7
--- /dev/null
+++ b/website_next/explore/block/preview/style.css
@@ -0,0 +1,30 @@
+[data-block-preview] {
+ display: grid;
+ min-width: 0;
+}
+
+[data-block-preview-figure] {
+ display: grid;
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+[data-block-preview-legend] {
+ display: grid;
+ min-width: 0;
+
+ > h5 {
+ color: var(--white);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-sm);
+ text-transform: uppercase;
+ }
+}
+
+[data-block-preview-status] {
+ 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/qr.js b/website_next/explore/block/receipt/qr.js
index 542cb77f7..3218ce9f7 100644
--- a/website_next/explore/block/receipt/qr.js
+++ b/website_next/explore/block/receipt/qr.js
@@ -16,7 +16,7 @@ export function createReceiptQr(block, url) {
section.dataset.receiptQr = "";
label.textContent = "Scan to verify";
image.alt = `QR code for block ${block.height}`;
- image.src = createQrDataUrl(url, { padX: 0, padY: 0, scale: 8 });
+ image.src = createQrDataUrl(url, { scale: 8 });
link.href = url;
link.textContent = formatDisplayUrl(url);
section.append(label, image, link);
diff --git a/website_next/heatmap/capacity.js b/website_next/heatmap/capacity.js
new file mode 100644
index 000000000..2f1ea7e3b
--- /dev/null
+++ b/website_next/heatmap/capacity.js
@@ -0,0 +1,59 @@
+import { packCells } from "./pack.js";
+
+/**
+ * @param {number} weight
+ * @param {number} capacity
+ * @param {number} columns
+ * @param {"floor" | "round"} rounding
+ */
+function weightToSpan(weight, capacity, columns, rounding) {
+ const cellWeight = capacity / (columns * columns);
+ const span = Math.sqrt(weight / cellWeight);
+
+ return Math.max(1, Math[rounding](span));
+}
+
+/**
+ * @template {CapacityCell} Cell
+ * @param {readonly Cell[]} cells
+ * @param {number} capacity
+ * @param {number} columns
+ * @param {"floor" | "round"} rounding
+ */
+function resolveCapacityCells(cells, capacity, columns, rounding) {
+ return cells.map((cell) => ({
+ ...cell,
+ span: weightToSpan(cell.weight ?? 0, capacity, columns, rounding),
+ }));
+}
+
+/**
+ * @template {CapacityCell} Cell
+ * @param {readonly Cell[]} cells
+ * @param {number} capacity
+ * @param {number} columns
+ * @param {"floor" | "round"} rounding
+ */
+function createSquareLayout(cells, capacity, columns, rounding) {
+ const resolvedCells = resolveCapacityCells(cells, capacity, columns, rounding);
+ const layouts = packCells(resolvedCells, columns, columns);
+
+ return layouts === null ? null : { columns, resolvedCells, layouts };
+}
+
+/**
+ * @template {CapacityCell} Cell
+ * @param {readonly Cell[]} cells
+ * @param {number} capacity
+ * @param {number} columns
+ */
+export function findSquareLayout(cells, capacity, columns) {
+ return createSquareLayout(cells, capacity, columns, "round") ??
+ createSquareLayout(cells, capacity, columns, "floor");
+}
+
+/**
+ * @typedef {Object} CapacityCell
+ * @property {number} span
+ * @property {number | undefined} weight
+ */
diff --git a/website_next/heatmap/index.js b/website_next/heatmap/index.js
new file mode 100644
index 000000000..091440480
--- /dev/null
+++ b/website_next/heatmap/index.js
@@ -0,0 +1,160 @@
+import { findSquareLayout } 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;
+ 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);
+
+ if (options.shape === "square" && options.capacity !== undefined) {
+ const square = findSquareLayout(
+ cells,
+ options.capacity,
+ options.columns ?? columns,
+ );
+
+ if (square === null) return;
+ columns = square.columns;
+ resolvedCells = square.resolvedCells;
+ layouts = square.layouts;
+ cell = Math.max(1, Math.floor((width - gap * (columns - 1)) / columns));
+ }
+
+ if (layouts === null) return;
+
+ const rows = Math.max(...layouts.map(({ rows }) => rows), 0);
+ const gridWidth = unitsToPixels(columns, cell, gap);
+ const offset = 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 ?? "";
+ 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} 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/pack.js b/website_next/heatmap/pack.js
new file mode 100644
index 000000000..dc4650c9c
--- /dev/null
+++ b/website_next/heatmap/pack.js
@@ -0,0 +1,89 @@
+/**
+ * @param {readonly PackCell[]} cells
+ * @param {number} columns
+ * @param {number} [rows]
+ * @returns {PackLayout[] | null}
+ */
+export function packCells(cells, columns, rows) {
+ const occupied = /** @type {boolean[][]} */ ([]);
+ const layouts = [];
+ let usedRows = 0;
+
+ for (const cell of cells) {
+ const span = Math.min(cell.span, columns);
+ const position = findPosition(occupied, columns, span, rows);
+
+ if (position === null) return null;
+
+ fillCells(occupied, position.x, position.y, span);
+ usedRows = Math.max(usedRows, position.y + span);
+
+ layouts.push({ ...position, span, rows: rows ?? usedRows });
+ }
+
+ return layouts;
+}
+
+/**
+ * @param {boolean[][]} occupied
+ * @param {number} columns
+ * @param {number} span
+ * @param {number} [rows]
+ * @returns {{ x: number, y: number } | null}
+ */
+function findPosition(occupied, columns, span, rows) {
+ const lastRow = rows === undefined ? Infinity : rows - span;
+
+ for (let y = 0; y <= lastRow; y += 1) {
+ for (let x = 0; x <= columns - span; x += 1) {
+ if (canPlace(occupied, x, y, span)) return { x, y };
+ }
+ }
+
+ return null;
+}
+
+/**
+ * @param {boolean[][]} occupied
+ * @param {number} x
+ * @param {number} y
+ * @param {number} span
+ */
+function canPlace(occupied, x, y, span) {
+ for (let row = y; row < y + span; row += 1) {
+ for (let column = x; column < x + span; column += 1) {
+ if (occupied[row]?.[column]) return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * @param {boolean[][]} occupied
+ * @param {number} x
+ * @param {number} y
+ * @param {number} span
+ */
+function fillCells(occupied, x, y, span) {
+ for (let row = y; row < y + span; row += 1) {
+ occupied[row] ??= [];
+
+ for (let column = x; column < x + span; column += 1) {
+ occupied[row][column] = true;
+ }
+ }
+}
+
+/**
+ * @typedef {Object} PackCell
+ * @property {number} span
+ */
+
+/**
+ * @typedef {Object} PackLayout
+ * @property {number} x
+ * @property {number} y
+ * @property {number} span
+ * @property {number} rows
+ */
diff --git a/website_next/heatmap/style.css b/website_next/heatmap/style.css
new file mode 100644
index 000000000..1d1cc7d4c
--- /dev/null
+++ b/website_next/heatmap/style.css
@@ -0,0 +1,30 @@
+[data-heatmap] {
+ --heatmap-cell-size: 0.4rem;
+ --heatmap-gap: 2px;
+
+ position: relative;
+ min-width: 0;
+}
+
+[data-heatmap-cell] {
+ position: absolute;
+ left: var(--x);
+ width: var(--size);
+ height: var(--size);
+ background: var(--color);
+}
+
+[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 983025d17..e2cdfe0da 100644
--- a/website_next/index.html
+++ b/website_next/index.html
@@ -102,6 +102,8 @@
+
+
@@ -117,6 +119,7 @@
+
diff --git a/website_next/qr/index.js b/website_next/qr/index.js
index 3b2a3f16f..3f8f2a215 100644
--- a/website_next/qr/index.js
+++ b/website_next/qr/index.js
@@ -22,5 +22,5 @@ const generateQr =
* @param {QrDataUrlOptions} [options]
*/
export function createQrDataUrl(value, options) {
- return generateQr(value)?.toDataURL(options) ?? "";
+ return generateQr(value)?.toDataURL({ padX: 0, padY: 0, ...options }) ?? "";
}
diff --git a/website_next/treemap/index.js b/website_next/treemap/index.js
new file mode 100644
index 000000000..761bff6eb
--- /dev/null
+++ b/website_next/treemap/index.js
@@ -0,0 +1,213 @@
+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
new file mode 100644
index 000000000..abdf08dc4
--- /dev/null
+++ b/website_next/treemap/style.css
@@ -0,0 +1,13 @@
+[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/transition.js b/website_next/utils/transition.js
index 19a2398e8..7bda023cf 100644
--- a/website_next/utils/transition.js
+++ b/website_next/utils/transition.js
@@ -16,7 +16,7 @@ function readCssDuration(name) {
return Number.parseFloat(value) * (value.endsWith("ms") ? 1 : 1000);
}
-function waitForTransition() {
+export function waitForTransition() {
return wait(readCssDuration("--transition-duration"));
}
diff --git a/website_next/wallets/scan/branches.js b/website_next/wallets/scan/branches.js
index 12885c15f..efd47995e 100644
--- a/website_next/wallets/scan/branches.js
+++ b/website_next/wallets/scan/branches.js
@@ -2,12 +2,15 @@ import {
scanBranch,
GAP_LIMIT,
} from "./branch.js";
+import { mapConcurrent } from "../concurrent.js";
import {
getOutputDescriptorBranchIds,
isOutputDescriptor,
} from "../derive/index.js";
import { isUsedAddress } from "./activity.js";
+const BRANCH_SCAN_CONCURRENCY = 2;
+
const keyBranches = /** @type {const} */ ([
{ id: "receive", label: "Receive", path: [0] },
{ id: "change", label: "Change", path: [1] },
@@ -85,9 +88,10 @@ function addBranch(address, branch) {
/**
* @param {string} source
+ * @returns {WalletBranch[]}
*/
function getWalletBranches(source) {
- if (!isOutputDescriptor(source)) return keyBranches;
+ if (!isOutputDescriptor(source)) return [...keyBranches];
const branchIds = new Set(getOutputDescriptorBranchIds(source));
const branches = descriptorBranches.filter((branch) => {
@@ -110,23 +114,29 @@ export async function scanBranches(client, source, options) {
branches.find((branch) => branch.id === "receive") ?? branches[0];
/** @type {ScannedAddress | undefined} */
let receiveAddress;
- let maxed = false;
+ const scans = await mapConcurrent(
+ branches,
+ BRANCH_SCAN_CONCURRENCY,
+ async (branch) => {
+ const scan = await scanBranch(client, source, {
+ script: options.script,
+ path: branch.path,
+ branchId: branch.id,
+ onProgress(progress) {
+ options.onProgress?.({
+ branchId: branch.id,
+ branchLabel: branch.label,
+ scannedCount: progress.scannedCount,
+ unusedInRow: progress.unusedInRow,
+ });
+ },
+ });
- for (const branch of branches) {
- const scan = await scanBranch(client, source, {
- script: options.script,
- path: branch.path,
- branchId: branch.id,
- onProgress(progress) {
- options.onProgress?.({
- branchId: branch.id,
- branchLabel: branch.label,
- scannedCount: progress.scannedCount,
- unusedInRow: progress.unusedInRow,
- });
- },
- });
+ return { branch, scan };
+ },
+ );
+ for (const { branch, scan } of scans) {
for (const address of scan.addresses) {
const branchedAddress = addBranch(address, branch);
@@ -139,14 +149,12 @@ export async function scanBranches(client, source, options) {
addresses.push(branchedAddress);
}
-
- maxed = maxed || scan.maxed;
}
return {
addresses: addresses.sort(compareWalletAddresses),
receiveAddress,
gapLimit: GAP_LIMIT,
- maxed,
+ maxed: scans.some(({ scan }) => scan.maxed),
};
}
diff --git a/website_next/wallets/scan/index.js b/website_next/wallets/scan/index.js
index c5c3296d3..3d6dc3b6f 100644
--- a/website_next/wallets/scan/index.js
+++ b/website_next/wallets/scan/index.js
@@ -1,8 +1,11 @@
import { scanBranches } from "./branches.js";
+import { mapConcurrent } from "../concurrent.js";
import { isOutputDescriptor } from "../derive/index.js";
import { parseOutputDescriptor } from "../derive/descriptor.js";
import { addressScripts } from "../derive/script.js";
+const SCRIPT_SCAN_CONCURRENCY = 2;
+
/**
* @typedef {import("../derive/address.js").AddressScript} AddressScript
* @typedef {import("../lookup/index.js").AddressClient} AddressClient
@@ -117,10 +120,10 @@ export async function scanWalletAddresses({
source,
onProgress,
}) {
- const scans = /** @type {ScriptScan[]} */ ([]);
-
- for (const script of getSourceScripts(source)) {
- scans.push(await scanBranches(client, source, {
+ const scans = await mapConcurrent(
+ getSourceScripts(source),
+ SCRIPT_SCAN_CONCURRENCY,
+ (script) => scanBranches(client, source, {
script: script.id,
onProgress(progress) {
onProgress?.({
@@ -128,12 +131,12 @@ export async function scanWalletAddresses({
branchLabel: `${script.label} ${progress.branchLabel}`,
});
},
- }));
- }
+ }),
+ );
const addresses = scans.flatMap((scan) => scan.addresses)
.sort(compareWalletAddresses);
- const btcUsdPrice = await client.getLivePrice({ cache: false });
+ const btcUsdPrice = await client.getLivePrice();
return {
addresses,
diff --git a/website_next/wallets/wallet/history/address.js b/website_next/wallets/wallet/history/address.js
index e040967fd..99b292d5c 100644
--- a/website_next/wallets/wallet/history/address.js
+++ b/website_next/wallets/wallet/history/address.js
@@ -3,6 +3,7 @@ import { createBucketKey } from "../bucket-key.js";
const HISTORY_CONCURRENCY = 4;
const MAX_SELECTED_ADDRESS_TXS = 100;
+const CACHE_KEY_SEPARATOR = "\n\n";
const historyByBucketKey =
/** @type {Map>>} */ (new Map());
@@ -17,65 +18,78 @@ const historyByBucketKey =
* @property {(address: string, options?: { cache?: boolean }) => Promise} getAddressTxs
*/
-/**
- * @typedef {Object} AddressHistory
- * @property {ApiTransaction[]} transactions
- */
-
/**
* @param {AddressHistoryClient} client
- * @param {readonly string[]} addresses
+ * @param {readonly string[]} bucketAddresses
+ * @param {ReadonlySet} selectedAddresses
* @returns {Promise