From b0b261fe9f8868e8321f7d885e057475f288a175 Mon Sep 17 00:00:00 2001 From: nym21 Date: Mon, 8 Jun 2026 16:37:53 +0200 Subject: [PATCH] website: redesign part 24 --- website_next/learn/charts/format.js | 12 +- website_next/learn/charts/scrubber/index.js | 121 ++++++++++++------ website_next/learn/charts/scrubber/style.css | 5 + website_next/learn/charts/stacked/series.js | 9 +- website_next/learn/charts/views.js | 19 +-- website_next/learn/sections/capitalization.js | 3 +- website_next/learn/sections/supply.js | 3 +- 7 files changed, 111 insertions(+), 61 deletions(-) diff --git a/website_next/learn/charts/format.js b/website_next/learn/charts/format.js index b3d9f6380..34dff581b 100644 --- a/website_next/learn/charts/format.js +++ b/website_next/learn/charts/format.js @@ -1,14 +1,18 @@ const suffixes = ["M", "B", "T", "P", "E", "Z", "Y"]; +const numberFormats = [0, 1, 2, 3].map( + (digits) => + new Intl.NumberFormat("en-US", { + maximumFractionDigits: digits, + minimumFractionDigits: digits, + }), +); /** * @param {number} value * @param {number} digits */ function formatNumber(value, digits) { - return value.toLocaleString("en-US", { - maximumFractionDigits: digits, - minimumFractionDigits: digits, - }); + return numberFormats[digits].format(value); } /** @param {number} value */ diff --git a/website_next/learn/charts/scrubber/index.js b/website_next/learn/charts/scrubber/index.js index 852accb18..1a59e4482 100644 --- a/website_next/learn/charts/scrubber/index.js +++ b/website_next/learn/charts/scrubber/index.js @@ -14,19 +14,37 @@ const dateFormat = new Intl.DateTimeFormat("en-US", { const markerRadiusPx = 4; -/** @param {SVGSVGElement} svg */ -function getMarkerRadiusInViewBox(svg) { - const width = svg.getBoundingClientRect().width; - +/** @param {number} width */ +function getMarkerRadiusInViewBox(width) { return width ? (markerRadiusPx * VIEWBOX_WIDTH) / width : markerRadiusPx; } /** * @param {ScrubberSeries} series - * @param {number} ratio + * @param {number} step */ -function getPointAtRatio(series, ratio) { - return series.points[Math.round(ratio * (series.points.length - 1))]; +function getPointAtStep(series, step) { + return series.points[step]; +} + +/** + * @param {ReturnType[]} points + * @param {number} y + */ +function getClosestPointIndex(points, y) { + let closestIndex = 0; + let closestDistance = Infinity; + + for (const [index, point] of points.entries()) { + const distance = Math.abs(point.y - y); + + if (distance < closestDistance) { + closestIndex = index; + closestDistance = distance; + } + } + + return closestIndex; } /** @@ -40,7 +58,7 @@ function updateTime(time, date) { /** * @param {Readout} readout - * @param {ReturnType[]} points + * @param {ReturnType[]} points */ function updateReadout(readout, points) { updateTime(readout.time, points[0].date); @@ -57,6 +75,7 @@ function updateReadout(readout, points) { */ export function createScrubber(svg, readout, highlight) { const group = createSvgElement("g"); + const shade = createSvgElement("rect"); const guide = createSvgElement("line"); /** @type {ScrubberSeries[]} */ let series = []; @@ -64,53 +83,83 @@ export function createScrubber(svg, readout, highlight) { let markers = []; let height = 0; let stepCount = 0; + let currentStep = -1; + let currentPoints = getPointsAtStep(0); + let rect = svg.getBoundingClientRect(); group.dataset.scrubber = "root"; + shade.dataset.scrubber = "shade"; guide.dataset.scrubber = "guide"; - group.append(guide); + group.append(shade, guide); svg.append(group); + function measure() { + rect = svg.getBoundingClientRect(); + } + + /** @param {number} step */ + function getPointsAtStep(step) { + return series.map((item) => getPointAtStep(item, step)); + } + /** * @param {number} ratio + * @param {number} [y] * @param {boolean} [scrubbing] */ - function update(ratio, scrubbing = true) { + function update(ratio, y, scrubbing = true) { if (!series.length) return; - const nextRatio = clamp(ratio, 0, 1); - const points = series.map((item) => getPointAtRatio(item, nextRatio)); - const x = points[0].x.toFixed(2); + const nextStep = Math.round(clamp(ratio, 0, 1) * stepCount); - svg.dataset.index = Math.round(nextRatio * stepCount).toString(); - guide.setAttribute("x1", x); - guide.setAttribute("x2", x); - guide.setAttribute("y1", "0"); - guide.setAttribute("y2", height.toString()); - updateReadout(readout, points); + if (nextStep !== currentStep) { + currentStep = nextStep; + currentPoints = getPointsAtStep(nextStep); - markers.forEach((marker, index) => { - const point = points[index]; + const x = currentPoints[0].x; + const xText = x.toFixed(2); - marker.setAttribute("cx", point.x.toFixed(2)); - marker.setAttribute("cy", point.y.toFixed(2)); - }); + svg.dataset.index = nextStep.toString(); + shade.setAttribute("x", xText); + shade.setAttribute("y", "0"); + shade.setAttribute("width", (VIEWBOX_WIDTH - x).toFixed(2)); + shade.setAttribute("height", height.toString()); + guide.setAttribute("x1", xText); + guide.setAttribute("x2", xText); + guide.setAttribute("y1", "0"); + guide.setAttribute("y2", height.toString()); + updateReadout(readout, currentPoints); + + markers.forEach((marker, index) => { + const point = currentPoints[index]; + + marker.setAttribute("cx", point.x.toFixed(2)); + marker.setAttribute("cy", point.y.toFixed(2)); + }); + } if (scrubbing) { svg.dataset.scrubbing = "true"; } else { delete svg.dataset.scrubbing; } + + if (y !== undefined) { + highlight.preview(getClosestPointIndex(currentPoints, y)); + } } function hide() { - update(1, false); + update(1, undefined, false); } function clear() { series = []; markers = []; + currentStep = -1; + currentPoints = []; highlight.clearPreview(); - group.replaceChildren(guide); + group.replaceChildren(shade, guide); delete svg.dataset.index; delete svg.dataset.scrubbing; } @@ -122,8 +171,10 @@ export function createScrubber(svg, readout, highlight) { function setSeries(nextSeries, nextHeight) { series = nextSeries; height = nextHeight; + currentStep = -1; stepCount = Math.max(...series.map(({ points }) => points.length - 1)); - const radius = getMarkerRadiusInViewBox(svg); + measure(); + const radius = getMarkerRadiusInViewBox(rect.width); markers = series.map(({ color }, index) => { const marker = createSvgElement("circle"); @@ -136,23 +187,19 @@ export function createScrubber(svg, readout, highlight) { return marker; }); - group.replaceChildren(guide, ...markers); - update(1, false); + group.replaceChildren(shade, guide, ...markers); + update(1, undefined, false); } /** @param {PointerEvent} event */ function updateFromPointer(event) { - const { left, width } = svg.getBoundingClientRect(); - const x = ((event.clientX - left) / width) * VIEWBOX_WIDTH; - const index = Number( - /** @type {SVGElement} */ (event.target).dataset.series, - ); + const x = ((event.clientX - rect.left) / rect.width) * VIEWBOX_WIDTH; + const y = ((event.clientY - rect.top) / rect.height) * height; - if (Number.isInteger(index)) highlight.preview(index); - else highlight.clearPreview(); - update(x / VIEWBOX_WIDTH); + update(x / VIEWBOX_WIDTH, y); } + svg.addEventListener("pointerenter", measure); svg.addEventListener("pointermove", updateFromPointer); svg.addEventListener("pointerleave", () => { highlight.clearPreview(); diff --git a/website_next/learn/charts/scrubber/style.css b/website_next/learn/charts/scrubber/style.css index 62cb6bdbb..21c580801 100644 --- a/website_next/learn/charts/scrubber/style.css +++ b/website_next/learn/charts/scrubber/style.css @@ -15,6 +15,11 @@ main.learn { vector-effect: non-scaling-stroke; } + [data-scrubber="shade"] { + fill: var(--black); + fill-opacity: 0.5; + } + [data-scrubber="marker"] { fill: var(--black); stroke: var(--color, var(--orange)); diff --git a/website_next/learn/charts/stacked/series.js b/website_next/learn/charts/stacked/series.js index 331c4c0f0..d153b658a 100644 --- a/website_next/learn/charts/stacked/series.js +++ b/website_next/learn/charts/stacked/series.js @@ -82,13 +82,16 @@ export function createStackedSeries(loadedSeries, height, order, scale) { if (value < 0) negative = end; else positive = end; + const y0 = scaleY(start, bounds, height, scale); + const y1 = scaleY(end, bounds, height, scale); + plottedSeries[seriesIndex].points.push({ date, value, x, - y: scaleY(end, bounds, height, scale), - y0: scaleY(start, bounds, height, scale), - y1: scaleY(end, bounds, height, scale), + y: y1, + y0, + y1, }); } diff --git a/website_next/learn/charts/views.js b/website_next/learn/charts/views.js index 82e034a66..e481d29e9 100644 --- a/website_next/learn/charts/views.js +++ b/website_next/learn/charts/views.js @@ -1,20 +1,13 @@ import { createChartSetting } from "./setting.js"; -export const viewTypes = /** @type {const} */ ({ - line: "line", - area: "area", - stacked: "stacked", - bar: "bar", - dots: "dots", -}); const views = /** @type {const} */ ([ - { value: viewTypes.line, label: "Line" }, - { value: viewTypes.area, label: "Area" }, - { value: viewTypes.stacked, label: "Stack" }, - { value: viewTypes.bar, label: "Bars" }, - { value: viewTypes.dots, label: "Dots" }, + { value: "line", label: "Line" }, + { value: "area", label: "Area" }, + { value: "stacked", label: "Stack" }, + { value: "bar", label: "Bars" }, + { value: "dots", label: "Dots" }, ]); -const defaultView = viewTypes.stacked; +const defaultView = "stacked"; const setting = createChartSetting({ storageKey: "view", legend: "View", diff --git a/website_next/learn/sections/capitalization.js b/website_next/learn/sections/capitalization.js index 5a5801c9d..1e4285ddc 100644 --- a/website_next/learn/sections/capitalization.js +++ b/website_next/learn/sections/capitalization.js @@ -1,6 +1,5 @@ import { capitalizationSeries } from "../capitalization.js"; import { units } from "../charts/units.js"; -import { viewTypes } from "../charts/views.js"; import { marketCapSection } from "./capitalization/market.js"; import { realizedCapSection } from "./capitalization/realized.js"; @@ -11,7 +10,7 @@ export const capitalizationSection = { chart: { title: "Capitalization", unit: units.usd, - defaultType: viewTypes.line, + defaultType: /** @type {const} */ ("line"), series: capitalizationSeries, }, children: [ diff --git a/website_next/learn/sections/supply.js b/website_next/learn/sections/supply.js index fa970afd7..235a381ea 100644 --- a/website_next/learn/sections/supply.js +++ b/website_next/learn/sections/supply.js @@ -14,7 +14,6 @@ import { supplyProfitabilitySeries, } from "../supply.js"; import { units } from "../charts/units.js"; -import { viewTypes } from "../charts/views.js"; export const supplySection = { title: "Supply", @@ -43,7 +42,7 @@ export const supplySection = { chart: { title: "Exposed supply", unit: units.btc, - defaultType: viewTypes.line, + defaultType: /** @type {const} */ ("line"), series: exposedSupplySeries, }, children: [