mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-10 02:28:13 -07:00
website_next: part 5
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 }) ?? "";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user