website_next: snapshot

This commit is contained in:
nym21
2026-07-03 21:06:32 +02:00
parent e0a618837e
commit 10ef95497f
73 changed files with 2241 additions and 733 deletions
+191
View File
@@ -0,0 +1,191 @@
import { createXyChart } from "../../chart/xy/index.js";
import { getPlotHeight, insetPlotY } from "../../chart/viewbox.js";
export const FEE_PERCENTILE_LABELS = /** @type {const} */ ([
"min",
"10%",
"25%",
"50%",
"75%",
"90%",
"max",
]);
const FEE_PERCENTILE_COLORS = /** @type {const} */ ([
"var(--cyan)",
"var(--blue)",
"var(--violet)",
"var(--white)",
"var(--yellow)",
"var(--orange)",
"var(--red)",
]);
const VIEWBOX_HEIGHT = 180;
const FEE_AVERAGE_COLOR = "var(--green)";
/** @param {number} value */
function scaleFeeRate(value) {
return Math.log10(value + 1);
}
/**
* @param {number[]} values
* @param {number} averageRate
* @returns {FeeEntry[]}
*/
function createEntries(values, averageRate) {
return [
...values.map((value, index) => ({
label: FEE_PERCENTILE_LABELS[index],
value,
color: FEE_PERCENTILE_COLORS[index],
pointIndex: index,
priority: 0,
})),
{
label: "avg",
value: averageRate,
color: FEE_AVERAGE_COLOR,
pointIndex: null,
priority: 1,
},
].sort((a, b) => a.value - b.value || a.priority - b.priority);
}
/**
* @param {FeeEntry[]} entries
* @returns {XySeries[]}
*/
function createSeries(entries) {
return [
{
label: "range",
color: () => "var(--gray)",
kind: /** @type {const} */ ("line"),
hidden: true,
},
...entries.map((entry) => ({
label: entry.label,
color: () => entry.color,
kind: /** @type {const} */ ("point"),
})),
];
}
/**
* @param {readonly number[]} values
* @param {ChartFrame} frame
* @returns {{ x: number, y: number, value: number }[]}
*/
function createPoints(values, frame) {
const scaledValues = values.map(scaleFeeRate);
const min = Math.min(...scaledValues);
const max = Math.max(...scaledValues);
const span = max - min;
const plotHeight = getPlotHeight(frame);
const xScale = frame.width / (scaledValues.length - 1);
return scaledValues.map((value, index) => ({
x: xScale * index,
y: span
? insetPlotY(frame, (1 - (value - min) / span) * plotHeight)
: insetPlotY(frame, plotHeight / 2),
value: values[index],
}));
}
/**
* @param {number[]} values
* @param {{ x: number, y: number, value: number }[]} points
* @param {number} target
*/
function interpolatePoint(values, points, target) {
const scaledValues = values.map(scaleFeeRate);
const scaledTarget = scaleFeeRate(target);
if (scaledTarget <= scaledValues[0]) {
return { ...points[0], value: target };
}
for (let index = 1; index < scaledValues.length; index += 1) {
if (scaledTarget > scaledValues[index]) continue;
const previousValue = scaledValues[index - 1];
const nextValue = scaledValues[index];
const previousPoint = points[index - 1];
const nextPoint = points[index];
const span = nextValue - previousValue;
const ratio = span ? (scaledTarget - previousValue) / span : 0;
return {
x: previousPoint.x + (nextPoint.x - previousPoint.x) * ratio,
y: previousPoint.y + (nextPoint.y - previousPoint.y) * ratio,
value: target,
};
}
return { ...points[points.length - 1], value: target };
}
/**
* @param {number[]} values
* @param {FeeEntry[]} entries
* @param {ChartFrame} frame
* @returns {XyPlottedSeries[]}
*/
function plotSeries(values, entries, frame) {
const points = createPoints(values, frame);
return [
{ points },
...entries.map((entry) => {
const point =
entry.pointIndex === null
? interpolatePoint(values, points, entry.value)
: points[entry.pointIndex];
return {
points: [point],
value: entry.value,
};
}),
];
}
/**
* @param {number[]} values
* @param {number} averageRate
* @param {(value: number) => string} formatRate
*/
export function createFeeChart(values, averageRate, formatRate) {
const entries = createEntries(values, averageRate);
const figure = createXyChart({
title: "Percentiles",
unit: {
id: "sat/vB",
name: "satoshis per virtual byte",
format: formatRate,
},
ariaLabel: `Fee rate percentiles from ${formatRate(
values[0],
)} to ${formatRate(values[values.length - 1])} sat/vB`,
fallbackHeight: VIEWBOX_HEIGHT,
series: createSeries(entries),
plot: (frame) => plotSeries(values, entries, frame),
marker: false,
});
figure.dataset.feeChart = "";
return figure;
}
/**
* @typedef {Object} FeeEntry
* @property {string} label
* @property {number} value
* @property {string} color
* @property {number | null} pointIndex
* @property {number} priority
*/
+232
View File
@@ -0,0 +1,232 @@
import { brk } from "../../utils/client.js";
import { createFeeChart } from "./fee-chart.js";
const SATS_PER_BTC = 100_000_000;
/** @typedef {Awaited<ReturnType<typeof brk.getBlocksV1>>[number]} Block */
/** @param {number} sats */
function formatBtc(sats) {
return `${(sats / SATS_PER_BTC).toFixed(8)} BTC`;
}
/** @param {number} bytes */
function formatBytes(bytes) {
return bytes >= 1_000_000
? `${(bytes / 1_000_000).toFixed(2)} MB`
: `${bytes.toLocaleString()} B`;
}
/** @param {number} rate */
function formatFeeRate(rate) {
if (rate >= 1_000_000) return `${(rate / 1_000_000).toFixed(1)}M`;
if (rate >= 100_000) return `${Math.round(rate / 1_000)}k`;
if (rate >= 1_000) return `${(rate / 1_000).toFixed(1)}k`;
if (rate >= 100) return Math.round(rate).toLocaleString();
if (rate >= 10) return rate.toFixed(1);
return rate.toFixed(2);
}
/** @param {number} unixSeconds */
function formatDateTime(unixSeconds) {
return new Date(unixSeconds * 1_000).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "medium",
});
}
/** @param {number} height */
function createHeightElement(height) {
const element = document.createElement("span");
const prefix = document.createElement("span");
const value = document.createElement("span");
prefix.classList.add("dim");
prefix.textContent = `#${"0".repeat(Math.max(0, 7 - String(height).length))}`;
value.textContent = String(height);
element.append(prefix, value);
return element;
}
/** @param {number} height */
function createTitle(height) {
const label = document.createElement("span");
const value = document.createElement("span");
label.classList.add("title-label");
value.classList.add("title-height");
label.textContent = "Block";
value.append(createHeightElement(height));
return [label, value];
}
/** @param {string} value */
function code(value) {
const element = document.createElement("code");
element.textContent = value;
return element;
}
/** @param {(string | Node | null)[]} values */
function joinValues(values) {
const fragment = document.createDocumentFragment();
let added = false;
for (const value of values) {
if (value == null || value === "") continue;
if (added) fragment.append(" · ");
fragment.append(value);
added = true;
}
return added ? fragment : null;
}
/** @param {string[]} values */
function joinText(values) {
return values.filter(Boolean).join(", ") || null;
}
/**
* @param {string} term
* @param {string | Node | null | undefined} value
*/
function createRow(term, value) {
if (value == null || value === "") return null;
const row = document.createElement("div");
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = term;
dd.append(value);
row.append(dt, dd);
return row;
}
/** @param {string} title */
function groupName(title) {
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
}
/**
* @param {HTMLElement} parent
* @param {string} title
* @param {[string, string | Node | null | undefined][]} rows
* @param {Node[]} [children]
*/
function appendGroup(parent, title, rows, children = []) {
const visibleRows = rows.flatMap(([term, value]) => {
const row = createRow(term, value);
return row ? [row] : [];
});
if (!visibleRows.length && !children.length) return;
const section = document.createElement("section");
const heading = document.createElement("h2");
section.dataset.group = groupName(title);
heading.textContent = title;
section.append(heading, ...children);
if (visibleRows.length) {
const list = document.createElement("dl");
list.append(...visibleRows);
section.append(list);
}
parent.append(section);
}
export function createBlockDetails() {
const element = document.createElement("section");
const header = document.createElement("header");
const title = document.createElement("h1");
const summary = document.createElement("p");
const content = document.createElement("div");
element.id = "block-details";
element.hidden = true;
header.append(title, summary);
element.append(header, content);
/** @param {Block} block */
function update(block) {
const extras = block.extras;
element.hidden = false;
title.replaceChildren(...createTitle(block.height));
summary.replaceChildren(
joinValues([
extras.pool.name,
formatDateTime(block.timestamp),
`${block.txCount.toLocaleString()} txs`,
]) ?? "",
);
for (const chart of content.querySelectorAll("[data-fee-chart]")) {
chart.dispatchEvent(new Event("chart:destroy"));
}
content.textContent = "";
appendGroup(content, "Overview", [
["Hash", code(block.id)],
["Previous", code(block.previousblockhash)],
["Merkle root", code(block.merkleRoot)],
["Timestamp", formatDateTime(block.timestamp)],
["Median time", formatDateTime(block.mediantime)],
["Version", `0x${block.version.toString(16)}`],
["Bits", `0x${block.bits.toString(16)}`],
["Nonce", block.nonce.toLocaleString()],
["Difficulty", block.difficulty.toLocaleString()],
["Stale", block.stale ? "yes" : null],
]);
appendGroup(content, "Mining", [
["Pool", extras.pool.name],
["Pool slug", extras.pool.slug],
["Miner names", joinText(extras.pool.minerNames ?? [])],
["Reward", formatBtc(extras.reward)],
["Total fees", formatBtc(extras.totalFees)],
["Price", `$${extras.price.toLocaleString()}`],
["Coinbase address", extras.coinbaseAddress ?? null],
["Coinbase addresses", joinText(extras.coinbaseAddresses)],
["Coinbase signature", extras.coinbaseSignatureAscii || null],
]);
appendGroup(content, "Transactions", [
["Count", block.txCount.toLocaleString()],
["Inputs", extras.totalInputs.toLocaleString()],
["Outputs", extras.totalOutputs.toLocaleString()],
["Input amount", formatBtc(extras.totalInputAmt)],
["Output amount", formatBtc(extras.totalOutputAmt)],
["UTXO set change", extras.utxoSetChange.toLocaleString()],
["UTXO set size", extras.utxoSetSize.toLocaleString()],
["SegWit transactions", extras.segwitTotalTxs.toLocaleString()],
]);
appendGroup(content, "Fees", [], [
createFeeChart(extras.feeRange, extras.avgFeeRate, formatFeeRate),
]);
appendGroup(content, "Size", [
["Size", formatBytes(block.size)],
["Weight", `${(block.weight / 1_000_000).toFixed(2)} MWU`],
["Virtual size", `${extras.virtualSize.toLocaleString()} vB`],
["Average tx size", formatBytes(extras.avgTxSize)],
["SegWit size", formatBytes(extras.segwitTotalSize)],
["SegWit weight", `${extras.segwitTotalWeight.toLocaleString()} WU`],
]);
}
return /** @type {const} */ ({
element,
update,
});
}
+184
View File
@@ -0,0 +1,184 @@
#block-details {
min-width: 0;
height: 100%;
min-height: 0;
overflow-y: auto;
padding: calc(var(--page-x) + 2.5rem) var(--page-x) var(--page-x) 0;
color: var(--white);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
scrollbar-width: none;
.dim {
opacity: 0.5;
}
:is(h1, h2, p, dl, dd) {
margin: 0;
}
> header {
display: grid;
gap: 0.5rem;
padding-bottom: 1.25rem;
h1 {
display: flex;
flex-wrap: wrap;
gap: 0.35em;
align-items: baseline;
min-width: 0;
overflow-wrap: anywhere;
font-family: var(--font-mono);
font-weight: 400;
line-height: 1;
}
.title-label {
font-family: var(--font-serif);
font-size: 2.5rem;
font-style: italic;
line-height: 0.9;
text-transform: lowercase;
}
.title-height {
color: var(--gray);
font-size: var(--font-size-lg);
line-height: var(--line-height-lg);
}
p {
color: var(--gray);
}
}
> div {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
section {
display: grid;
align-content: start;
gap: 0.75rem;
min-width: 0;
border: 1px solid color-mix(in oklch, var(--gray) 18%, transparent);
border-radius: 0.5rem;
padding: 1rem;
&[data-group="overview"] {
grid-column: 1 / -1;
}
&[data-group="mining"] {
border-color: color-mix(in oklch, var(--orange) 34%, transparent);
h2 {
color: var(--orange);
}
}
&[data-group="transactions"] {
border-color: color-mix(in oklch, var(--cyan) 28%, transparent);
h2 {
color: var(--cyan);
}
}
&[data-group="fees"] {
border-color: color-mix(in oklch, var(--green) 28%, transparent);
h2 {
color: var(--green);
}
figure[data-fee-chart] {
--chart-xy-height: 7.5rem;
}
}
&[data-group="size"] {
border-color: color-mix(in oklch, var(--blue) 28%, transparent);
h2 {
color: var(--blue);
}
}
}
h2 {
color: var(--gray);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 450;
line-height: var(--line-height-xs);
text-transform: uppercase;
}
dl {
display: grid;
> div {
display: grid;
grid-template-columns: minmax(6rem, 0.36fr) minmax(0, 1fr);
gap: 0.75rem;
padding: 0.35rem 0;
border-bottom: 1px solid
color-mix(in oklch, var(--gray) 12%, transparent);
&:first-child {
padding-top: 0;
}
&:last-child {
padding-bottom: 0;
border-bottom: 0;
}
}
}
dt {
color: var(--gray);
}
dd {
min-width: 0;
overflow-wrap: anywhere;
text-align: right;
}
code {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
}
}
@media (max-width: 48rem) {
#block-details {
min-width: 0;
height: auto;
padding: 1rem var(--page-x) var(--page-x);
> div {
grid-template-columns: minmax(0, 1fr);
}
section[data-group="overview"] {
grid-column: auto;
}
dl > div {
grid-template-columns: minmax(0, 1fr);
gap: 0.15rem;
}
dd {
text-align: left;
}
}
}