website_next: snapshot

This commit is contained in:
nym21
2026-07-04 13:27:57 +02:00
parent 10ef95497f
commit a99c06013b
33 changed files with 1539 additions and 641 deletions
+154
View File
@@ -0,0 +1,154 @@
export const SATS_PER_BTC = 100_000_000;
const FRACTION_DIGITS = 8;
/**
* @typedef {Object} BtcAmountOptions
* @property {boolean} [signed]
*
* @typedef {Object} BtcPart
* @property {string} text
* @property {boolean} muted
*/
/**
* @param {BtcPart[]} parts
* @param {string} text
* @param {boolean} muted
*/
function pushPart(parts, text, muted) {
const last = parts[parts.length - 1];
if (last && last.muted === muted) {
last.text += text;
return;
}
parts.push({ text, muted });
}
/**
* @param {number} value
*/
function formatInteger(value) {
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
}
/**
* @param {number} sats
*/
function splitBtc(sats) {
const absolute = Math.abs(sats);
return {
whole: Math.floor(absolute / SATS_PER_BTC),
fraction: String(absolute % SATS_PER_BTC).padStart(FRACTION_DIGITS, "0"),
};
}
/**
* @param {string} fraction
* @param {(index: number) => boolean} isMuted
* @param {(index: number) => boolean} isSpaceMuted
*/
function getFractionParts(fraction, isMuted, isSpaceMuted) {
const parts = /** @type {BtcPart[]} */ ([]);
for (let index = 0; index < fraction.length; index += 1) {
pushPart(parts, fraction[index], isMuted(index));
if (index === 1 || index === 4) {
pushPart(parts, " ", isSpaceMuted(index));
}
}
return parts;
}
/**
* @param {number} sats
* @param {BtcAmountOptions} [options]
*/
export function getBtcParts(sats, options = {}) {
const parts = /** @type {BtcPart[]} */ ([]);
const { whole, fraction } = splitBtc(sats);
const firstFractionDigit = fraction.search(/[1-9]/);
const lastFractionDigit = Math.max(...[...fraction].map((digit, index) => {
return digit === "0" ? -1 : index;
}));
if (options.signed && sats > 0) pushPart(parts, "+", false);
if (sats < 0) pushPart(parts, "-", false);
pushPart(parts, "₿", true);
if (whole === 0) {
const mutedUntil = firstFractionDigit === -1
? FRACTION_DIGITS
: firstFractionDigit;
pushPart(parts, "0.", true);
for (const part of getFractionParts(
fraction,
(index) => index < mutedUntil,
(index) => index < mutedUntil,
)) {
pushPart(parts, part.text, part.muted);
}
return parts;
}
pushPart(parts, formatInteger(whole), false);
if (lastFractionDigit === -1) {
pushPart(parts, ".", true);
for (const part of getFractionParts(fraction, () => true, () => true)) {
pushPart(parts, part.text, part.muted);
}
return parts;
}
pushPart(parts, ".", false);
for (const part of getFractionParts(
fraction,
(index) => index > lastFractionDigit,
(index) => index >= lastFractionDigit,
)) {
pushPart(parts, part.text, part.muted);
}
return parts;
}
/**
* @param {HTMLElement} element
* @param {number} sats
* @param {BtcAmountOptions} [options]
*/
export function renderBtcAmount(element, sats, options = {}) {
element.replaceChildren(...getBtcParts(sats, options).map((part) => {
const span = document.createElement("span");
if (part.muted) span.classList.add("muted");
span.append(part.text);
return span;
}));
}
/**
* @template {keyof HTMLElementTagNameMap} Tag
* @param {Tag} tag
* @param {number} sats
* @param {BtcAmountOptions} [options]
*/
export function createBtcAmount(tag, sats, options = {}) {
const element = document.createElement(tag);
element.classList.add("amount");
renderBtcAmount(element, sats, options);
return element;
}
+5
View File
@@ -0,0 +1,5 @@
.amount {
.muted {
color: color-mix(in oklch, currentColor 45%, transparent);
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ function createAreaPoints(frame, points) {
return points.map((point) => ({
...point,
y0: bottom,
y1: point.y,
plotY0: bottom,
plotY1: point.plotY,
}));
}
+10 -8
View File
@@ -1,25 +1,27 @@
import { createLinePathData, formatCoordinate } from "../path.js";
import { VIEWBOX_WIDTH } from "../viewbox.js";
import { createStackedSeries } from "../stacked/series.js";
import { clamp } from "../math.js";
import { appendSeriesPath } from "../series-path.js";
/** @param {StackedPoint[]} points */
function getBarWidth(points) {
return points.length > 1 ? (VIEWBOX_WIDTH / (points.length - 1)) * 0.8 : 1;
return points.length > 1
? Math.abs(points[1].plotX - points[0].plotX) * 0.8
: 1;
}
/**
* @param {ChartFrame} frame
* @param {StackedPoint[]} points
* @param {number} width
*/
function createBarPathData(points, width) {
function createBarPathData(frame, points, width) {
return points
.map(({ x, y0, y1 }) => {
const left = clamp(x - width / 2, 0, VIEWBOX_WIDTH - width);
.map(({ plotX, plotY0, plotY1 }) => {
const left = clamp(plotX - width / 2, frame.left, frame.right - width);
const right = left + width;
const top = Math.min(y0, y1);
const bottom = Math.max(y0, y1);
const top = Math.min(plotY0, plotY1);
const bottom = Math.max(plotY0, plotY1);
return (
`M${formatCoordinate(left)} ${formatCoordinate(top)}` +
@@ -56,7 +58,7 @@ export function renderBarPlot({
index,
chart: "bar",
color,
d: createBarPathData(points, getBarWidth(points)),
d: createBarPathData(frame, points, getBarWidth(points)),
});
}
+2 -2
View File
@@ -9,8 +9,8 @@ const radius = 1;
function createDotsPathData(points) {
return points
.map(
({ x, y }) =>
`M${formatCoordinate(x - radius)} ${formatCoordinate(y)}` +
({ plotX, plotY }) =>
`M${formatCoordinate(plotX - radius)} ${formatCoordinate(plotY)}` +
`a${radius} ${radius} 0 1 0 ${radius * 2} 0` +
`a${radius} ${radius} 0 1 0 ${radius * -2} 0`,
)
+23
View File
@@ -0,0 +1,23 @@
/**
* @template {{ plotX: number }} T
* @param {T[]} points
* @param {number} plotX
* @param {(point: T) => number} read
*/
export function interpolatePlotValue(points, plotX, read) {
if (plotX <= points[0].plotX) return read(points[0]);
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const next = points[index];
if (plotX > next.plotX) continue;
const span = next.plotX - previous.plotX;
const ratio = span ? (plotX - previous.plotX) / span : 0;
return read(previous) + (read(next) - read(previous)) * ratio;
}
return read(points[points.length - 1]);
}
+13 -12
View File
@@ -1,3 +1,9 @@
import {
appendLegendListItem,
createLegendItem,
createLegendList,
} from "../../legend/index.js";
/**
* @param {LegendChart} chart
* @returns {{ legend: HTMLElement, menu: HTMLElement, items: (HTMLElement | null)[], readout: LegendReadout }}
@@ -8,22 +14,17 @@ export function createLegend(chart) {
const title = document.createElement("h5");
const separator = document.createElement("span");
const unit = document.createElement("span");
const menu = document.createElement("menu");
const menu = createLegendList({ scroll: true });
const rows = chart.series.map((series) => {
if (series.hidden) return null;
const item = document.createElement("li");
const button = document.createElement("button");
const label = document.createElement("span");
const value = document.createElement("output");
const { button, value } = createLegendItem({
label: series.label,
color: series.color(),
ariaLabel: `Highlight ${series.label}`,
});
button.type = "button";
button.setAttribute("aria-label", `Highlight ${series.label}`);
button.style.setProperty("--color", series.color());
label.append(series.label);
button.append(label, value);
item.append(button);
menu.append(item);
appendLegendListItem(menu, button);
return { button, value };
});
-97
View File
@@ -20,99 +20,6 @@ figure[data-chart-legend] {
color: var(--gray);
}
menu {
display: flex;
padding: 0.25rem 0 0.5rem;
overflow-x: auto;
list-style: none;
}
li {
flex: 0 0 auto;
}
button {
display: block;
min-width: 8.5ch;
padding: 0.25rem 0.375rem;
border: 0;
border-radius: 0.25rem;
color: inherit;
background: none;
font: inherit;
text-align: inherit;
text-transform: inherit;
cursor: pointer;
@media (hover: hover) and (pointer: fine) {
&:hover {
color: var(--black);
background: var(--color);
span,
output {
color: inherit;
}
}
}
&[data-press] {
color: var(--black);
background: var(--color);
span,
output {
color: inherit;
}
}
&:is([data-active], [data-preview]) {
color: var(--black);
background: var(--color);
span,
output {
color: inherit;
}
}
&:focus-visible {
outline: 1px solid var(--orange);
outline-offset: 0.125rem;
}
&[data-muted] {
opacity: 0.35;
}
> span {
display: block;
color: var(--color);
text-align: left;
&::before {
content: "";
display: inline-block;
width: 0.5em;
height: 0.5em;
margin-right: 0.35em;
margin-bottom: 0.1rem;
border-radius: 50%;
background: currentColor;
}
}
> output {
display: block;
margin-top: 0.25rem;
margin-left: auto;
width: 7ch;
min-height: 1em;
color: var(--white);
font-variant-numeric: tabular-nums;
text-align: right;
}
}
}
&:fullscreen {
@@ -123,10 +30,6 @@ figure[data-chart-legend] {
font-size: 2rem;
text-transform: none;
}
menu {
padding-bottom: 0.5rem;
}
}
}
+19 -40
View File
@@ -1,13 +1,16 @@
import { getPlotHeight, insetPlotY, VIEWBOX_WIDTH } from "../viewbox.js";
import { createBounds, includeBoundValue, scaleY } from "../scale.js";
import { interpolatePlotValue } from "../interpolate.js";
import { createChartPoints } from "../points.js";
import { createBounds, includeBoundValue } from "../scale.js";
import { createStepXScale } from "../x.js";
import { createYScale } from "../y.js";
/** @param {LoadedSeries[]} series */
function createValueBounds(series) {
const bounds = createBounds();
for (const { entries } of series) {
for (const { value } of entries) {
includeBoundValue(bounds, value);
for (const { samples } of series) {
for (const { y } of samples) {
includeBoundValue(bounds, y);
}
}
@@ -15,44 +18,17 @@ function createValueBounds(series) {
}
/**
* @param {ChartEntry[]} entries
* @param {ChartSample[]} samples
* @param {ScaleBounds} bounds
* @param {ChartFrame} frame
* @param {ChartScale} scale
* @returns {ChartPoint[]}
*/
function createPoints(entries, bounds, frame, scale) {
const xScale = VIEWBOX_WIDTH / (entries.length - 1);
const plotHeight = getPlotHeight(frame);
function createPoints(samples, bounds, frame, scale) {
const scaleX = createStepXScale(frame, samples.length);
const scalePlotY = createYScale(frame, bounds, scale);
return entries.map(({ date, value }, index) => ({
date,
value,
x: index * xScale,
y: insetPlotY(frame, scaleY(value, bounds, plotHeight, scale)),
}));
}
/**
* @param {ChartPoint[]} points
* @param {number} x
*/
function interpolateY(points, x) {
if (x <= points[0].x) return points[0].y;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const next = points[index];
if (x > next.x) continue;
const span = next.x - previous.x;
const ratio = span ? (x - previous.x) / span : 0;
return previous.y + (next.y - previous.y) * ratio;
}
return points[points.length - 1].y;
return createChartPoints(samples, scaleX, scalePlotY);
}
/**
@@ -63,8 +39,8 @@ function interpolateY(points, x) {
export function createLineSeries(loadedSeries, frame, scale) {
const bounds = createValueBounds(loadedSeries);
return loadedSeries.map(({ series, color, entries }) => {
const points = createPoints(entries, bounds, frame, scale);
return loadedSeries.map(({ series, color, samples }) => {
const points = createPoints(samples, bounds, frame, scale);
return {
series,
@@ -72,7 +48,10 @@ export function createLineSeries(loadedSeries, frame, scale) {
points,
hitTest: /** @type {PlottedSeries["hitTest"]} */ (
(_point, pointerX, pointerY) =>
Math.abs(interpolateY(points, pointerX) - pointerY)
Math.abs(
interpolatePlotValue(points, pointerX, ({ plotY }) => plotY) -
pointerY,
)
),
};
});
+10 -10
View File
@@ -3,20 +3,20 @@ import { fetchTimeframe } from "./timeframes.js";
/**
* @param {ChartResult} result
* @returns {ChartEntry[]}
* @returns {ChartSample[]}
*/
function createEntries(result) {
/** @type {ChartEntry[]} */
const entries = [];
function createSamples(result) {
/** @type {ChartSample[]} */
const samples = [];
/** @type {number | undefined} */
let lastValue;
let lastY;
for (const [date, value] of result.dateEntries()) {
if (typeof value === "number" && Number.isFinite(value)) lastValue = value;
if (lastValue !== undefined) entries.push({ date, value: lastValue });
for (const [x, y] of result.dateEntries()) {
if (typeof y === "number" && Number.isFinite(y)) lastY = y;
if (lastY !== undefined) samples.push({ x, y: lastY });
}
return entries;
return samples;
}
/**
@@ -28,7 +28,7 @@ function loadSeries(chart, timeframe) {
chart.series.map(async (item) => ({
series: item,
color: item.color(),
entries: createEntries(await fetchTimeframe(item.metric(brk), timeframe)),
samples: createSamples(await fetchTimeframe(item.metric(brk), timeframe)),
})),
);
}
+8 -6
View File
@@ -12,23 +12,25 @@ function createPathCommand(command, x, y) {
return `${command}${formatCoordinate(x)} ${formatCoordinate(y)}`;
}
/** @param {{ x: number, y: number }[]} points */
/** @param {ChartPoint[]} points */
export function createLinePathData(points) {
return points
.map(({ x, y }, index) => createPathCommand(index ? "L" : "M", x, y))
.map(({ plotX, plotY }, index) =>
createPathCommand(index ? "L" : "M", plotX, plotY),
)
.join(" ");
}
/** @param {StackedPoint[]} points */
export function createAreaPathData(points) {
const commands = points.map(({ x, y1 }, index) =>
createPathCommand(index ? "L" : "M", x, y1),
const commands = points.map(({ plotX, plotY1 }, index) =>
createPathCommand(index ? "L" : "M", plotX, plotY1),
);
for (let index = points.length - 1; index >= 0; index -= 1) {
const { x, y0 } = points[index];
const { plotX, plotY0 } = points[index];
commands.push(createPathCommand("L", x, y0));
commands.push(createPathCommand("L", plotX, plotY0));
}
return `${commands.join(" ")} Z`;
+25
View File
@@ -0,0 +1,25 @@
/**
* @param {ChartSample} sample
* @param {number} index
* @param {(x: ChartX, index: number) => number} scaleX
* @param {(y: number, index: number) => number} scaleY
* @returns {ChartPoint}
*/
export function createChartPoint(sample, index, scaleX, scaleY) {
return {
...sample,
plotX: scaleX(sample.x, index),
plotY: scaleY(sample.y, index),
};
}
/**
* @param {ChartSample[]} samples
* @param {(x: ChartX, index: number) => number} scaleX
* @param {(y: number, index: number) => number} scaleY
*/
export function createChartPoints(samples, scaleX, scaleY) {
return samples.map((sample, index) =>
createChartPoint(sample, index, scaleX, scaleY),
);
}
+14 -11
View File
@@ -32,7 +32,7 @@ function getClosestPointIndex(series, points, x, y) {
for (const [index, point] of points.entries()) {
const distance =
series[index].hitTest?.(point, x, y) ?? Math.abs(point.y - y);
series[index].hitTest?.(point, x, y) ?? Math.abs(point.plotY - y);
if (distance < closestDistance) {
closestIndex = index;
@@ -51,7 +51,7 @@ function getClosestPointIndex(series, points, x, y) {
function updateReadout(readout, points, format) {
readout.rows.forEach((row, index) => {
if (!row) return;
row.value.textContent = format(points[index].value);
row.value.textContent = format(points[index].y);
});
}
@@ -114,32 +114,34 @@ export function createScrubber(svg, readout, highlight, format) {
currentStep = nextStep;
currentPoints = getPointsAtStep(nextStep);
const stepX = currentPoints[0].x;
const xText = stepX.toFixed(2);
const plotX = currentPoints[0].plotX;
const xText = plotX.toFixed(2);
const plotBottom = getPlotBottom(frame);
svg.dataset.index = nextStep.toString();
shade.setAttribute("x", xText);
shade.setAttribute("y", "0");
shade.setAttribute("width", (VIEWBOX_WIDTH - stepX).toFixed(2));
shade.setAttribute("width", (frame.right - plotX).toFixed(2));
shade.setAttribute("height", plotBottom.toString());
guide.setAttribute("x1", xText);
guide.setAttribute("x2", xText);
guide.setAttribute("y1", "0");
guide.setAttribute("y2", plotBottom.toString());
dateMarker.textContent = dateFormat.format(currentPoints[0].date);
dateMarker.textContent = dateFormat.format(
/** @type {Date} */ (currentPoints[0].x),
);
dateMarker.setAttribute(
"aria-label",
`Date ${dateMarker.textContent}`,
);
layoutChartMarker(dateMarker, stepX);
layoutChartMarker(dateMarker, plotX);
updateReadout(readout, currentPoints, format);
markers.forEach((marker, index) => {
const point = currentPoints[index];
marker.setAttribute("cx", point.x.toFixed(2));
marker.setAttribute("cy", point.y.toFixed(2));
marker.setAttribute("cx", point.plotX.toFixed(2));
marker.setAttribute("cy", point.plotY.toFixed(2));
});
}
@@ -213,11 +215,12 @@ export function createScrubber(svg, readout, highlight, format) {
pointerFrame = requestAnimationFrame(() => {
pointerFrame = 0;
if (!frame) return;
const x = ((pointerX - rect.left) / rect.width) * VIEWBOX_WIDTH;
const y = ((pointerY - rect.top) / rect.height) * (frame?.height ?? 0);
const y = ((pointerY - rect.top) / rect.height) * frame.height;
update(x / VIEWBOX_WIDTH, x, y);
update((x - frame.left) / frame.plotWidth, x, y);
});
}
+47 -57
View File
@@ -1,6 +1,8 @@
import { getPlotHeight, insetPlotY, VIEWBOX_WIDTH } from "../viewbox.js";
import { interpolatePlotValue } from "../interpolate.js";
import { orderIndexes } from "../order.js";
import { createBounds, includeBoundValue, scaleY } from "../scale.js";
import { createBounds, includeBoundValue } from "../scale.js";
import { createStepXScale } from "../x.js";
import { createYScale } from "../y.js";
/**
* @param {LoadedSeries[]} series
@@ -9,7 +11,7 @@ import { createBounds, includeBoundValue, scaleY } from "../scale.js";
*/
function createStackBounds(series, stackOrder, lineIndexes) {
const bounds = createBounds();
const length = series[0].entries.length;
const length = series[0].samples.length;
includeBoundValue(bounds, 0);
@@ -18,48 +20,25 @@ function createStackBounds(series, stackOrder, lineIndexes) {
let positive = 0;
for (const seriesIndex of stackOrder) {
const value = series[seriesIndex].entries[index].value;
const end = value < 0 ? negative + value : positive + value;
const y = series[seriesIndex].samples[index].y;
const end = y < 0 ? negative + y : positive + y;
if (value < 0) negative = end;
if (y < 0) negative = end;
else positive = end;
includeBoundValue(bounds, end);
}
for (const seriesIndex of lineIndexes) {
const value = series[seriesIndex].entries[index].value;
const y = series[seriesIndex].samples[index].y;
includeBoundValue(bounds, value);
includeBoundValue(bounds, y);
}
}
return bounds;
}
/**
* @param {StackedPoint[]} points
* @param {number} x
* @param {"y" | "y0" | "y1"} key
*/
function interpolateStackY(points, x, key) {
if (x <= points[0].x) return points[0][key];
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const next = points[index];
if (x > next.x) continue;
const span = next.x - previous.x;
const ratio = span ? (x - previous.x) / span : 0;
return previous[key] + (next[key] - previous[key]) * ratio;
}
return points[points.length - 1][key];
}
/**
* @param {LoadedSeries[]} loadedSeries
* @param {ChartFrame} frame
@@ -77,9 +56,8 @@ export function createStackedSeries(loadedSeries, frame, order, scale) {
order,
);
const length = loadedSeries[0].entries.length;
const xScale = VIEWBOX_WIDTH / (length - 1);
const plotHeight = getPlotHeight(frame);
const length = loadedSeries[0].samples.length;
const scaleX = createStepXScale(frame, length);
const plottedSeries = loadedSeries.map(({ series, color }) => ({
series,
color,
@@ -88,6 +66,7 @@ export function createStackedSeries(loadedSeries, frame, order, scale) {
}));
const bounds = createStackBounds(loadedSeries, stackIndexes, lineIndexes);
const scalePlotY = createYScale(frame, bounds, scale);
for (const index of stackIndexes) {
const points = plottedSeries[index].points;
@@ -95,10 +74,18 @@ export function createStackedSeries(loadedSeries, frame, order, scale) {
plottedSeries[index].hitTest = (_point, pointerX, pointerY) => {
if (!points.length) return Infinity;
const y0 = interpolateStackY(points, pointerX, "y0");
const y1 = interpolateStackY(points, pointerX, "y1");
const top = Math.min(y0, y1);
const bottom = Math.max(y0, y1);
const plotY0 = interpolatePlotValue(
points,
pointerX,
(point) => point.plotY0,
);
const plotY1 = interpolatePlotValue(
points,
pointerX,
(point) => point.plotY1,
);
const top = Math.min(plotY0, plotY1);
const bottom = Math.max(plotY0, plotY1);
return pointerY >= top && pointerY <= bottom
? 0
@@ -110,46 +97,49 @@ export function createStackedSeries(loadedSeries, frame, order, scale) {
const points = plottedSeries[index].points;
plottedSeries[index].hitTest = (_point, pointerX, pointerY) =>
Math.abs(interpolateStackY(points, pointerX, "y") - pointerY);
Math.abs(
interpolatePlotValue(points, pointerX, ({ plotY }) => plotY) -
pointerY,
);
}
for (let index = 0; index < length; index += 1) {
let negative = 0;
let positive = 0;
const x = index * xScale;
for (const seriesIndex of stackIndexes) {
const { date, value } = loadedSeries[seriesIndex].entries[index];
const start = value < 0 ? negative : positive;
const end = start + value;
const { x, y } = loadedSeries[seriesIndex].samples[index];
const start = y < 0 ? negative : positive;
const end = start + y;
const plotX = scaleX(x, index);
if (value < 0) negative = end;
if (y < 0) negative = end;
else positive = end;
const y0 = insetPlotY(frame, scaleY(start, bounds, plotHeight, scale));
const y1 = insetPlotY(frame, scaleY(end, bounds, plotHeight, scale));
const plotY0 = scalePlotY(start);
const plotY1 = scalePlotY(end);
plottedSeries[seriesIndex].points.push({
date,
value,
x,
y: y1,
y0,
y1,
y,
plotX,
plotY: plotY1,
plotY0,
plotY1,
});
}
for (const seriesIndex of lineIndexes) {
const { date, value } = loadedSeries[seriesIndex].entries[index];
const y = insetPlotY(frame, scaleY(value, bounds, plotHeight, scale));
const { x, y } = loadedSeries[seriesIndex].samples[index];
const plotY = scalePlotY(y);
plottedSeries[seriesIndex].points.push({
date,
value,
x,
y,
y0: y,
y1: y,
plotX: scaleX(x, index),
plotY,
plotY0: plotY,
plotY1: plotY,
});
}
}
+17 -16
View File
@@ -5,15 +5,16 @@ import { timeframes, timeframeOptions } from "./timeframes.js";
import { views } from "./views.js";
declare global {
type ChartEntry = {
date: Date;
value: number;
type ChartX = Date | number;
type ChartSample = {
x: ChartX;
y: number;
};
type ChartMetric = (client: typeof brk) => TimeframeMetric;
type ChartOrder = (typeof orders)[number]["value"];
type ChartPoint = ChartEntry & {
x: number;
y: number;
type ChartPoint = ChartSample & {
plotX: number;
plotY: number;
};
type ChartResult = {
dateEntries(): Iterable<[Date, number | null | undefined]>;
@@ -41,11 +42,16 @@ declare global {
type ChartFrame = {
width: number;
height: number;
left: number;
right: number;
top: number;
bottom: number;
plotWidth: number;
plotHeight: number;
};
type ChartFrameOptions = {
leftPadding?: number;
rightPadding?: number;
topPadding?: number;
bottomPadding?: number;
};
@@ -56,7 +62,7 @@ declare global {
type LoadedSeries = {
series: ChartSeries;
color: string;
entries: ChartEntry[];
samples: ChartSample[];
};
type PlotContext = {
group: SVGGElement;
@@ -91,21 +97,16 @@ declare global {
preview(index: number): void;
};
type StackedPoint = ChartPoint & {
y0: number;
y1: number;
plotY0: number;
plotY1: number;
};
type StackedPlottedSeries = Omit<PlottedSeries, "points" | "hitTest"> & {
points: StackedPoint[];
hitTest?: PlottedSeries["hitTest"];
};
type XyPoint = {
x: number;
y: number;
value: number;
};
type XyPlottedSeries = {
points: XyPoint[];
value?: number | string;
points: ChartPoint[];
readout?: number | string;
};
type XySeries = {
label: string;
+20
View File
@@ -34,21 +34,33 @@ export function createChartFrame(
) {
const height = getViewBoxHeight(svg, fallbackHeight);
const unit = getViewBoxUnit(svg, height);
const leftPadding = options.leftPadding ?? 0;
const rightPadding = options.rightPadding ?? 0;
const topPadding =
options.topPadding ?? CHART_MARKER.height + CHART_FRAME.topGap;
const bottomPadding = options.bottomPadding ?? CHART_FRAME.bottomPadding;
const left = leftPadding * unit;
const right = Math.max(left + 1, VIEWBOX_WIDTH - rightPadding * unit);
const top = topPadding * unit;
const bottom = Math.max(top + 1, height - bottomPadding * unit);
return {
width: VIEWBOX_WIDTH,
height,
left,
right,
top,
bottom,
plotWidth: right - left,
plotHeight: bottom - top,
};
}
/** @param {ChartFrame} frame */
export function getPlotWidth(frame) {
return frame.plotWidth;
}
/** @param {ChartFrame} frame */
export function getPlotHeight(frame) {
return frame.plotHeight;
@@ -59,6 +71,14 @@ export function getPlotBottom(frame) {
return frame.bottom;
}
/**
* @param {ChartFrame} frame
* @param {number} x
*/
export function insetPlotX(frame, x) {
return frame.left + x;
}
/**
* @param {ChartFrame} frame
* @param {number} y
+33
View File
@@ -0,0 +1,33 @@
import { getPlotWidth, insetPlotX } from "./viewbox.js";
/**
* @param {ChartFrame} frame
* @param {number} count
*/
export function createStepXScale(frame, count) {
const width = getPlotWidth(frame);
const last = count - 1;
return /** @param {ChartX} _x @param {number} index */ (_x, index) =>
insetPlotX(frame, last > 0 ? (index / last) * width : width / 2);
}
/**
* @param {ChartFrame} frame
* @param {number[]} values
*/
export function createLinearXScale(frame, values) {
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min;
const width = getPlotWidth(frame);
return /** @param {ChartX} x */ (x) => {
const value = /** @type {number} */ (x);
return insetPlotX(
frame,
span ? ((value - min) / span) * width : width / 2,
);
};
}
+11 -32
View File
@@ -14,7 +14,7 @@ import { createChartFrame, VIEWBOX_WIDTH } from "../viewbox.js";
* @param {ChartFrameOptions} [args.gutter]
* @param {XySeries[]} args.series
* @param {(frame: ChartFrame) => XyPlottedSeries[]} args.plot
* @param {false | ((series: XySeries, plotted: XyPlottedSeries, point: XyPoint) => string)} [args.marker]
* @param {false | ((series: XySeries, plotted: XyPlottedSeries, point: ChartPoint) => string)} [args.marker]
*/
export function createXyChart({
title,
@@ -114,26 +114,26 @@ export function createXyChart({
}
/**
* @param {{ index: number, point: XyPoint }} closest
* @param {{ index: number, point: ChartPoint }} closest
* @param {ChartFrame} frame
*/
function showMarker(closest, frame) {
const plotted = currentSeries[closest.index];
const item = series[closest.index];
guide.setAttribute("x1", closest.point.x.toFixed(2));
guide.setAttribute("x2", closest.point.x.toFixed(2));
guide.setAttribute("x1", closest.point.plotX.toFixed(2));
guide.setAttribute("x2", closest.point.plotX.toFixed(2));
guide.setAttribute("y1", "0");
guide.setAttribute("y2", frame.bottom.toString());
const text =
marker === false
? ""
: (marker?.(item, plotted, closest.point) ??
unit.format(closest.point.value));
unit.format(closest.point.y));
markerElement.textContent = text;
markerElement.hidden = !text;
if (text) layoutChartMarker(markerElement, closest.point.x);
if (text) layoutChartMarker(markerElement, closest.point.plotX);
svg.dataset.xyHover = "true";
highlight.preview(closest.index);
}
@@ -173,7 +173,7 @@ export function createXyChart({
* @param {number} y
*/
function findClosestPoint(series, plottedSeries, x, y) {
/** @type {{ index: number, point: XyPoint } | null} */
/** @type {{ index: number, point: ChartPoint } | null} */
let closest = null;
let closestDistance = Infinity;
@@ -181,7 +181,7 @@ function findClosestPoint(series, plottedSeries, x, y) {
if (series[index].hidden) return;
for (const point of item.points) {
const distance = Math.hypot(point.x - x, point.y - y);
const distance = Math.hypot(point.plotX - x, point.plotY - y);
if (distance < closestDistance) {
closest = { index, point };
@@ -202,7 +202,7 @@ function updateReadout(readout, unit, plottedSeries) {
readout.rows.forEach((row, index) => {
if (!row) return;
const value = plottedSeries[index]?.value;
const value = plottedSeries[index]?.readout;
row.value.textContent =
typeof value === "number" ? unit.format(value) : (value ?? "");
@@ -242,31 +242,10 @@ function appendPoints(group, highlight, series, plotted, index, radius) {
circle.dataset.chart = "xy-point";
circle.dataset.series = index.toString();
circle.style.setProperty("--color", series.color());
circle.setAttribute("cx", point.x.toFixed(2));
circle.setAttribute("cy", point.y.toFixed(2));
circle.setAttribute("cx", point.plotX.toFixed(2));
circle.setAttribute("cy", point.plotY.toFixed(2));
circle.setAttribute("r", radius.toString());
highlight.addNode(circle, index);
group.append(circle);
}
}
/**
* @typedef {Object} XyPoint
* @property {number} x
* @property {number} y
* @property {number} value
*/
/**
* @typedef {Object} XyPlottedSeries
* @property {XyPoint[]} points
* @property {number | string} [value]
*/
/**
* @typedef {Object} XySeries
* @property {string} label
* @property {() => string} color
* @property {"line" | "point"} kind
* @property {boolean} [hidden]
*/
+33
View File
@@ -0,0 +1,33 @@
import { scaleY } from "./scale.js";
import { getPlotHeight, insetPlotY } from "./viewbox.js";
/**
* @param {ChartFrame} frame
* @param {ScaleBounds} bounds
* @param {ChartScale} scale
*/
export function createYScale(frame, bounds, scale) {
const height = getPlotHeight(frame);
return /** @param {number} y */ (y) =>
insetPlotY(frame, scaleY(y, bounds, height, scale));
}
/**
* @param {ChartFrame} frame
* @param {number[]} values
* @param {(value: number) => number} [transform]
*/
export function createValueYScale(frame, values, transform = (value) => value) {
const scaledValues = values.map(transform);
const min = Math.min(...scaledValues);
const max = Math.max(...scaledValues);
const span = max - min;
const height = getPlotHeight(frame);
return /** @param {number} y */ (y) =>
insetPlotY(
frame,
span ? (1 - (transform(y) - min) / span) * height : height / 2,
);
}
+75 -80
View File
@@ -1,5 +1,7 @@
import { createXyChart } from "../../chart/xy/index.js";
import { getPlotHeight, insetPlotY } from "../../chart/viewbox.js";
import { createChartPoint, createChartPoints } from "../../chart/points.js";
import { createLinearXScale } from "../../chart/x.js";
import { createValueYScale } from "../../chart/y.js";
export const FEE_PERCENTILE_LABELS = /** @type {const} */ ([
"min",
@@ -21,6 +23,7 @@ const FEE_PERCENTILE_COLORS = /** @type {const} */ ([
"var(--red)",
]);
const FEE_PERCENTILE_X = /** @type {const} */ ([0, 10, 25, 50, 75, 90, 100]);
const VIEWBOX_HEIGHT = 180;
const FEE_AVERAGE_COLOR = "var(--green)";
@@ -30,27 +33,66 @@ function scaleFeeRate(value) {
}
/**
* @param {number[]} values
* @param {readonly number[]} values
* @returns {ChartSample[]}
*/
function createPercentileSamples(values) {
return values.map((y, index) => ({ x: FEE_PERCENTILE_X[index], y }));
}
/**
* @param {ChartSample[]} samples
* @param {number} averageRate
*/
function createAverageSample(samples, averageRate) {
const scaledValues = samples.map(({ y }) => scaleFeeRate(y));
const scaledAverage = scaleFeeRate(averageRate);
if (scaledAverage <= scaledValues[0]) {
return { x: samples[0].x, y: averageRate };
}
for (let index = 1; index < scaledValues.length; index += 1) {
if (scaledAverage > scaledValues[index]) continue;
const previousValue = scaledValues[index - 1];
const nextValue = scaledValues[index];
const previousSample = samples[index - 1];
const nextSample = samples[index];
const span = nextValue - previousValue;
const ratio = span ? (scaledAverage - previousValue) / span : 0;
const previousX = /** @type {number} */ (previousSample.x);
const nextX = /** @type {number} */ (nextSample.x);
return {
x: previousX + (nextX - previousX) * ratio,
y: averageRate,
};
}
return { x: samples[samples.length - 1].x, y: averageRate };
}
/**
* @param {ChartSample[]} percentileSamples
* @param {number} averageRate
* @returns {FeeEntry[]}
*/
function createEntries(values, averageRate) {
function createEntries(percentileSamples, averageRate) {
return [
...values.map((value, index) => ({
...percentileSamples.map((sample, index) => ({
label: FEE_PERCENTILE_LABELS[index],
value,
sample,
color: FEE_PERCENTILE_COLORS[index],
pointIndex: index,
priority: 0,
})),
{
label: "avg",
value: averageRate,
sample: createAverageSample(percentileSamples, averageRate),
color: FEE_AVERAGE_COLOR,
pointIndex: null,
priority: 1,
},
].sort((a, b) => a.value - b.value || a.priority - b.priority);
].sort((a, b) => a.sample.y - b.sample.y || a.priority - b.priority);
}
/**
@@ -74,80 +116,33 @@ function createSeries(entries) {
}
/**
* @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 {ChartSample[]} percentileSamples
* @param {FeeEntry[]} entries
* @param {ChartFrame} frame
* @returns {XyPlottedSeries[]}
*/
function plotSeries(values, entries, frame) {
const points = createPoints(values, frame);
function plotSeries(percentileSamples, entries, frame) {
const scaleX = createLinearXScale(
frame,
percentileSamples.map(({ x }) => /** @type {number} */ (x)),
);
const scalePlotY = createValueYScale(
frame,
entries.map(({ sample }) => sample.y),
scaleFeeRate,
);
const percentilePoints = createChartPoints(
percentileSamples,
scaleX,
scalePlotY,
);
return [
{ points },
{ points: percentilePoints },
...entries.map((entry) => {
const point =
entry.pointIndex === null
? interpolatePoint(values, points, entry.value)
: points[entry.pointIndex];
return {
points: [point],
value: entry.value,
points: [createChartPoint(entry.sample, 0, scaleX, scalePlotY)],
readout: entry.sample.y,
};
}),
];
@@ -159,9 +154,10 @@ function plotSeries(values, entries, frame) {
* @param {(value: number) => string} formatRate
*/
export function createFeeChart(values, averageRate, formatRate) {
const entries = createEntries(values, averageRate);
const percentileSamples = createPercentileSamples(values);
const entries = createEntries(percentileSamples, averageRate);
const figure = createXyChart({
title: "Percentiles",
title: "Fees",
unit: {
id: "sat/vB",
name: "satoshis per virtual byte",
@@ -172,7 +168,7 @@ export function createFeeChart(values, averageRate, formatRate) {
)} to ${formatRate(values[values.length - 1])} sat/vB`,
fallbackHeight: VIEWBOX_HEIGHT,
series: createSeries(entries),
plot: (frame) => plotSeries(values, entries, frame),
plot: (frame) => plotSeries(percentileSamples, entries, frame),
marker: false,
});
@@ -184,8 +180,7 @@ export function createFeeChart(values, averageRate, formatRate) {
/**
* @typedef {Object} FeeEntry
* @property {string} label
* @property {number} value
* @property {ChartSample} sample
* @property {string} color
* @property {number | null} pointIndex
* @property {number} priority
*/
+372 -85
View File
@@ -1,14 +1,17 @@
import { createBtcAmount, SATS_PER_BTC } from "../../btc/index.js";
import {
appendLegendListItem,
createLegendItem,
createLegendList,
} from "../../legend/index.js";
import { createPoolLogo } from "../../pools/index.js";
import { createUsdAmount, renderUsdAmount } from "../../usd/index.js";
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`;
}
const MAX_BLOCK_WEIGHT = 4_000_000;
/** @param {number} bytes */
function formatBytes(bytes) {
@@ -49,6 +52,23 @@ function createHeightElement(height) {
return element;
}
/** @param {string} hash */
function createHashElement(hash) {
const element = document.createElement("span");
const prefix = document.createElement("span");
const value = document.createElement("span");
const firstNonZero = hash.search(/[^0]/);
const visibleStart = firstNonZero === -1 ? hash.length : firstNonZero;
element.dataset.blockHash = "";
prefix.classList.add("dim");
prefix.textContent = hash.slice(0, visibleStart);
value.textContent = hash.slice(visibleStart);
element.append(prefix, value);
return element;
}
/** @param {number} height */
function createTitle(height) {
const label = document.createElement("span");
@@ -62,35 +82,6 @@ function createTitle(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
@@ -109,18 +100,344 @@ function createRow(term, value) {
return row;
}
/**
* @param {string} label
* @param {string | Node} value
*/
function createStat(label, value) {
const stat = document.createElement("div");
const name = document.createElement("span");
const amount = document.createElement("strong");
stat.dataset.stat = "";
name.textContent = label;
amount.append(value);
stat.append(name, amount);
return stat;
}
/** @param {[string, string | Node][]} items */
function createStats(items) {
const stats = document.createElement("div");
stats.dataset.stats = "";
stats.append(...items.map(([label, value]) => createStat(label, value)));
return stats;
}
/** @param {string} title */
function groupName(title) {
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
}
/**
* @param {string} title
* @param {[string, string | Node][]} stats
* @param {Node[]} [children]
*/
function createStatBox(title, stats, children = []) {
const box = document.createElement("div");
const heading = document.createElement("h3");
box.dataset.statBox = groupName(title);
heading.textContent = title;
box.append(heading, createStats(stats), ...children);
return box;
}
/**
* @param {string} label
* @param {(string | Node)[]} values
*/
function createInlineRow(label, values) {
const row = document.createElement("div");
const name = document.createElement("span");
const data = document.createElement("strong");
row.dataset.inlineRow = "";
name.textContent = label;
data.append(...values);
row.append(name, data);
return row;
}
/**
* @param {string} label
* @param {string | Node} value
*/
function createInlineBox(label, value) {
const box = document.createElement("div");
box.dataset.blockBox = "inline";
box.append(createInlineRow(label, [value]));
return box;
}
/** @param {Block} block */
function formatBlockFill(block) {
return `${((block.weight / MAX_BLOCK_WEIGHT) * 100).toFixed(1)}%`;
}
/** @param {number | string} bits */
function formatBits(bits) {
return typeof bits === "number" ? `0x${bits.toString(16)}` : bits;
}
/**
* @param {string} label
* @param {string} value
*/
function createMinerStat(label, value) {
const stat = document.createElement("div");
const name = document.createElement("span");
const amount = document.createElement("strong");
stat.dataset.minerStat = "";
name.textContent = label;
amount.textContent = value;
stat.append(name, amount);
return stat;
}
/** @param {Block} block */
function createMinerSummary(block) {
const { pool } = block.extras;
const pane = document.createElement("div");
const head = document.createElement("div");
const identity = document.createElement("div");
const name = document.createElement("strong");
const slug = document.createElement("span");
const stats = document.createElement("div");
const logo = createPoolLogo(pool);
pane.dataset.minerPane = "";
head.dataset.minerHead = "";
identity.dataset.minerIdentity = "";
slug.dataset.minerSlug = "";
stats.dataset.minerStats = "";
logo.dataset.minerLogo = "";
name.textContent = pool.name;
slug.textContent = `#${pool.slug}`;
identity.append(name, slug);
head.append(identity, logo);
stats.append(
createMinerStat("Difficulty", block.difficulty.toLocaleString()),
createMinerStat("Bits", formatBits(block.bits)),
createMinerStat("Nonce", block.nonce.toLocaleString()),
);
pane.append(head, stats);
return pane;
}
/**
* @param {number} sats
* @param {number} total
*/
function formatShare(sats, total) {
return `${((sats / total) * 100).toFixed(2)}%`;
}
/**
* @param {number} sats
* @param {number} price
*/
function getSatsUsd(sats, price) {
return (sats / SATS_PER_BTC) * price;
}
/**
* @param {number} sats
* @param {number} price
*/
function createSatsUsdAmount(sats, price) {
return createUsdAmount("span", getSatsUsd(sats, price));
}
/**
* @param {number} sats
* @param {number} total
* @param {number} price
*/
function createRewardDetail(sats, total, price) {
const detail = document.createDocumentFragment();
detail.append(createSatsUsdAmount(sats, price), " · ", formatShare(sats, total));
return detail;
}
const REWARD_COLORS = /** @type {const} */ ({
subsidy: "var(--orange)",
fees: "var(--green)",
});
/** @typedef {keyof typeof REWARD_COLORS} RewardType */
/**
* @param {RewardType} type
* @param {number} sats
* @param {number} total
*/
function createRewardSegment(type, sats, total) {
const segment = document.createElement("span");
segment.dataset.rewardSegment = type;
segment.dataset.rewardKey = type;
segment.style.setProperty("--share", `${(sats / total) * 100}%`);
return segment;
}
/**
* @param {RewardType} type
* @param {string} label
* @param {number} sats
* @param {number} total
* @param {number} price
*/
function createRewardPart(type, label, sats, total, price) {
const { button: part, value } = createLegendItem({
label,
color: REWARD_COLORS[type],
ariaLabel: `Highlight ${label}`,
detail: createRewardDetail(sats, total, price),
});
const amount = createBtcAmount("span", sats);
part.dataset.rewardPart = type;
part.dataset.rewardKey = type;
value.replaceChildren(amount);
return part;
}
/**
* @param {number} sats
* @param {number} price
*/
function createRewardTotal(sats, price) {
const total = document.createElement("div");
const amount = createBtcAmount("strong", sats);
const usd = createSatsUsdAmount(sats, price);
total.dataset.rewardTotal = "";
total.append(amount, usd);
return total;
}
/** @param {EventTarget | null} target */
function getRewardKey(target) {
if (!(target instanceof HTMLElement)) return null;
return target.closest("[data-reward-key]")?.getAttribute("data-reward-key") ?? null;
}
/**
* @param {HTMLElement} rewards
* @param {string | null} activeKey
*/
function setRewardPreview(rewards, activeKey) {
for (const element of rewards.querySelectorAll("[data-reward-key]")) {
if (!(element instanceof HTMLElement)) continue;
if (element.dataset.rewardKey === activeKey) {
element.dataset.preview = "";
delete element.dataset.muted;
} else if (activeKey) {
element.dataset.muted = "";
delete element.dataset.preview;
} else {
delete element.dataset.muted;
delete element.dataset.preview;
}
}
}
/** @param {Block["extras"]} extras */
function createRewardSummary(extras) {
const subsidy = extras.reward - extras.totalFees;
const bar = document.createElement("div");
const split = createLegendList({ fill: true });
const rewards = createStatBox(
"Rewards",
[],
[
createRewardTotal(extras.reward, extras.price),
bar,
split,
],
);
appendLegendListItem(
split,
createRewardPart("subsidy", "Subsidy", subsidy, extras.reward, extras.price),
);
appendLegendListItem(
split,
createRewardPart("fees", "Fees", extras.totalFees, extras.reward, extras.price),
);
bar.dataset.rewardBar = "";
bar.append(
createRewardSegment("subsidy", subsidy, extras.reward),
createRewardSegment("fees", extras.totalFees, extras.reward),
);
rewards.addEventListener("pointerenter", (event) => {
setRewardPreview(rewards, getRewardKey(event.target));
}, true);
rewards.addEventListener("pointerleave", () => setRewardPreview(rewards, null));
rewards.addEventListener("pointerdown", (event) => {
setRewardPreview(rewards, getRewardKey(event.target));
});
rewards.addEventListener("pointerup", () => setRewardPreview(rewards, null));
rewards.addEventListener("pointercancel", () => setRewardPreview(rewards, null));
return rewards;
}
/** @param {Block} block */
function createTransactionSummary(block) {
const { extras } = block;
const box = document.createElement("div");
const transactions = document.createElement("div");
const io = document.createElement("div");
box.dataset.blockBox = "";
transactions.dataset.blockBox = "tx";
io.dataset.blockIo = "";
io.append(
createInlineBox("Input", extras.totalInputs.toLocaleString()),
createInlineBox("Output", extras.totalOutputs.toLocaleString()),
);
transactions.append(
createInlineRow("Tx", [block.txCount.toLocaleString()]),
io,
);
box.append(
createInlineRow("Block", [`${formatBytes(block.size)} · ${formatBlockFill(block)}`]),
transactions,
);
return box;
}
/**
* @param {HTMLElement} parent
* @param {string} title
* @param {[string, string | Node | null | undefined][]} rows
* @param {Node[]} [children]
* @param {boolean} [showHeading]
*/
function appendGroup(parent, title, rows, children = []) {
function appendGroup(parent, title, rows, children = [], showHeading = true) {
const visibleRows = rows.flatMap(([term, value]) => {
const row = createRow(term, value);
@@ -134,7 +451,7 @@ function appendGroup(parent, title, rows, children = []) {
section.dataset.group = groupName(title);
heading.textContent = title;
section.append(heading, ...children);
section.append(...(showHeading ? [heading] : []), ...children);
if (visibleRows.length) {
const list = document.createElement("dl");
@@ -147,13 +464,20 @@ function appendGroup(parent, title, rows, children = []) {
export function createBlockDetails() {
const element = document.createElement("section");
const header = document.createElement("header");
const titleRow = document.createElement("div");
const title = document.createElement("h1");
const summary = document.createElement("p");
const price = createUsdAmount("output", 0, {
size: "title",
tone: "positive",
});
const content = document.createElement("div");
element.id = "block-details";
element.hidden = true;
header.append(title, summary);
titleRow.dataset.blockTitle = "";
titleRow.append(title, price);
header.append(titleRow, summary);
element.append(header, content);
/** @param {Block} block */
@@ -163,66 +487,29 @@ export function createBlockDetails() {
element.hidden = false;
title.replaceChildren(...createTitle(block.height));
summary.replaceChildren(
joinValues([
extras.pool.name,
formatDateTime(block.timestamp),
`${block.txCount.toLocaleString()} txs`,
]) ?? "",
createHashElement(block.id),
document.createElement("br"),
formatDateTime(block.timestamp),
);
renderUsdAmount(price, extras.price, {
size: "title",
tone: "positive",
});
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", [], [createMinerSummary(block)], false);
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, "Rewards", [], [createRewardSummary(extras)], false);
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, "Block", [], [createTransactionSummary(block)], false);
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} */ ({
+270 -20
View File
@@ -20,9 +20,16 @@
> header {
display: grid;
gap: 0.5rem;
padding-bottom: 1.25rem;
[data-block-title] {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.35rem 1rem;
}
h1 {
display: flex;
flex-wrap: wrap;
@@ -65,33 +72,278 @@
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);
--section-color: var(--orange);
h2 {
color: var(--orange);
[data-miner-pane] {
display: grid;
gap: 0.75rem;
min-width: 0;
}
[data-miner-head] {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
align-items: start;
min-width: 0;
}
[data-miner-identity] {
display: grid;
min-width: 0;
> strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--white);
font-size: var(--font-size-sm);
font-weight: 450;
line-height: var(--line-height-sm);
}
}
[data-miner-slug] {
min-width: 0;
overflow-wrap: anywhere;
color: var(--gray);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
}
[data-miner-logo] {
width: 2rem;
height: 2rem;
object-fit: contain;
}
[data-miner-stats] {
display: grid;
gap: 0.35rem;
min-width: 0;
}
[data-miner-stat] {
display: grid;
grid-template-columns: minmax(5.5rem, auto) minmax(0, 1fr);
gap: 0.75rem;
align-items: center;
min-width: 0;
> span {
color: var(--section-color);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
text-transform: uppercase;
}
strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--white);
font-size: var(--font-size-sm);
font-weight: 450;
line-height: var(--line-height-sm);
text-align: right;
}
}
}
&[data-group="transactions"] {
border-color: color-mix(in oklch, var(--cyan) 28%, transparent);
&[data-group="block"] {
--section-color: var(--cyan);
h2 {
color: var(--cyan);
color: var(--section-color);
}
}
&[data-group="rewards"] {
--section-color: var(--orange);
}
&[data-group="rewards"] {
[data-stat-box] {
display: grid;
gap: 0.75rem;
min-width: 0;
border: 1px solid
color-mix(in oklch, var(--section-color) 28%, transparent);
border-radius: 0.25rem;
padding: 0.75rem;
h3 {
color: var(--section-color);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 450;
line-height: var(--line-height-xs);
text-transform: uppercase;
}
[data-stat-box] {
border-color: color-mix(
in oklch,
var(--section-color) 18%,
transparent
);
}
}
[data-stats] {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
min-width: 0;
}
[data-stat] {
display: grid;
gap: 0.25rem;
min-width: 0;
> span {
color: var(--section-color);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
text-transform: uppercase;
}
strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--white);
font-size: var(--font-size-sm);
font-weight: 450;
line-height: var(--line-height-sm);
}
}
}
&[data-group="block"] {
[data-block-box] {
display: grid;
gap: 0.5rem;
min-width: 0;
border: 1px solid
color-mix(in oklch, var(--section-color) 28%, transparent);
border-radius: 0.25rem;
padding: 0.75rem;
[data-block-box] {
border-color: color-mix(
in oklch,
var(--section-color) 18%,
transparent
);
}
}
[data-inline-row] {
display: grid;
grid-template-columns: minmax(4.5rem, auto) minmax(0, 1fr);
gap: 0.75rem;
align-items: center;
min-width: 0;
> span {
color: var(--section-color);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
text-transform: uppercase;
}
strong {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: flex-end;
min-width: 0;
color: var(--white);
font-size: var(--font-size-sm);
font-weight: 450;
line-height: var(--line-height-sm);
text-align: right;
}
}
[data-block-io] {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.5rem;
min-width: 0;
[data-block-box] {
aspect-ratio: 1 / 1;
place-content: center;
padding: 0.5rem;
}
[data-inline-row] {
grid-template-columns: minmax(0, 1fr);
justify-items: center;
gap: 0.25rem;
strong {
justify-content: center;
text-align: center;
}
}
}
}
&[data-group="rewards"] {
[data-reward-total] {
display: grid;
gap: 0.25rem;
justify-items: center;
min-width: 0;
color: var(--gray);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
text-align: center;
strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--white);
font-size: var(--font-size-sm);
font-weight: 450;
line-height: var(--line-height-sm);
}
}
[data-reward-bar] {
display: flex;
gap: 0.125rem;
height: 0.5rem;
min-width: 0;
}
[data-reward-segment] {
width: var(--share);
border-radius: 0.125rem;
transition: opacity var(--transition-duration) ease;
&[data-reward-segment="subsidy"] {
background: var(--orange);
}
&[data-reward-segment="fees"] {
background: var(--green);
}
&[data-muted] {
opacity: 0.25;
}
}
}
&[data-group="fees"] {
border-color: color-mix(in oklch, var(--green) 28%, transparent);
h2 {
color: var(--green);
}
@@ -100,14 +352,6 @@
--chart-xy-height: 7.5rem;
}
}
&[data-group="size"] {
border-color: color-mix(in oklch, var(--blue) 28%, transparent);
h2 {
color: var(--blue);
}
}
}
h2 {
@@ -172,6 +416,12 @@
grid-column: auto;
}
section[data-group="rewards"] {
[data-stats] {
grid-template-columns: minmax(0, 1fr);
}
}
dl > div {
grid-template-columns: minmax(0, 1fr);
gap: 0.15rem;
+3 -13
View File
@@ -1,3 +1,4 @@
import { createPoolLogo, getPoolDisplayName } from "../../pools/index.js";
import { brk } from "../../utils/client.js";
import { isPlainLeftClick } from "../../utils/event.js";
import { createCubeButton, createCubeDiv } from "./cube/index.js";
@@ -72,11 +73,6 @@ function span(text, className) {
return element;
}
/** @param {string} name */
function poolSlug(name) {
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
}
/** @param {number} unixSeconds */
function formatShortDate(unixSeconds) {
const date = new Date(unixSeconds * 1_000);
@@ -682,16 +678,10 @@ export function createChain({ onSelect = () => {} } = {}) {
height.append(createHeightElement(block.height));
const poolElement = document.createElement("div");
const logo = document.createElement("img");
const logo = createPoolLogo(pool);
const name = document.createElement("span");
poolElement.classList.add("pool");
logo.src = `/assets/pools/${poolSlug(pool.name)}.svg`;
logo.alt = "";
logo.onerror = () => {
logo.onerror = null;
logo.src = "/assets/pools/default.svg";
};
name.textContent = pool.name.replace(/\s+(Pool|USA)$/i, "").trim();
name.textContent = getPoolDisplayName(pool.name);
poolElement.append(logo, name);
cube.rightFace.append(height, poolElement);
+3 -1
View File
@@ -98,6 +98,9 @@
<link rel="stylesheet" href="/styles/fonts.css" />
<link rel="stylesheet" href="/styles/main.css" />
<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="/cube/style.css" />
<link rel="stylesheet" href="/header/style.css" />
@@ -122,7 +125,6 @@
<link rel="stylesheet" href="/build/style.css" />
<link rel="stylesheet" href="/wallets/style.css" />
<link rel="stylesheet" href="/dialog/style.css" />
<link rel="stylesheet" href="/wallets/amount/style.css" />
<link rel="stylesheet" href="/wallets/hold/style.css" />
<link rel="stylesheet" href="/wallets/layout/style.css" />
<link rel="stylesheet" href="/wallets/form/style.css" />
-2
View File
@@ -9,9 +9,7 @@ main.learn {
position: sticky;
top: 0;
max-height: 100dvh;
margin-left: -1rem;
padding-block: var(--nav-offset) var(--offset);
padding-left: 0.5rem;
overflow: auto;
overscroll-behavior: contain;
scroll-snap-type: y proximity;
+59
View File
@@ -0,0 +1,59 @@
/**
* @param {Object} args
* @param {string} args.label
* @param {string} args.color
* @param {string} [args.ariaLabel]
* @param {string | Node} [args.detail]
*/
export function createLegendItem(args) {
const button = document.createElement("button");
const label = document.createElement("span");
const value = document.createElement("output");
button.type = "button";
button.dataset.legendItem = "";
button.style.setProperty("--color", args.color);
button.setAttribute("aria-label", args.ariaLabel ?? args.label);
label.dataset.legendLabel = "";
value.dataset.legendValue = "";
label.append(args.label);
button.append(label, value);
if (args.detail != null) {
const detail = document.createElement("output");
detail.dataset.legendDetail = "";
detail.append(args.detail);
button.append(detail);
}
return { button, label, value };
}
/**
* @param {Object} [args]
* @param {boolean} [args.fill]
* @param {boolean} [args.scroll]
*/
export function createLegendList(args = {}) {
const list = document.createElement("menu");
list.dataset.legendList = "";
if (args.fill) list.dataset.legendFill = "";
if (args.scroll) list.dataset.legendScroll = "";
return list;
}
/**
* @param {HTMLElement} list
* @param {HTMLElement} item
*/
export function appendLegendListItem(list, item) {
const row = document.createElement("li");
row.append(item);
list.append(row);
return row;
}
+123
View File
@@ -0,0 +1,123 @@
[data-legend-list] {
display: flex;
padding: 0.25rem 0 0.5rem;
margin: 0;
overflow: visible;
list-style: none;
&[data-legend-scroll] {
overflow-x: auto;
}
&[data-legend-fill] {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8.5ch, 1fr));
padding: 0;
}
> li {
flex: 0 0 auto;
min-width: 0;
}
&[data-legend-fill] [data-legend-item] {
width: 100%;
min-width: 0;
> [data-legend-value] {
width: auto;
min-width: 0;
overflow-wrap: anywhere;
}
}
}
[data-legend-item] {
display: block;
min-width: 8.5ch;
padding: 0.25rem 0.375rem;
border: 0;
border-radius: 0.25rem;
color: inherit;
background: none;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 450;
line-height: var(--line-height-xs);
text-align: left;
text-transform: uppercase;
cursor: pointer;
@media (hover: hover) and (pointer: fine) {
&:hover {
color: var(--black);
background: var(--color);
[data-legend-label],
[data-legend-value],
[data-legend-detail] {
color: inherit;
}
}
}
&[data-press],
&:is([data-active], [data-preview]) {
color: var(--black);
background: var(--color);
[data-legend-label],
[data-legend-value],
[data-legend-detail] {
color: inherit;
}
}
&:focus-visible {
outline: 1px solid var(--orange);
outline-offset: 0.125rem;
}
&[data-muted] {
opacity: 0.35;
}
> [data-legend-label] {
display: block;
color: var(--color);
text-align: left;
&::before {
content: "";
display: inline-block;
width: 0.5em;
height: 0.5em;
margin-right: 0.35em;
margin-bottom: 0.1rem;
border-radius: 50%;
background: currentColor;
}
}
> [data-legend-value],
> [data-legend-detail] {
display: block;
margin-top: 0.25rem;
margin-left: auto;
color: var(--white);
font-variant-numeric: tabular-nums;
text-align: right;
}
> [data-legend-value] {
width: 7ch;
min-height: 1em;
}
> [data-legend-detail] {
color: var(--gray);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
}
}
+24
View File
@@ -0,0 +1,24 @@
/** @param {string} name */
export function getPoolSlug(name) {
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
}
/** @param {string} name */
export function getPoolDisplayName(name) {
return name.replace(/\s+(Pool|USA)$/i, "").trim();
}
/** @param {{ name: string, slug?: string }} pool */
export function createPoolLogo(pool) {
const logo = document.createElement("img");
const slug = pool.slug || getPoolSlug(pool.name);
logo.src = `/assets/pools/${slug}.svg`;
logo.alt = "";
logo.onerror = () => {
logo.onerror = null;
logo.src = "/assets/pools/default.svg";
};
return logo;
}
+140
View File
@@ -0,0 +1,140 @@
const FRACTION_DIGITS = 2;
/**
* @typedef {Object} UsdAmountOptions
* @property {boolean} [signed]
* @property {"positive" | "negative"} [tone]
* @property {"title"} [size]
*
* @typedef {Object} UsdPart
* @property {string} text
* @property {boolean} muted
*/
/**
* @param {UsdPart[]} parts
* @param {string} text
* @param {boolean} muted
*/
function pushPart(parts, text, muted) {
const last = parts[parts.length - 1];
if (last && last.muted === muted) {
last.text += text;
return;
}
parts.push({ text, muted });
}
/**
* @param {number} value
*/
function formatInteger(value) {
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
/**
* @param {number} dollars
*/
function splitUsd(dollars) {
const cents = Math.round(Math.abs(dollars) * 100);
return {
cents,
whole: Math.floor(cents / 100),
fraction: String(cents % 100).padStart(FRACTION_DIGITS, "0"),
};
}
/**
* @param {number} dollars
* @param {UsdAmountOptions} [options]
*/
export function getUsdParts(dollars, options = {}) {
const parts = /** @type {UsdPart[]} */ ([]);
const { cents, whole, fraction } = splitUsd(dollars);
const lastFractionDigit = Math.max(...[...fraction].map((digit, index) => {
return digit === "0" ? -1 : index;
}));
if (options.signed && dollars > 0 && cents > 0) pushPart(parts, "+", false);
if (dollars < 0 && cents > 0) pushPart(parts, "-", false);
pushPart(parts, "$", true);
pushPart(parts, formatInteger(whole), false);
if (lastFractionDigit === -1) {
pushPart(parts, ".", true);
pushPart(parts, fraction, true);
return parts;
}
pushPart(parts, ".", false);
for (let index = 0; index < fraction.length; index += 1) {
pushPart(parts, fraction[index], index > lastFractionDigit);
}
return parts;
}
/**
* @param {HTMLElement} element
* @param {UsdAmountOptions} options
*/
function syncUsdOptions(element, options) {
if (options.tone) {
element.dataset.usdTone = options.tone;
} else {
delete element.dataset.usdTone;
}
if (options.size) {
element.dataset.usdSize = options.size;
} else {
delete element.dataset.usdSize;
}
}
/**
* @param {HTMLElement} element
* @param {number} dollars
* @param {UsdAmountOptions} [options]
*/
export function renderUsdAmount(element, dollars, options = {}) {
element.dataset.usdAmount = "";
syncUsdOptions(element, options);
element.replaceChildren(...getUsdParts(dollars, options).map((part) => {
const span = document.createElement("span");
if (part.muted) span.dataset.usdMuted = "";
span.append(part.text);
return span;
}));
}
/**
* @template {keyof HTMLElementTagNameMap} Tag
* @param {Tag} tag
* @param {number} dollars
* @param {UsdAmountOptions} [options]
*/
export function createUsdAmount(tag, dollars, options = {}) {
const element = document.createElement(tag);
renderUsdAmount(element, dollars, options);
return element;
}
/**
* @param {number} dollars
*/
export function formatUsd(dollars) {
return new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 0,
style: "currency",
}).format(dollars);
}
+20
View File
@@ -0,0 +1,20 @@
[data-usd-amount] {
font-variant-numeric: tabular-nums;
&[data-usd-tone="positive"] {
color: var(--green);
}
&[data-usd-tone="negative"] {
color: var(--red);
}
&[data-usd-size="title"] {
font-size: var(--font-size-lg);
line-height: var(--line-height-lg);
}
[data-usd-muted] {
color: color-mix(in oklch, currentColor 45%, transparent);
}
}
+2 -129
View File
@@ -1,7 +1,6 @@
import { renderBtcAmount as renderVisibleBtcAmount } from "../../btc/index.js";
import { redaction } from "../redaction/index.js";
const SATS_PER_BTC = 100_000_000;
const FRACTION_DIGITS = 8;
const FIXED_PRIVATE_TEXT = "*****";
const amounts = /** @type {BtcAmountRecord[]} */ ([]);
@@ -18,123 +17,6 @@ const amounts = /** @type {BtcAmountRecord[]} */ ([]);
* @property {BtcAmount} amount
*/
/**
* @typedef {Object} BtcPart
* @property {string} text
* @property {boolean} muted
*/
/**
* @param {BtcPart[]} parts
* @param {string} text
* @param {boolean} muted
*/
function pushPart(parts, text, muted) {
const last = parts[parts.length - 1];
if (last && last.muted === muted) {
last.text += text;
return;
}
parts.push({ text, muted });
}
/**
* @param {number} value
*/
function formatInteger(value) {
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
}
/**
* @param {number} sats
*/
function splitBtc(sats) {
const absolute = Math.abs(sats);
return {
whole: Math.floor(absolute / SATS_PER_BTC),
fraction: String(absolute % SATS_PER_BTC).padStart(FRACTION_DIGITS, "0"),
};
}
/**
* @param {string} fraction
* @param {(index: number) => boolean} isMuted
* @param {(index: number) => boolean} isSpaceMuted
*/
function getFractionParts(fraction, isMuted, isSpaceMuted) {
const parts = /** @type {BtcPart[]} */ ([]);
for (let index = 0; index < fraction.length; index += 1) {
pushPart(parts, fraction[index], isMuted(index));
if (index === 1 || index === 4) {
pushPart(parts, " ", isSpaceMuted(index));
}
}
return parts;
}
/**
* @param {number} sats
* @param {BtcAmountOptions} [options]
*/
function getBtcParts(sats, options = {}) {
const parts = /** @type {BtcPart[]} */ ([]);
const { whole, fraction } = splitBtc(sats);
const firstFractionDigit = fraction.search(/[1-9]/);
const lastFractionDigit = Math.max(...[...fraction].map((digit, index) => {
return digit === "0" ? -1 : index;
}));
if (options.signed && sats > 0) pushPart(parts, "+", false);
if (sats < 0) pushPart(parts, "-", false);
pushPart(parts, "₿", true);
if (whole === 0) {
const mutedUntil = firstFractionDigit === -1
? FRACTION_DIGITS
: firstFractionDigit;
pushPart(parts, "0.", true);
for (const part of getFractionParts(
fraction,
(index) => index < mutedUntil,
(index) => index < mutedUntil,
)) {
pushPart(parts, part.text, part.muted);
}
return parts;
}
pushPart(parts, formatInteger(whole), false);
if (lastFractionDigit === -1) {
pushPart(parts, ".", true);
for (const part of getFractionParts(fraction, () => true, () => true)) {
pushPart(parts, part.text, part.muted);
}
return parts;
}
pushPart(parts, ".", false);
for (const part of getFractionParts(
fraction,
(index) => index > lastFractionDigit,
(index) => index >= lastFractionDigit,
)) {
pushPart(parts, part.text, part.muted);
}
return parts;
}
/**
* @param {HTMLElement} element
* @param {BtcAmount} amount
@@ -145,16 +27,7 @@ function renderBtcAmount(element, amount) {
return;
}
element.replaceChildren(...getBtcParts(amount.sats, amount).map((part) => {
const span = document.createElement("span");
if (part.muted) {
span.classList.add("muted");
}
span.append(part.text);
return span;
}));
renderVisibleBtcAmount(element, amount.sats, amount);
}
/**
-7
View File
@@ -1,7 +0,0 @@
main.wallets {
.amount {
.muted {
color: color-mix(in oklch, currentColor 45%, transparent);
}
}
}
+2 -11
View File
@@ -1,17 +1,8 @@
export { formatUsd } from "../usd/index.js";
/**
* @param {number} value
*/
export function formatNumber(value) {
return new Intl.NumberFormat("en-US").format(value);
}
/**
* @param {number} dollars
*/
export function formatUsd(dollars) {
return new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 0,
style: "currency",
}).format(dollars);
}