diff --git a/website_next/btc/index.js b/website_next/btc/index.js new file mode 100644 index 000000000..ae447f472 --- /dev/null +++ b/website_next/btc/index.js @@ -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; +} diff --git a/website_next/btc/style.css b/website_next/btc/style.css new file mode 100644 index 000000000..321bf2d77 --- /dev/null +++ b/website_next/btc/style.css @@ -0,0 +1,5 @@ +.amount { + .muted { + color: color-mix(in oklch, currentColor 45%, transparent); + } +} diff --git a/website_next/chart/area/index.js b/website_next/chart/area/index.js index 43807e9f8..d44e9b6bc 100644 --- a/website_next/chart/area/index.js +++ b/website_next/chart/area/index.js @@ -14,8 +14,8 @@ function createAreaPoints(frame, points) { return points.map((point) => ({ ...point, - y0: bottom, - y1: point.y, + plotY0: bottom, + plotY1: point.plotY, })); } diff --git a/website_next/chart/bar/index.js b/website_next/chart/bar/index.js index daa158ea6..178e28cb1 100644 --- a/website_next/chart/bar/index.js +++ b/website_next/chart/bar/index.js @@ -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)), }); } diff --git a/website_next/chart/dots/index.js b/website_next/chart/dots/index.js index ae1ecfcb0..1d841740b 100644 --- a/website_next/chart/dots/index.js +++ b/website_next/chart/dots/index.js @@ -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`, ) diff --git a/website_next/chart/interpolate.js b/website_next/chart/interpolate.js new file mode 100644 index 000000000..abf0c6f2e --- /dev/null +++ b/website_next/chart/interpolate.js @@ -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]); +} diff --git a/website_next/chart/legend/index.js b/website_next/chart/legend/index.js index 65aa408f5..483b4bfe5 100644 --- a/website_next/chart/legend/index.js +++ b/website_next/chart/legend/index.js @@ -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 }; }); diff --git a/website_next/chart/legend/style.css b/website_next/chart/legend/style.css index fdfa56a82..f1264c836 100644 --- a/website_next/chart/legend/style.css +++ b/website_next/chart/legend/style.css @@ -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; - } } } diff --git a/website_next/chart/line/series.js b/website_next/chart/line/series.js index 5c2ee20b1..31e8a2245 100644 --- a/website_next/chart/line/series.js +++ b/website_next/chart/line/series.js @@ -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, + ) ), }; }); diff --git a/website_next/chart/loader.js b/website_next/chart/loader.js index d231d11ab..3a79f386d 100644 --- a/website_next/chart/loader.js +++ b/website_next/chart/loader.js @@ -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)), })), ); } diff --git a/website_next/chart/path.js b/website_next/chart/path.js index 205a7af91..71c1c1f16 100644 --- a/website_next/chart/path.js +++ b/website_next/chart/path.js @@ -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`; diff --git a/website_next/chart/points.js b/website_next/chart/points.js new file mode 100644 index 000000000..9e9940731 --- /dev/null +++ b/website_next/chart/points.js @@ -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), + ); +} diff --git a/website_next/chart/scrubber/index.js b/website_next/chart/scrubber/index.js index 94090fed9..0397a332b 100644 --- a/website_next/chart/scrubber/index.js +++ b/website_next/chart/scrubber/index.js @@ -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); }); } diff --git a/website_next/chart/stacked/series.js b/website_next/chart/stacked/series.js index a777288d5..01dcaa3d9 100644 --- a/website_next/chart/stacked/series.js +++ b/website_next/chart/stacked/series.js @@ -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, }); } } diff --git a/website_next/chart/types.d.ts b/website_next/chart/types.d.ts index e58071525..233097c80 100644 --- a/website_next/chart/types.d.ts +++ b/website_next/chart/types.d.ts @@ -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 & { 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; diff --git a/website_next/chart/viewbox.js b/website_next/chart/viewbox.js index 79950e07c..b074ff7b6 100644 --- a/website_next/chart/viewbox.js +++ b/website_next/chart/viewbox.js @@ -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 diff --git a/website_next/chart/x.js b/website_next/chart/x.js new file mode 100644 index 000000000..53e8e6ba6 --- /dev/null +++ b/website_next/chart/x.js @@ -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, + ); + }; +} diff --git a/website_next/chart/xy/index.js b/website_next/chart/xy/index.js index 007a2c03b..4520161d1 100644 --- a/website_next/chart/xy/index.js +++ b/website_next/chart/xy/index.js @@ -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] - */ diff --git a/website_next/chart/y.js b/website_next/chart/y.js new file mode 100644 index 000000000..f9857455f --- /dev/null +++ b/website_next/chart/y.js @@ -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, + ); +} diff --git a/website_next/explore/block/fee-chart.js b/website_next/explore/block/fee-chart.js index dd0103705..1d73f55ca 100644 --- a/website_next/explore/block/fee-chart.js +++ b/website_next/explore/block/fee-chart.js @@ -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 */ diff --git a/website_next/explore/block/index.js b/website_next/explore/block/index.js index e07499db2..9ee612ff6 100644 --- a/website_next/explore/block/index.js +++ b/website_next/explore/block/index.js @@ -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>[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} */ ({ diff --git a/website_next/explore/block/style.css b/website_next/explore/block/style.css index a0394be10..d22438ffa 100644 --- a/website_next/explore/block/style.css +++ b/website_next/explore/block/style.css @@ -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; diff --git a/website_next/explore/chain/index.js b/website_next/explore/chain/index.js index d673d47bf..e261ed726 100644 --- a/website_next/explore/chain/index.js +++ b/website_next/explore/chain/index.js @@ -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); diff --git a/website_next/index.html b/website_next/index.html index 0f42235d5..447463127 100644 --- a/website_next/index.html +++ b/website_next/index.html @@ -98,6 +98,9 @@ + + + @@ -122,7 +125,6 @@ - diff --git a/website_next/learn/contents/style.css b/website_next/learn/contents/style.css index fdd0c926a..f8d1715f6 100644 --- a/website_next/learn/contents/style.css +++ b/website_next/learn/contents/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; diff --git a/website_next/legend/index.js b/website_next/legend/index.js new file mode 100644 index 000000000..cde2036ec --- /dev/null +++ b/website_next/legend/index.js @@ -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; +} diff --git a/website_next/legend/style.css b/website_next/legend/style.css new file mode 100644 index 000000000..42703fec1 --- /dev/null +++ b/website_next/legend/style.css @@ -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); + } + +} diff --git a/website_next/pools/index.js b/website_next/pools/index.js new file mode 100644 index 000000000..33129aaa9 --- /dev/null +++ b/website_next/pools/index.js @@ -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; +} diff --git a/website_next/usd/index.js b/website_next/usd/index.js new file mode 100644 index 000000000..d2f9d1d58 --- /dev/null +++ b/website_next/usd/index.js @@ -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); +} diff --git a/website_next/usd/style.css b/website_next/usd/style.css new file mode 100644 index 000000000..50f089448 --- /dev/null +++ b/website_next/usd/style.css @@ -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); + } +} diff --git a/website_next/wallets/amount/index.js b/website_next/wallets/amount/index.js index bab0ac03d..5f644ef41 100644 --- a/website_next/wallets/amount/index.js +++ b/website_next/wallets/amount/index.js @@ -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); } /** diff --git a/website_next/wallets/amount/style.css b/website_next/wallets/amount/style.css deleted file mode 100644 index c0354f118..000000000 --- a/website_next/wallets/amount/style.css +++ /dev/null @@ -1,7 +0,0 @@ -main.wallets { - .amount { - .muted { - color: color-mix(in oklch, currentColor 45%, transparent); - } - } -} diff --git a/website_next/wallets/format.js b/website_next/wallets/format.js index 2f062797e..988b494e9 100644 --- a/website_next/wallets/format.js +++ b/website_next/wallets/format.js @@ -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); -}