mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-04 07:43:41 -07:00
website_next: snapshot
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.amount {
|
||||
.muted {
|
||||
color: color-mix(in oklch, currentColor 45%, transparent);
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ function createAreaPoints(frame, points) {
|
||||
|
||||
return points.map((point) => ({
|
||||
...point,
|
||||
y0: bottom,
|
||||
y1: point.y,
|
||||
plotY0: bottom,
|
||||
plotY1: point.plotY,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
)
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+17
-16
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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]
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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} */ ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
main.wallets {
|
||||
.amount {
|
||||
.muted {
|
||||
color: color-mix(in oklch, currentColor 45%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user