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>} */ -async function fetchBucketHistory(client, addresses) { - const entries = await mapConcurrent( - addresses, +async function fetchBucketHistory(client, bucketAddresses, selectedAddresses) { + const history = /** @type {Map} */ (new Map()); + + await mapConcurrent( + bucketAddresses, HISTORY_CONCURRENCY, async (address) => { const transactions = await client.getAddressTxs(address, { cache: false }); - return /** @type {const} */ ([address, transactions]); + if (selectedAddresses.has(address)) { + history.set(address, transactions); + } }, ); - return new Map(entries); + return history; +} + +/** @param {WalletAddress} address */ +function canLoadHistory(address) { + return ( + address.txCount <= MAX_SELECTED_ADDRESS_TXS && + address.historyAddresses.length > 0 + ); +} + +/** @param {readonly WalletAddress[]} addresses */ +function createHistoryCacheKey(addresses) { + return [ + createBucketKey(addresses[0].historyAddresses), + createBucketKey(addresses.map((address) => address.address)), + ].join(CACHE_KEY_SEPARATOR); } /** * @param {AddressHistoryClient} client - * @param {WalletAddress} address - * @returns {Promise} + * @param {readonly WalletAddress[]} addresses + * @returns {Promise>} */ -async function load(client, address) { - if ( - address.txCount > MAX_SELECTED_ADDRESS_TXS || - address.historyAddresses.length === 0 - ) { - return { - transactions: [], - }; - } +async function loadBucket(client, addresses) { + const selectedAddresses = addresses.filter(canLoadHistory); + if (!selectedAddresses.length) return new Map(); - const key = createBucketKey(address.historyAddresses); + const key = createHistoryCacheKey(selectedAddresses); let history = historyByBucketKey.get(key); if (!history) { - history = fetchBucketHistory(client, address.historyAddresses).catch( - (error) => { - historyByBucketKey.delete(key); - throw error; - }, + const bucketAddresses = selectedAddresses[0].historyAddresses; + const selectedAddressSet = new Set( + selectedAddresses.map((address) => address.address), ); + + history = fetchBucketHistory( + client, + bucketAddresses, + selectedAddressSet, + ).catch((error) => { + historyByBucketKey.delete(key); + throw error; + }); historyByBucketKey.set(key, history); } - const bucketHistory = await history; - - return { - transactions: bucketHistory.get(address.address) ?? [], - }; + return history; } export const addressHistory = /** @type {const} */ ({ - load, + loadBucket, }); diff --git a/website_next/wallets/wallet/history/cache.js b/website_next/wallets/wallet/history/cache.js index 2ed32daa3..954e5f020 100644 --- a/website_next/wallets/wallet/history/cache.js +++ b/website_next/wallets/wallet/history/cache.js @@ -1,6 +1,10 @@ import { addressHistory } from "./address.js"; +import { mapConcurrent } from "../../concurrent.js"; +import { createBucketKey } from "../bucket-key.js"; import { readWalletTransaction } from "./transaction.js"; +const HISTORY_BUCKET_CONCURRENCY = 2; + /** * @typedef {import("../../scan/index.js").WalletAddress} WalletAddress * @typedef {import("./transaction.js").ApiTransaction} ApiTransaction @@ -19,6 +23,24 @@ function isUsedAddress(address) { return address.txCount > 0; } +/** + * @param {readonly WalletAddress[]} addresses + * @returns {WalletAddress[][]} + */ +function groupAddressesByBucket(addresses) { + const groups = /** @type {Map} */ (new Map()); + + for (const address of addresses) { + const key = createBucketKey(address.historyAddresses); + const group = groups.get(key) ?? []; + + group.push(address); + groups.set(key, group); + } + + return [...groups.values()]; +} + /** * @param {WalletTransaction} a * @param {WalletTransaction} b @@ -44,15 +66,23 @@ async function load(client, addresses) { new Map() ); const usedAddresses = addresses.filter(isUsedAddress); + const histories = await mapConcurrent( + groupAddressesByBucket(usedAddresses), + HISTORY_BUCKET_CONCURRENCY, + (group) => addressHistory.loadBucket(client, group), + ); - for (const address of usedAddresses) { - const history = await addressHistory.load(client, address); + for (const history of histories) { + for (const transactions of history.values()) { + for (const transaction of transactions) { + const walletTransaction = readWalletTransaction( + transaction, + usedAddresses, + ); - for (const transaction of history.transactions) { - const walletTransaction = readWalletTransaction(transaction, usedAddresses); - - if (walletTransaction.txid) { - transactionsById.set(walletTransaction.txid, walletTransaction); + if (walletTransaction.txid) { + transactionsById.set(walletTransaction.txid, walletTransaction); + } } } } diff --git a/website_next/wallets/wallet/receive/index.js b/website_next/wallets/wallet/receive/index.js index 976490bcf..bb1ada64c 100644 --- a/website_next/wallets/wallet/receive/index.js +++ b/website_next/wallets/wallet/receive/index.js @@ -2,25 +2,11 @@ import { createQrDataUrl } from "../../../qr/index.js"; import { openDialog } from "../../../dialog/index.js"; import { createGroupedAddress } from "../address/index.js"; import { createWalletPart } from "../../dom.js"; -import { formatNumber } from "../../format.js"; /** * @typedef {import("../../scan/index.js").WalletAddress} ReceiveAddress */ -/** - * @param {ReceiveAddress} receiveAddress - */ -function createReceiveTitle(receiveAddress) { - const title = document.createElement("h2"); - - title.append( - `${receiveAddress.branchLabel.toLowerCase()} #${formatNumber(receiveAddress.index)}`, - ); - - return title; -} - /** * @param {ReceiveAddress} receiveAddress */ @@ -29,7 +15,7 @@ function createReceiveQr(receiveAddress) { const uri = `bitcoin:${receiveAddress.address}`; image.alt = `QR code for ${receiveAddress.address}`; - image.src = createQrDataUrl(uri, { scale: 8 }); + image.src = createQrDataUrl(uri, { scale: 6 }); return image; } @@ -47,11 +33,13 @@ function createReceiveAddress(receiveAddress) { /** * @param {ReceiveAddress} receiveAddress - * @param {HTMLButtonElement} copy + * @param {HTMLElement} content + * @param {() => void} onCopied */ -async function copyReceiveAddress(receiveAddress, copy) { +async function copyReceiveAddress(receiveAddress, content, onCopied) { await navigator.clipboard.writeText(receiveAddress.address); - copy.textContent = "Copied"; + content.dataset.copied = ""; + onCopied(); } /** @@ -61,30 +49,31 @@ async function copyReceiveAddress(receiveAddress, copy) { function openReceiveDialog(host, receiveAddress) { const dialog = createWalletPart("dialog", "receive"); const content = document.createElement("article"); - const actions = document.createElement("footer"); - const copy = document.createElement("button"); - const closeForm = document.createElement("form"); - const close = document.createElement("button"); + let copiedTimeout = 0; - copy.type = "button"; - copy.append("Copy"); - closeForm.method = "dialog"; - close.type = "submit"; - close.append("Close"); - closeForm.append(close); - actions.append(copy, closeForm); + content.role = "button"; + content.tabIndex = 0; content.append( - createReceiveTitle(receiveAddress), createReceiveQr(receiveAddress), createReceiveAddress(receiveAddress), - actions, ); dialog.append(content); - copy.addEventListener("click", () => { - void copyReceiveAddress(receiveAddress, copy).catch(() => { - copy.textContent = "Copy failed"; - }); + function copy() { + void copyReceiveAddress(receiveAddress, content, () => { + window.clearTimeout(copiedTimeout); + copiedTimeout = window.setTimeout(() => { + delete content.dataset.copied; + }, 1_000); + }).catch(() => {}); + } + + content.addEventListener("click", copy); + content.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + + event.preventDefault(); + copy(); }); openDialog(dialog, host); } diff --git a/website_next/wallets/wallet/receive/style.css b/website_next/wallets/wallet/receive/style.css index 9bd989688..87974510b 100644 --- a/website_next/wallets/wallet/receive/style.css +++ b/website_next/wallets/wallet/receive/style.css @@ -1,40 +1,76 @@ main[data-page="wallets"] { dialog[data-wallet="receive"] { - width: min(100% - 2rem, 32rem); + --dialog-space: 1rem; + + width: min(100% - 2rem, 10rem); + border-radius: 0.125rem; > article { + position: relative; display: grid; - gap: 1rem; + gap: 0.375rem; + cursor: pointer; - > h2 { - margin: 0; - font-size: var(--font-size-lg); - font-weight: var(--font-weight-regular); - line-height: var(--line-height-lg); + &:focus-visible { + outline: 2px solid var(--orange); + outline-offset: 0.25rem; + } + + &::after { + content: "Copied"; + position: absolute; + top: 50%; + left: 50%; + color: var(--black); + font-size: var(--font-size-sm); + opacity: 0; + pointer-events: none; + translate: -50% calc(-50% + 0.25rem); + transition: + opacity 150ms ease, + translate 150ms ease; + text-transform: uppercase; + } + + &[data-copied]::after { + opacity: 1; + translate: -50% -50%; + } + + &[data-copied] > * { + opacity: 0.1; } > img { justify-self: center; - width: min(100%, 18rem); + width: 100%; aspect-ratio: 1; - padding: 1rem; background: var(--white); image-rendering: pixelated; } + > img, > p { - margin: 0; - font-size: var(--font-size-sm); - line-height: var(--line-height-sm); + transition: opacity 150ms ease; } - > footer { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - justify-content: end; - > form { - margin: 0; + > p { + margin: 0; + font-size: var(--font-size-xs); + font-style: italic; + line-height: var(--line-height-xs); + + [data-wallet="address"] { + max-width: 100%; + gap: 0 0.25rem; + + > span { + color: var(--black); + + > var { + color: color-mix(in oklch, var(--black) 75%, var(--gray)); + } + } } } }