website_next: part 5

This commit is contained in:
nym21
2026-07-06 15:54:25 +02:00
parent 3f2a48f248
commit 2207ec1b7e
25 changed files with 1083 additions and 130 deletions
+6 -1
View File
@@ -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();
}
+32
View File
@@ -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;
}
}
}
+1 -1
View File
@@ -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) {
+19 -4
View File
@@ -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),
]);
@@ -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],
}));
}
@@ -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
*/
+102
View File
@@ -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 */
@@ -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 */
@@ -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;
}
+1 -1
View File
@@ -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);
+59
View File
@@ -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
*/
+160
View File
@@ -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
*/
+89
View File
@@ -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
*/
+30
View File
@@ -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;
}
+3
View File
@@ -102,6 +102,8 @@
<link rel="stylesheet" href="/btc/style.css" />
<link rel="stylesheet" href="/usd/style.css" />
<link rel="stylesheet" href="/legend/style.css" />
<link rel="stylesheet" href="/treemap/style.css" />
<link rel="stylesheet" href="/heatmap/style.css" />
<link rel="stylesheet" href="/cube/style.css" />
<link rel="stylesheet" href="/brand/style.css" />
<link rel="stylesheet" href="/header/style.css" />
@@ -117,6 +119,7 @@
<link rel="stylesheet" href="/explore/block/rewards/style.css" />
<link rel="stylesheet" href="/explore/block/transactions/style.css" />
<link rel="stylesheet" href="/explore/block/fee-chart/style.css" />
<link rel="stylesheet" href="/explore/block/preview/style.css" />
<link rel="stylesheet" href="/explore/block/receipt/style.css" />
<link rel="stylesheet" href="/learn/style.css" />
<link rel="stylesheet" href="/chart/style.css" />
+1 -1
View File
@@ -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 }) ?? "";
}
+213
View File
@@ -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
*/
+13
View File
@@ -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;
}
}
+1 -1
View File
@@ -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"));
}
+27 -19
View File
@@ -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),
};
}
+10 -7
View File
@@ -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,
+48 -34
View File
@@ -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<string, Promise<Map<string, ApiTransaction[]>>>} */ (new Map());
@@ -17,65 +18,78 @@ const historyByBucketKey =
* @property {(address: string, options?: { cache?: boolean }) => Promise<ApiTransaction[]>} getAddressTxs
*/
/**
* @typedef {Object} AddressHistory
* @property {ApiTransaction[]} transactions
*/
/**
* @param {AddressHistoryClient} client
* @param {readonly string[]} addresses
* @param {readonly string[]} bucketAddresses
* @param {ReadonlySet<string>} selectedAddresses
* @returns {Promise<Map<string, ApiTransaction[]>>}
*/
async function fetchBucketHistory(client, addresses) {
const entries = await mapConcurrent(
addresses,
async function fetchBucketHistory(client, bucketAddresses, selectedAddresses) {
const history = /** @type {Map<string, ApiTransaction[]>} */ (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<AddressHistory>}
* @param {readonly WalletAddress[]} addresses
* @returns {Promise<Map<string, ApiTransaction[]>>}
*/
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,
});
+37 -7
View File
@@ -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<string, WalletAddress[]>} */ (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);
}
}
}
}
+24 -35
View File
@@ -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);
}
+55 -19
View File
@@ -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));
}
}
}
}
}