From c68d1d1fda45bb2caeed8a62735391ec2b548d37 Mon Sep 17 00:00:00 2001 From: nym21 Date: Sun, 7 Jun 2026 00:54:50 +0200 Subject: [PATCH] website: redesign part 13 --- modules/brk-client/index.js | 2 +- modules/brk-client/package.json | 2 +- packages/brk_client/brk_client/__init__.py | 2 +- packages/brk_client/pyproject.toml | 2 +- website_next/header/index.js | 18 +-- website_next/header/style.css | 49 +----- website_next/home/index.js | 22 ++- website_next/home/style.css | 36 ++++- website_next/learn/capitalization.js | 133 +++++++++++++++ website_next/learn/charts/bar/index.js | 11 +- website_next/learn/charts/dots/index.js | 5 +- website_next/learn/charts/highlight.js | 14 +- website_next/learn/charts/index.js | 87 ++++++++-- website_next/learn/charts/intersection.js | 22 ++- website_next/learn/charts/line/index.js | 5 +- website_next/learn/charts/line/series.js | 22 +-- website_next/learn/charts/scale.js | 74 +++++++++ website_next/learn/charts/scrubber.js | 10 +- website_next/learn/charts/stacked/index.js | 3 + website_next/learn/charts/stacked/series.js | 28 ++-- website_next/learn/charts/style.css | 1 + website_next/learn/cohort-series.js | 56 +++++++ website_next/learn/cohorts.js | 157 ++---------------- website_next/learn/contents/index.js | 16 +- website_next/learn/contents/style.css | 23 ++- website_next/learn/data.js | 169 ++++++++++++++++++-- website_next/learn/groups.js | 99 ++++++++++++ website_next/learn/index.js | 14 +- website_next/learn/path.js | 6 + website_next/learn/scroll-spy.js | 20 ++- website_next/learn/style.css | 59 +++++-- website_next/main.js | 19 --- website_next/routes.js | 10 +- 33 files changed, 855 insertions(+), 341 deletions(-) create mode 100644 website_next/learn/capitalization.js create mode 100644 website_next/learn/charts/scale.js create mode 100644 website_next/learn/cohort-series.js create mode 100644 website_next/learn/groups.js create mode 100644 website_next/learn/path.js diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index c464ead39..557cc118c 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -7579,7 +7579,7 @@ function createTransferPattern(client, acc) { * @extends BrkClientBase */ class BrkClient extends BrkClientBase { - VERSION = "v0.3.2"; + VERSION = "v0.3.3"; INDEXES = /** @type {const} */ ([ "minute10", diff --git a/modules/brk-client/package.json b/modules/brk-client/package.json index edce510e2..e3f2ee8b3 100644 --- a/modules/brk-client/package.json +++ b/modules/brk-client/package.json @@ -40,5 +40,5 @@ "url": "git+https://github.com/bitcoinresearchkit/brk.git" }, "type": "module", - "version": "0.3.2" + "version": "0.3.3" } diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index eb13a2c3b..1e695474c 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -6724,7 +6724,7 @@ class SeriesTree: class BrkClient(BrkClientBase): """Main BRK client with series tree and API methods.""" - VERSION = "v0.3.2" + VERSION = "v0.3.3" INDEXES = [ "minute10", diff --git a/packages/brk_client/pyproject.toml b/packages/brk_client/pyproject.toml index 02c0c1baa..1a7318734 100644 --- a/packages/brk_client/pyproject.toml +++ b/packages/brk_client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brk-client" -version = "0.3.2" +version = "0.3.3" description = "Bitcoin on-chain analytics client — thousands of metrics, block explorer, and address index" readme = "README.md" requires-python = ">=3.9" diff --git a/website_next/header/index.js b/website_next/header/index.js index 28b943852..dbaffcc9f 100644 --- a/website_next/header/index.js +++ b/website_next/header/index.js @@ -1,5 +1,4 @@ import { createCube } from "../cube/index.js"; -import { primaryRoutes } from "../routes.js"; export function createHeader() { const header = document.createElement("header"); @@ -12,21 +11,6 @@ export function createHeader() { cube.append(createCube()); home.append(cube, "bitview"); - const nav = document.createElement("nav"); - const list = document.createElement("ul"); - nav.setAttribute("aria-label", "Primary"); - - for (const { pathname, label } of primaryRoutes) { - const item = document.createElement("li"); - const anchor = document.createElement("a"); - - anchor.href = pathname; - anchor.append(label); - item.append(anchor); - list.append(item); - } - - nav.append(list); - header.append(home, nav); + header.append(home); return header; } diff --git a/website_next/header/style.css b/website_next/header/style.css index 4ccf7a9f1..070af9842 100644 --- a/website_next/header/style.css +++ b/website_next/header/style.css @@ -1,20 +1,16 @@ body { > header { position: fixed; - inset: 1.5rem var(--page-x) auto; + top: 1.5rem; + left: var(--page-x); z-index: var(--layer-header); - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - font-size: var(--font-size-sm); line-height: 1; - text-transform: uppercase; + mix-blend-mode: difference; > a { --color: var(--white); opacity: 0.8; - justify-self: start; display: flex; align-items: center; gap: 0.5rem; @@ -45,44 +41,5 @@ body { animation: cube-fill 5s linear infinite alternate; } } - - > nav { - font-size: var(--font-size-xs); - - ul { - display: flex; - gap: 0.5rem; - margin: 0; - padding: 0; - list-style: none; - } - - a { - display: block; - padding: 0.75rem 1rem; - border-radius: 0.3125rem; - text-decoration: none; - - color: var(--white); - background: var(--dark-gray); - - &:hover { - background: var(--gray); - } - - &:active { - background: var(--orange); - } - - &[aria-current="page"] { - color: var(--black); - background: var(--dark-white); - - &:hover { - background: var(--white); - } - } - } - } } } diff --git a/website_next/home/index.js b/website_next/home/index.js index bda72e274..3d750c647 100644 --- a/website_next/home/index.js +++ b/website_next/home/index.js @@ -1,8 +1,26 @@ +const links = [ + { href: "/explore", label: "Explore" }, + { href: "/learn", label: "Learn" }, + { href: "/build", label: "Build" }, +]; + export function createHomePage() { const main = document.createElement("main"); main.className = "home"; + const title = document.createElement("h1"); - title.append("Home"); - main.append(title); + const nav = document.createElement("nav"); + + nav.setAttribute("aria-label", "Sections"); + title.append("bitview"); + + for (const { href, label } of links) { + const link = document.createElement("a"); + link.href = href; + link.append(label); + nav.append(link); + } + + main.append(title, nav); return main; } diff --git a/website_next/home/style.css b/website_next/home/style.css index 54bde0219..18837ec11 100644 --- a/website_next/home/style.css +++ b/website_next/home/style.css @@ -1,5 +1,39 @@ main.home { display: grid; + gap: 2rem; place-items: center; - font-size: 4rem; + align-content: center; + padding: var(--offset, 6rem) var(--page-x); + + h1 { + margin: 0; + font-size: 4rem; + line-height: 1; + } + + nav { + display: flex; + gap: 0.5rem; + font-size: var(--font-size-xs); + line-height: 1; + text-transform: uppercase; + + a { + display: block; + padding: 0.75rem 1rem; + border-radius: 0.3125rem; + color: var(--white); + background: var(--dark-gray); + text-decoration: none; + + &:hover { + background: var(--gray); + } + + &:active { + color: var(--black); + background: var(--orange); + } + } + } } diff --git a/website_next/learn/capitalization.js b/website_next/learn/capitalization.js new file mode 100644 index 000000000..7843afbc8 --- /dev/null +++ b/website_next/learn/capitalization.js @@ -0,0 +1,133 @@ +import { + createCohortSeries, + createCohortSeriesFromKeys, +} from "./cohort-series.js"; +import { + ageRanges, + amountRanges, + classes, + epochs, + spendableTypes, +} from "./groups.js"; +import { colors } from "../utils/colors.js"; + +export const capitalizationSeries = createCohortSeries([ + { + label: "Market cap", + color: colors.green, + metric: (client) => client.series.supply.marketCap.usd, + }, + { + label: "Realized cap", + color: colors.orange, + metric: (client) => client.series.cohorts.utxo.all.realized.cap.usd, + }, +]); + +export const marketCapSeries = createCohortSeries([ + { + label: "Market cap", + color: colors.green, + metric: (client) => client.series.supply.marketCap.usd, + }, +]); + +export const realizedCapSeries = createCohortSeries([ + { + label: "Realized cap", + color: colors.orange, + metric: (client) => client.series.cohorts.utxo.all.realized.cap.usd, + }, +]); + +export const marketCapTermSeries = createCohortSeries([ + { + label: "STH", + color: colors.sky, + metric: (client) => client.series.cohorts.utxo.sth.supply.total.usd, + }, + { + label: "LTH", + color: colors.orange, + metric: (client) => client.series.cohorts.utxo.lth.supply.total.usd, + }, +]); + +export const realizedCapTermSeries = createCohortSeries([ + { + label: "STH", + color: colors.sky, + metric: (client) => client.series.cohorts.utxo.sth.realized.cap.usd, + }, + { + label: "LTH", + color: colors.orange, + metric: (client) => client.series.cohorts.utxo.lth.realized.cap.usd, + }, +]); + +export const marketCapAgeSeries = createCohortSeriesFromKeys( + ageRanges, + (key) => (client) => + client.series.cohorts.utxo.ageRange[key].supply.total.usd, +); + +export const realizedCapAgeSeries = createCohortSeriesFromKeys( + ageRanges, + (key) => (client) => + client.series.cohorts.utxo.ageRange[key].realized.cap.usd, +); + +export const marketCapUtxoBalanceSeries = createCohortSeriesFromKeys( + amountRanges, + (key) => (client) => + client.series.cohorts.utxo.amountRange[key].supply.total.usd, +); + +export const realizedCapUtxoBalanceSeries = createCohortSeriesFromKeys( + amountRanges, + (key) => (client) => + client.series.cohorts.utxo.amountRange[key].realized.cap.usd, +); + +export const marketCapAddressBalanceSeries = createCohortSeriesFromKeys( + amountRanges, + (key) => (client) => + client.series.cohorts.addr.amountRange[key].supply.total.usd, +); + +export const realizedCapAddressBalanceSeries = createCohortSeriesFromKeys( + amountRanges, + (key) => (client) => + client.series.cohorts.addr.amountRange[key].realized.cap.usd, +); + +export const marketCapTypeSeries = createCohortSeriesFromKeys( + spendableTypes, + (key) => (client) => client.series.cohorts.utxo.type[key].supply.total.usd, +); + +export const realizedCapTypeSeries = createCohortSeriesFromKeys( + spendableTypes, + (key) => (client) => client.series.cohorts.utxo.type[key].realized.cap.usd, +); + +export const marketCapEpochSeries = createCohortSeriesFromKeys( + epochs, + (key) => (client) => client.series.cohorts.utxo.epoch[key].supply.total.usd, +); + +export const realizedCapEpochSeries = createCohortSeriesFromKeys( + epochs, + (key) => (client) => client.series.cohorts.utxo.epoch[key].realized.cap.usd, +); + +export const marketCapClassSeries = createCohortSeriesFromKeys( + classes, + (key) => (client) => client.series.cohorts.utxo.class[key].supply.total.usd, +); + +export const realizedCapClassSeries = createCohortSeriesFromKeys( + classes, + (key) => (client) => client.series.cohorts.utxo.class[key].realized.cap.usd, +); diff --git a/website_next/learn/charts/bar/index.js b/website_next/learn/charts/bar/index.js index a03a2c4d5..d234c86b5 100644 --- a/website_next/learn/charts/bar/index.js +++ b/website_next/learn/charts/bar/index.js @@ -44,12 +44,21 @@ function createBarPathData(points, width) { * @param {number} height * @param {SeriesHighlight} highlight * @param {{ reversed: boolean }} options + * @param {import("../scale.js").ChartScale} scale */ -export function renderBarPlot(group, loadedSeries, height, highlight, options) { +export function renderBarPlot( + group, + loadedSeries, + height, + highlight, + options, + scale, +) { const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries( loadedSeries, height, options.reversed, + scale, ); for (const index of stackIndexes) { diff --git a/website_next/learn/charts/dots/index.js b/website_next/learn/charts/dots/index.js index c2a4a68a7..2304ce97c 100644 --- a/website_next/learn/charts/dots/index.js +++ b/website_next/learn/charts/dots/index.js @@ -21,9 +21,10 @@ function createDotsPathData(points) { * @param {LoadedSeries[]} loadedSeries * @param {number} height * @param {SeriesHighlight} highlight + * @param {import("../scale.js").ChartScale} scale */ -export function renderDotsPlot(group, loadedSeries, height, highlight) { - const plottedSeries = createLineSeries(loadedSeries, height); +export function renderDotsPlot(group, loadedSeries, height, highlight, scale) { + const plottedSeries = createLineSeries(loadedSeries, height, scale); plottedSeries.forEach(({ color, points }, index) => { const path = createSvgElement("path"); diff --git a/website_next/learn/charts/highlight.js b/website_next/learn/charts/highlight.js index fb2383a8a..7c274e4a8 100644 --- a/website_next/learn/charts/highlight.js +++ b/website_next/learn/charts/highlight.js @@ -39,6 +39,16 @@ export function createSeriesHighlight(items) { } } + /** @param {number} index */ + function activateItem(index) { + setActive(items[index], true); + } + + /** @param {number} index */ + function clearItem(index) { + clearState(items[index]); + } + items.forEach((item, index) => { item.addEventListener("pointerenter", () => activate(index)); item.addEventListener("pointerleave", clear); @@ -54,9 +64,9 @@ export function createSeriesHighlight(items) { seriesNodes[index].push(node); node.addEventListener("pointerenter", () => { scrollToItem(index); - activate(index); + activateItem(index); }); - node.addEventListener("pointerleave", clear); + node.addEventListener("pointerleave", () => clearItem(index)); } function clearNodes() { diff --git a/website_next/learn/charts/index.js b/website_next/learn/charts/index.js index 8d897c53f..2bf86bb69 100644 --- a/website_next/learn/charts/index.js +++ b/website_next/learn/charts/index.js @@ -2,9 +2,14 @@ import { brk } from "../../utils/client.js"; import { renderBarPlot } from "./bar/index.js"; import { createFullscreenButton } from "./fullscreen.js"; import { createSeriesHighlight } from "./highlight.js"; -import { onFirstIntersection } from "./intersection.js"; +import { onChartVisibility } from "./intersection.js"; import { createLegend } from "./legend.js"; import { renderLinePlot } from "./line/index.js"; +import { + createScaleControl, + getDefaultScale, + saveScale, +} from "./scale.js"; import { createScrubber } from "./scrubber.js"; import { renderDotsPlot } from "./dots/index.js"; import { createSvgElement } from "./svg.js"; @@ -27,6 +32,7 @@ import { } from "./viewbox.js"; /** @typedef {import("./legend.js").Readout} Readout */ +/** @typedef {import("./scale.js").ChartScale} ChartScale */ /** @typedef {import("./timeframes.js").TimeframeValue} TimeframeValue */ /** @typedef {import("./views.js").ChartView} ChartView */ @@ -90,22 +96,33 @@ function createLoadedSeriesCache(chart) { * @param {LoadedSeries[]} loadedSeries * @param {number} height * @param {SeriesHighlight} highlight + * @param {ChartScale} scale */ -function renderPlot(view, group, loadedSeries, height, highlight) { +function renderPlot(view, group, loadedSeries, height, highlight, scale) { switch (view) { case "line": - return renderLinePlot(group, loadedSeries, height, highlight); + return renderLinePlot(group, loadedSeries, height, highlight, scale); case "bar": case "bar-reversed": - return renderBarPlot(group, loadedSeries, height, highlight, { - reversed: view === "bar-reversed", - }); + return renderBarPlot( + group, + loadedSeries, + height, + highlight, + { reversed: view === "bar-reversed" }, + scale, + ); case "dots": - return renderDotsPlot(group, loadedSeries, height, highlight); + return renderDotsPlot(group, loadedSeries, height, highlight, scale); default: - return renderStackedPlot(group, loadedSeries, height, highlight, { - reversed: view === "stacked-reversed", - }); + return renderStackedPlot( + group, + loadedSeries, + height, + highlight, + { reversed: view === "stacked-reversed" }, + scale, + ); } } @@ -116,6 +133,7 @@ function renderPlot(view, group, loadedSeries, height, highlight) { * @param {HTMLElement} status * @param {Chart} chart * @param {() => ChartView} getView + * @param {() => ChartScale} getScale * @param {() => TimeframeValue} getTimeframe */ function createChartRenderer( @@ -125,6 +143,7 @@ function createChartRenderer( status, chart, getView, + getScale, getTimeframe, ) { const group = createSvgElement("g"); @@ -134,12 +153,14 @@ function createChartRenderer( let loadedSeries = []; /** @type {ReturnType | undefined} */ let scrubber; + const resizeObserver = new ResizeObserver(renderCurrent); + let active = false; let loadId = 0; svg.append(group); function renderCurrent() { - if (!loadedSeries.length) return; + if (!active || !loadedSeries.length) return; const height = getViewBoxHeight(svg); @@ -148,7 +169,7 @@ function createChartRenderer( highlight.clearNodes(); scrubber ??= createScrubber(svg, readout, highlight); scrubber.setSeries( - renderPlot(getView(), group, loadedSeries, height, highlight), + renderPlot(getView(), group, loadedSeries, height, highlight, getScale()), height, ); } @@ -160,7 +181,7 @@ function createChartRenderer( try { const nextSeries = await getLoadedSeries(getTimeframe()); - if (id !== loadId) return; + if (id !== loadId || !active) return; loadedSeries = nextSeries; renderCurrent(); @@ -174,11 +195,31 @@ function createChartRenderer( } } - new ResizeObserver(renderCurrent).observe(svg); + function resume() { + if (active) return; + + active = true; + resizeObserver.observe(svg); + void loadCurrent(); + } + + function suspend() { + if (!active) return; + + active = false; + loadId += 1; + resizeObserver.disconnect(); + group.replaceChildren(); + highlight.clearNodes(); + scrubber?.clear(); + svg.removeAttribute("aria-busy"); + } return { loadCurrent, renderCurrent, + resume, + suspend, }; } @@ -187,16 +228,19 @@ export function createChart(chart) { const figure = document.createElement("figure"); const svg = createSvgElement("svg"); const controls = document.createElement("footer"); + const chartControls = document.createElement("div"); const timeControls = document.createElement("div"); const status = document.createElement("p"); const chartKey = chart.title; let currentTimeframe = getDefaultTimeframe(chartKey); let currentView = getDefaultView(chartKey); + let currentScale = getDefaultScale(chartKey); const { legend, items, readout } = createLegend(chart); figure.dataset.chart = "series"; figure.dataset.timeframe = currentTimeframe; figure.dataset.view = currentView; + figure.dataset.scale = currentScale; svg.setAttribute( "viewBox", `0 0 ${VIEWBOX_WIDTH} ${FALLBACK_VIEWBOX_HEIGHT}`, @@ -214,6 +258,7 @@ export function createChart(chart) { status, chart, () => currentView, + () => currentScale, () => currentTimeframe, ); const viewControl = createViewControl(currentView, (view) => { @@ -222,6 +267,12 @@ export function createChart(chart) { figure.dataset.view = view; renderer.renderCurrent(); }); + const scaleControl = createScaleControl(currentScale, (scale) => { + currentScale = scale; + saveScale(chartKey, scale); + figure.dataset.scale = scale; + renderer.renderCurrent(); + }); const timeframeControl = createTimeframeControl( currentTimeframe, (timeframe) => { @@ -231,10 +282,14 @@ export function createChart(chart) { void renderer.loadCurrent(); }, ); + chartControls.append(viewControl, scaleControl); timeControls.append(timeframeControl, createFullscreenButton(figure)); - controls.append(viewControl, timeControls); + controls.append(chartControls, timeControls); figure.append(legend, svg, controls, status); - onFirstIntersection(figure, () => void renderer.loadCurrent()); + onChartVisibility(figure, { + show: renderer.resume, + hide: renderer.suspend, + }); return figure; } diff --git a/website_next/learn/charts/intersection.js b/website_next/learn/charts/intersection.js index e7650db58..1d7a85b00 100644 --- a/website_next/learn/charts/intersection.js +++ b/website_next/learn/charts/intersection.js @@ -1,14 +1,20 @@ /** * @param {Element} element - * @param {() => void} callback + * @param {{ show: () => void, hide: () => void }} lifecycle */ -export function onFirstIntersection(element, callback) { - const observer = new IntersectionObserver((entries) => { - if (!entries[0].isIntersecting) return; - - observer.disconnect(); - callback(); - }); +export function onChartVisibility(element, lifecycle) { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + lifecycle.show(); + } else { + lifecycle.hide(); + } + }, + { + rootMargin: "800px 0px", + }, + ); observer.observe(element); } diff --git a/website_next/learn/charts/line/index.js b/website_next/learn/charts/line/index.js index 2e00b22ca..b133f387b 100644 --- a/website_next/learn/charts/line/index.js +++ b/website_next/learn/charts/line/index.js @@ -7,9 +7,10 @@ import { createLineSeries } from "./series.js"; * @param {LoadedSeries[]} loadedSeries * @param {number} height * @param {SeriesHighlight} highlight + * @param {import("../scale.js").ChartScale} scale */ -export function renderLinePlot(group, loadedSeries, height, highlight) { - const plottedSeries = createLineSeries(loadedSeries, height); +export function renderLinePlot(group, loadedSeries, height, highlight, scale) { + const plottedSeries = createLineSeries(loadedSeries, height, scale); plottedSeries.forEach(({ color, points }, index) => { const path = createSvgElement("path"); diff --git a/website_next/learn/charts/line/series.js b/website_next/learn/charts/line/series.js index 8bd57a3f5..493490a00 100644 --- a/website_next/learn/charts/line/series.js +++ b/website_next/learn/charts/line/series.js @@ -1,52 +1,52 @@ import { VIEWBOX_WIDTH } from "../viewbox.js"; +import { scaleY } from "../scale.js"; /** @param {LoadedSeries[]} series */ function createValueBounds(series) { let min = Infinity; let max = -Infinity; + let minPositive = Infinity; for (const { entries } of series) { for (const { value } of entries) { min = Math.min(min, value); max = Math.max(max, value); + if (value > 0) minPositive = Math.min(minPositive, value); } } - return { min, max }; + return { min, max, minPositive }; } /** * @param {{ date: Date, value: number }[]} entries - * @param {{ min: number, max: number }} bounds + * @param {import("../scale.js").ScaleBounds} bounds * @param {number} height + * @param {import("../scale.js").ChartScale} scale */ -function createPoints(entries, bounds, height) { +function createPoints(entries, bounds, height, scale) { const xScale = VIEWBOX_WIDTH / (entries.length - 1); - const yScale = - bounds.max === bounds.min ? 0 : height / (bounds.max - bounds.min); return entries.map(({ date, value }, index) => ({ date, value, x: index * xScale, - y: - bounds.max === bounds.min - ? height / 2 - : height - (value - bounds.min) * yScale, + y: scaleY(value, bounds, height, scale), })); } /** * @param {LoadedSeries[]} loadedSeries * @param {number} height + * @param {import("../scale.js").ChartScale} scale */ -export function createLineSeries(loadedSeries, height) { +export function createLineSeries(loadedSeries, height, scale) { const bounds = createValueBounds(loadedSeries); return loadedSeries.map(({ series, color, entries }) => ({ series, color, - points: createPoints(entries, bounds, height), + points: createPoints(entries, bounds, height, scale), })); } diff --git a/website_next/learn/charts/scale.js b/website_next/learn/charts/scale.js new file mode 100644 index 000000000..e6202dcfd --- /dev/null +++ b/website_next/learn/charts/scale.js @@ -0,0 +1,74 @@ +import { createRadioGroup } from "./radio.js"; +import { createChartStorage } from "./storage.js"; + +const storage = createChartStorage("scale"); +/** @type {ChartScale} */ +const defaultScale = "linear"; +/** @type {{ value: ChartScale, label: string }[]} */ +const scales = [ + { value: "linear", label: "Lin" }, + { value: "log", label: "Log" }, +]; + +/** @param {string} chartKey */ +export function getDefaultScale(chartKey) { + const value = storage.get(chartKey); + + return scales.find((scale) => scale.value === value)?.value ?? defaultScale; +} + +/** + * @param {string} chartKey + * @param {ChartScale} scale + */ +export function saveScale(chartKey, scale) { + storage.set(chartKey, scale); +} + +/** + * @param {ChartScale} currentScale + * @param {(scale: ChartScale) => void} onChange + */ +export function createScaleControl(currentScale, onChange) { + return createRadioGroup({ + legend: "Scale", + options: scales, + currentValue: currentScale, + onChange, + }); +} + +/** + * @param {number} value + * @param {ScaleBounds} bounds + * @param {number} height + * @param {ChartScale} scale + */ +export function scaleY(value, bounds, height, scale) { + if (bounds.max === bounds.min) return height / 2; + + if (scale === "log") { + if (bounds.max <= bounds.minPositive) { + return value > 0 ? height / 2 : height; + } + + const nextValue = Math.max(value, bounds.minPositive); + return ( + height - + ((Math.log10(nextValue) - Math.log10(bounds.minPositive)) / + (Math.log10(bounds.max) - Math.log10(bounds.minPositive))) * + height + ); + } + + return height - ((value - bounds.min) / (bounds.max - bounds.min)) * height; +} + +/** + * @typedef {Object} ScaleBounds + * @property {number} min + * @property {number} max + * @property {number} minPositive + */ + +/** @typedef {"linear" | "log"} ChartScale */ diff --git a/website_next/learn/charts/scrubber.js b/website_next/learn/charts/scrubber.js index 3a7535bb8..5ac69cadc 100644 --- a/website_next/learn/charts/scrubber.js +++ b/website_next/learn/charts/scrubber.js @@ -103,6 +103,14 @@ export function createScrubber(svg, readout, highlight) { update(1, false); } + function clear() { + series = []; + markers = []; + group.replaceChildren(guide); + delete svg.dataset.index; + delete svg.dataset.scrubbing; + } + /** * @param {ScrubberSeries[]} nextSeries * @param {number} nextHeight @@ -153,7 +161,7 @@ export function createScrubber(svg, readout, highlight) { } }); - return { setSeries }; + return { clear, setSeries }; } /** diff --git a/website_next/learn/charts/stacked/index.js b/website_next/learn/charts/stacked/index.js index 4d443dfee..1d47f9d3a 100644 --- a/website_next/learn/charts/stacked/index.js +++ b/website_next/learn/charts/stacked/index.js @@ -8,6 +8,7 @@ import { createStackedSeries } from "./series.js"; * @param {number} height * @param {SeriesHighlight} highlight * @param {{ reversed: boolean }} options + * @param {import("../scale.js").ChartScale} scale */ export function renderStackedPlot( group, @@ -15,11 +16,13 @@ export function renderStackedPlot( height, highlight, options, + scale, ) { const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries( loadedSeries, height, options.reversed, + scale, ); for (const index of stackIndexes) { diff --git a/website_next/learn/charts/stacked/series.js b/website_next/learn/charts/stacked/series.js index 6a9c82074..5199921e9 100644 --- a/website_next/learn/charts/stacked/series.js +++ b/website_next/learn/charts/stacked/series.js @@ -1,4 +1,5 @@ import { VIEWBOX_WIDTH } from "../viewbox.js"; +import { scaleY } from "../scale.js"; /** * @param {LoadedSeries[]} series @@ -9,6 +10,7 @@ function createStackBounds(series, stackIndexes, lineIndexes) { const length = series[0].entries.length; let min = 0; let max = 0; + let minPositive = Infinity; for (let index = 0; index < length; index += 1) { let negative = 0; @@ -23,27 +25,18 @@ function createStackBounds(series, stackIndexes, lineIndexes) { min = Math.min(min, negative); max = Math.max(max, positive); + if (positive > 0) minPositive = Math.min(minPositive, positive); for (const seriesIndex of lineIndexes) { const value = series[seriesIndex].entries[index].value; min = Math.min(min, value); max = Math.max(max, value); + if (value > 0) minPositive = Math.min(minPositive, value); } } - return { min, max }; -} - -/** - * @param {number} value - * @param {{ min: number, max: number }} bounds - * @param {number} height - */ -function scaleY(value, bounds, height) { - return bounds.max === bounds.min - ? height / 2 - : height - ((value - bounds.min) / (bounds.max - bounds.min)) * height; + return { min, max, minPositive }; } /** @returns {StackedPoint[]} */ @@ -55,8 +48,9 @@ function createStackedPoints() { * @param {LoadedSeries[]} loadedSeries * @param {number} height * @param {boolean} reversed + * @param {import("../scale.js").ChartScale} scale */ -export function createStackedSeries(loadedSeries, height, reversed) { +export function createStackedSeries(loadedSeries, height, reversed, scale) { const indexes = loadedSeries.map((_, index) => index); const lineIndexes = indexes.filter( (index) => loadedSeries[index].series.role === "line", @@ -94,15 +88,15 @@ export function createStackedSeries(loadedSeries, height, reversed) { date, value, x, - y: scaleY(end, bounds, height), - y0: scaleY(start, bounds, height), - y1: scaleY(end, bounds, height), + y: scaleY(end, bounds, height, scale), + y0: scaleY(start, bounds, height, scale), + y1: scaleY(end, bounds, height, scale), }); } for (const seriesIndex of lineIndexes) { const { date, value } = loadedSeries[seriesIndex].entries[index]; - const y = scaleY(value, bounds, height); + const y = scaleY(value, bounds, height, scale); plottedSeries[seriesIndex].points.push({ date, diff --git a/website_next/learn/charts/style.css b/website_next/learn/charts/style.css index e99b58861..3cf0b9661 100644 --- a/website_next/learn/charts/style.css +++ b/website_next/learn/charts/style.css @@ -222,6 +222,7 @@ main.learn { > output { display: block; margin-top: 0.25rem; + min-height: 1em; color: var(--white); font-variant-numeric: tabular-nums; text-align: right; diff --git a/website_next/learn/cohort-series.js b/website_next/learn/cohort-series.js new file mode 100644 index 000000000..397d04089 --- /dev/null +++ b/website_next/learn/cohort-series.js @@ -0,0 +1,56 @@ +import { createSeries } from "./charts/config.js"; +import { colors } from "../utils/colors.js"; + +const palette = [ + colors.red, + colors.orange, + colors.amber, + colors.yellow, + colors.avocado, + colors.lime, + colors.green, + colors.emerald, + colors.teal, + colors.cyan, + colors.sky, + colors.blue, + colors.indigo, + colors.violet, + colors.purple, + colors.fuchsia, + colors.pink, + colors.rose, +]; + +/** @param {number} index */ +function colorAt(index) { + return palette[index % palette.length]; +} + +/** @param {readonly { label: string, color?: ChartColor, metric: Metric }[]} items */ +export function createCohortSeries(items) { + return createSeries( + items.map(({ label, color, metric }, index) => ({ + label, + color: color ?? colorAt(index), + metric, + })), + ); +} + +/** + * @template {string} Key + * @param {readonly (readonly [string, Key])[]} items + * @param {(key: Key) => Metric} createMetric + */ +export function createCohortSeriesFromKeys(items, createMetric) { + return createCohortSeries( + items.map(([label, key]) => ({ + label, + metric: createMetric(key), + })), + ); +} + +/** @typedef {import("./charts/index.js").ChartSeries["color"]} ChartColor */ +/** @typedef {import("./charts/index.js").ChartSeries["metric"]} Metric */ diff --git a/website_next/learn/cohorts.js b/website_next/learn/cohorts.js index 36a103093..41ba448f1 100644 --- a/website_next/learn/cohorts.js +++ b/website_next/learn/cohorts.js @@ -1,149 +1,16 @@ -import { createSeries } from "./charts/config.js"; +import { + createCohortSeries, + createCohortSeriesFromKeys, +} from "./cohort-series.js"; +import { + ageRanges, + amountRanges, + classes, + epochs, + outputTypes, +} from "./groups.js"; import { colors } from "../utils/colors.js"; -/** @typedef {import("./charts/index.js").ChartSeries["color"]} ChartColor */ -/** @typedef {import("./charts/index.js").ChartSeries["metric"]} Metric */ - -/** @type {ChartColor[]} */ -const palette = [ - colors.red, - colors.orange, - colors.amber, - colors.yellow, - colors.avocado, - colors.lime, - colors.green, - colors.emerald, - colors.teal, - colors.cyan, - colors.sky, - colors.blue, - colors.indigo, - colors.violet, - colors.purple, - colors.fuchsia, - colors.pink, - colors.rose, -]; - -/** @param {number} index */ -function colorAt(index) { - return palette[index % palette.length]; -} - -/** - * @param {readonly { label: string, color?: ChartColor, metric: Metric }[]} items - */ -function createCohortSeries(items) { - return createSeries( - items.map(({ label, color, metric }, index) => ({ - label, - color: color ?? colorAt(index), - metric, - })), - ); -} - -/** - * @template {string} Key - * @param {readonly (readonly [string, Key])[]} items - * @param {(key: Key) => Metric} createMetric - */ -function createCohortSeriesFromKeys(items, createMetric) { - return createCohortSeries( - items.map(([label, key]) => ({ - label, - metric: createMetric(key), - })), - ); -} - -const ageRanges = /** @type {const} */ ([ - ["0-1h", "under1h"], - ["1h to 1d", "_1hTo1d"], - ["1d to 1w", "_1dTo1w"], - ["1w to 1m", "_1wTo1m"], - ["1m to 2m", "_1mTo2m"], - ["2m to 3m", "_2mTo3m"], - ["3m to 4m", "_3mTo4m"], - ["4m to 5m", "_4mTo5m"], - ["5m to 6m", "_5mTo6m"], - ["6m to 1y", "_6mTo1y"], - ["1y to 2y", "_1yTo2y"], - ["2y to 3y", "_2yTo3y"], - ["3y to 4y", "_3yTo4y"], - ["4y to 5y", "_4yTo5y"], - ["5y to 6y", "_5yTo6y"], - ["6y to 7y", "_6yTo7y"], - ["7y to 8y", "_7yTo8y"], - ["8y to 10y", "_8yTo10y"], - ["10y to 12y", "_10yTo12y"], - ["12y to 15y", "_12yTo15y"], - ["15y+", "over15y"], -]); - -const amountRanges = /** @type {const} */ ([ - ["0 sats", "_0sats"], - ["1-10 sats", "_1satTo10sats"], - ["10-100 sats", "_10satsTo100sats"], - ["100-1k sats", "_100satsTo1kSats"], - ["1k-10k sats", "_1kSatsTo10kSats"], - ["10k-100k sats", "_10kSatsTo100kSats"], - ["100k-1M sats", "_100kSatsTo1mSats"], - ["1M-10M sats", "_1mSatsTo10mSats"], - ["10M sats-1 BTC", "_10mSatsTo1btc"], - ["1-10 BTC", "_1btcTo10btc"], - ["10-100 BTC", "_10btcTo100btc"], - ["100-1k BTC", "_100btcTo1kBtc"], - ["1k-10k BTC", "_1kBtcTo10kBtc"], - ["10k-100k BTC", "_10kBtcTo100kBtc"], - ["100k+ BTC", "over100kBtc"], -]); - -const types = /** @type {const} */ ([ - ["P2PK65", "p2pk65"], - ["P2PK33", "p2pk33"], - ["P2PKH", "p2pkh"], - ["OP_RETURN", "opReturn"], - ["P2MS", "p2ms"], - ["P2SH", "p2sh"], - ["P2WPKH", "p2wpkh"], - ["P2WSH", "p2wsh"], - ["P2TR", "p2tr"], - ["P2A", "p2a"], - ["Unknown", "unknown"], - ["Empty", "empty"], -]); - -const epochs = /** @type {const} */ ([ - ["Epoch 0", "_0"], - ["Epoch 1", "_1"], - ["Epoch 2", "_2"], - ["Epoch 3", "_3"], - ["Epoch 4", "_4"], -]); - -const classes = /** @type {const} */ ([ - ["2009", "_2009"], - ["2010", "_2010"], - ["2011", "_2011"], - ["2012", "_2012"], - ["2013", "_2013"], - ["2014", "_2014"], - ["2015", "_2015"], - ["2016", "_2016"], - ["2017", "_2017"], - ["2018", "_2018"], - ["2019", "_2019"], - ["2020", "_2020"], - ["2021", "_2021"], - ["2022", "_2022"], - ["2023", "_2023"], - ["2024", "_2024"], - ["2025", "_2025"], - ["2026", "_2026"], -]); - export const termSeries = createCohortSeries([ { label: "STH", @@ -176,7 +43,7 @@ export const addressBalanceSeries = createCohortSeriesFromKeys( ); export const typeSeries = createCohortSeriesFromKeys( - types, + outputTypes, (key) => (client) => key === "opReturn" ? client.series.outputs.value.opReturn.cumulative.btc diff --git a/website_next/learn/contents/index.js b/website_next/learn/contents/index.js index 387130315..2ddcb60e0 100644 --- a/website_next/learn/contents/index.js +++ b/website_next/learn/contents/index.js @@ -1,19 +1,23 @@ -import { createId } from "../../utils/id.js"; +import { createPathId } from "../path.js"; -/** @param {Section} section */ -function createContentsItem(section) { +/** + * @param {Section} section + * @param {readonly string[]} path + */ +function createContentsItem(section, path) { const item = document.createElement("li"); const anchor = document.createElement("a"); const children = section.children ?? []; + const sectionPath = [...path, section.title]; - anchor.href = `#${createId(section.title)}`; + anchor.href = `#${createPathId(sectionPath)}`; anchor.append(section.title); if (children.length) { const list = document.createElement("ol"); for (const child of children) { - list.append(createContentsItem(child)); + list.append(createContentsItem(child, sectionPath)); } item.append(list); } @@ -30,7 +34,7 @@ export function createContents(sections) { nav.setAttribute("aria-label", "Learn contents"); for (const section of sections) { - list.append(createContentsItem(section)); + list.append(createContentsItem(section, [])); } nav.append(list); diff --git a/website_next/learn/contents/style.css b/website_next/learn/contents/style.css index 3a3269cb1..693e129d4 100644 --- a/website_next/learn/contents/style.css +++ b/website_next/learn/contents/style.css @@ -1,9 +1,11 @@ main.learn { > nav { + --nav-offset: calc(var(--offset) + 2rem); + counter-reset: content-theme; position: sticky; top: 0; - padding-block: var(--offset); + padding-block: var(--nav-offset) var(--offset); max-height: 100dvh; overflow: auto; scrollbar-width: thin; @@ -24,16 +26,21 @@ main.learn { counter-reset: content-topic; } + > ol > li > ol > li { + counter-increment: content-topic; + counter-reset: content-detail; + } + + > ol > li > ol > li > ol > li { + counter-increment: content-detail; + } + ol ol { margin-top: 0.25rem; margin-left: 1rem; color: var(--gray); } - ol ol > li { - counter-increment: content-topic; - } - li + li { margin-top: 0.25rem; } @@ -76,8 +83,12 @@ main.learn { content: counter(content-theme, upper-roman) ". "; } - ol ol > li > a::before { + > ol > li > ol > li > a::before { content: counter(content-topic) ". "; } + + > ol > li > ol > li > ol > li > a::before { + content: counter(content-detail, lower-alpha) ". "; + } } } diff --git a/website_next/learn/data.js b/website_next/learn/data.js index f260e74f3..5be4b27f3 100644 --- a/website_next/learn/data.js +++ b/website_next/learn/data.js @@ -1,3 +1,22 @@ +import { + capitalizationSeries, + marketCapAddressBalanceSeries, + marketCapAgeSeries, + marketCapClassSeries, + marketCapEpochSeries, + marketCapSeries, + marketCapTermSeries, + marketCapTypeSeries, + marketCapUtxoBalanceSeries, + realizedCapAddressBalanceSeries, + realizedCapAgeSeries, + realizedCapClassSeries, + realizedCapEpochSeries, + realizedCapSeries, + realizedCapTermSeries, + realizedCapTypeSeries, + realizedCapUtxoBalanceSeries, +} from "./capitalization.js"; import { addressBalanceSeries, ageSeries, @@ -126,25 +145,157 @@ export const sections = [ title: "Capitalization", description: "Different ways to value the network by market price, realized cost, and accumulated flows.", - chart: "Capitalization overview", + chart: { + title: "Capitalization", + series: capitalizationSeries, + }, children: [ { title: "Market Cap", description: "The current market value of circulating bitcoin at spot price.", - chart: "Market capitalization", + chart: { + title: "Market cap", + series: marketCapSeries, + }, + children: [ + { + title: "Term", + description: + "Market value split between recently moved and long-term holder coins.", + chart: { + title: "Market cap by term", + series: marketCapTermSeries, + }, + }, + { + title: "Age", + description: + "Market value grouped by how long coins have remained still.", + chart: { + title: "Market cap by age", + series: marketCapAgeSeries, + }, + }, + { + title: "UTXO Balance", + description: + "Market value grouped by the amount held in each unspent output.", + chart: { + title: "Market cap by UTXO balance", + series: marketCapUtxoBalanceSeries, + }, + }, + { + title: "Address Balance", + description: + "Market value grouped by the balance held at each address.", + chart: { + title: "Market cap by address balance", + series: marketCapAddressBalanceSeries, + }, + }, + { + title: "Type", + description: "Market value grouped by spendable output script type.", + chart: { + title: "Market cap by type", + series: marketCapTypeSeries, + }, + }, + { + title: "Epoch", + description: + "Market value grouped by the halving epoch in which coins were created.", + chart: { + title: "Market cap by epoch", + series: marketCapEpochSeries, + }, + }, + { + title: "Class", + description: + "Market value grouped by the calendar year in which coins were created.", + chart: { + title: "Market cap by class", + series: marketCapClassSeries, + }, + }, + ], }, { title: "Realized Cap", description: "The aggregate value of coins priced where they last moved on-chain.", - chart: "Realized capitalization", - }, - { - title: "Value Bands", - description: - "How market value compares with cost basis and historical valuation ranges.", - chart: "Valuation bands", + chart: { + title: "Realized cap", + series: realizedCapSeries, + }, + children: [ + { + title: "Term", + description: + "Realized value split between recently moved and long-term holder coins.", + chart: { + title: "Realized cap by term", + series: realizedCapTermSeries, + }, + }, + { + title: "Age", + description: + "Realized value grouped by how long coins have remained still.", + chart: { + title: "Realized cap by age", + series: realizedCapAgeSeries, + }, + }, + { + title: "UTXO Balance", + description: + "Realized value grouped by the amount held in each unspent output.", + chart: { + title: "Realized cap by UTXO balance", + series: realizedCapUtxoBalanceSeries, + }, + }, + { + title: "Address Balance", + description: + "Realized value grouped by the balance held at each address.", + chart: { + title: "Realized cap by address balance", + series: realizedCapAddressBalanceSeries, + }, + }, + { + title: "Type", + description: + "Realized value grouped by spendable output script type.", + chart: { + title: "Realized cap by type", + series: realizedCapTypeSeries, + }, + }, + { + title: "Epoch", + description: + "Realized value grouped by the halving epoch in which coins were created.", + chart: { + title: "Realized cap by epoch", + series: realizedCapEpochSeries, + }, + }, + { + title: "Class", + description: + "Realized value grouped by the calendar year in which coins were created.", + chart: { + title: "Realized cap by class", + series: realizedCapClassSeries, + }, + }, + ], }, ], }, diff --git a/website_next/learn/groups.js b/website_next/learn/groups.js new file mode 100644 index 000000000..176f7d3db --- /dev/null +++ b/website_next/learn/groups.js @@ -0,0 +1,99 @@ +export const ageRanges = /** @type {const} */ ([ + ["0-1h", "under1h"], + ["1h to 1d", "_1hTo1d"], + ["1d to 1w", "_1dTo1w"], + ["1w to 1m", "_1wTo1m"], + ["1m to 2m", "_1mTo2m"], + ["2m to 3m", "_2mTo3m"], + ["3m to 4m", "_3mTo4m"], + ["4m to 5m", "_4mTo5m"], + ["5m to 6m", "_5mTo6m"], + ["6m to 1y", "_6mTo1y"], + ["1y to 2y", "_1yTo2y"], + ["2y to 3y", "_2yTo3y"], + ["3y to 4y", "_3yTo4y"], + ["4y to 5y", "_4yTo5y"], + ["5y to 6y", "_5yTo6y"], + ["6y to 7y", "_6yTo7y"], + ["7y to 8y", "_7yTo8y"], + ["8y to 10y", "_8yTo10y"], + ["10y to 12y", "_10yTo12y"], + ["12y to 15y", "_12yTo15y"], + ["15y+", "over15y"], +]); + +export const amountRanges = /** @type {const} */ ([ + ["0 sats", "_0sats"], + ["1-10 sats", "_1satTo10sats"], + ["10-100 sats", "_10satsTo100sats"], + ["100-1k sats", "_100satsTo1kSats"], + ["1k-10k sats", "_1kSatsTo10kSats"], + ["10k-100k sats", "_10kSatsTo100kSats"], + ["100k-1M sats", "_100kSatsTo1mSats"], + ["1M-10M sats", "_1mSatsTo10mSats"], + ["10M sats-1 BTC", "_10mSatsTo1btc"], + ["1-10 BTC", "_1btcTo10btc"], + ["10-100 BTC", "_10btcTo100btc"], + ["100-1k BTC", "_100btcTo1kBtc"], + ["1k-10k BTC", "_1kBtcTo10kBtc"], + ["10k-100k BTC", "_10kBtcTo100kBtc"], + ["100k+ BTC", "over100kBtc"], +]); + +export const spendableTypes = /** @type {const} */ ([ + ["P2PK65", "p2pk65"], + ["P2PK33", "p2pk33"], + ["P2PKH", "p2pkh"], + ["P2MS", "p2ms"], + ["P2SH", "p2sh"], + ["P2WPKH", "p2wpkh"], + ["P2WSH", "p2wsh"], + ["P2TR", "p2tr"], + ["P2A", "p2a"], + ["Unknown", "unknown"], + ["Empty", "empty"], +]); + +export const outputTypes = /** @type {const} */ ([ + ["P2PK65", "p2pk65"], + ["P2PK33", "p2pk33"], + ["P2PKH", "p2pkh"], + ["OP_RETURN", "opReturn"], + ["P2MS", "p2ms"], + ["P2SH", "p2sh"], + ["P2WPKH", "p2wpkh"], + ["P2WSH", "p2wsh"], + ["P2TR", "p2tr"], + ["P2A", "p2a"], + ["Unknown", "unknown"], + ["Empty", "empty"], +]); + +export const epochs = /** @type {const} */ ([ + ["Epoch 0", "_0"], + ["Epoch 1", "_1"], + ["Epoch 2", "_2"], + ["Epoch 3", "_3"], + ["Epoch 4", "_4"], +]); + +export const classes = /** @type {const} */ ([ + ["2009", "_2009"], + ["2010", "_2010"], + ["2011", "_2011"], + ["2012", "_2012"], + ["2013", "_2013"], + ["2014", "_2014"], + ["2015", "_2015"], + ["2016", "_2016"], + ["2017", "_2017"], + ["2018", "_2018"], + ["2019", "_2019"], + ["2020", "_2020"], + ["2021", "_2021"], + ["2022", "_2022"], + ["2023", "_2023"], + ["2024", "_2024"], + ["2025", "_2025"], + ["2026", "_2026"], +]); diff --git a/website_next/learn/index.js b/website_next/learn/index.js index 2308eb8f9..ed58b34fa 100644 --- a/website_next/learn/index.js +++ b/website_next/learn/index.js @@ -3,7 +3,7 @@ import { sections } from "./data.js"; import { createChart as createDataChart } from "./charts/index.js"; import { initHashLinks } from "./hash-links.js"; import { initScrollSpy } from "./scroll-spy.js"; -import { createId } from "../utils/id.js"; +import { createPathId } from "./path.js"; /** @param {Section["chart"]} chart */ function createFigure(chart) { @@ -22,15 +22,17 @@ function createFigure(chart) { /** * @param {Section} section - * @param {number} [level] + * @param {readonly string[]} [path] */ -function createSection(section, level = 1) { +function createSection(section, path = []) { const element = document.createElement("section"); - const heading = document.createElement(level === 1 ? "h1" : "h2"); + const level = path.length + 1; + const sectionPath = [...path, section.title]; + const heading = document.createElement(`h${Math.min(level, 6)}`); const anchor = document.createElement("a"); const description = document.createElement("p"); const children = section.children ?? []; - const id = createId(section.title); + const id = createPathId(sectionPath); element.id = id; anchor.href = `#${id}`; @@ -40,7 +42,7 @@ function createSection(section, level = 1) { element.append(heading, description, createFigure(section.chart)); for (const child of children) { - element.append(createSection(child, level + 1)); + element.append(createSection(child, sectionPath)); } return element; diff --git a/website_next/learn/path.js b/website_next/learn/path.js new file mode 100644 index 000000000..10fc638e7 --- /dev/null +++ b/website_next/learn/path.js @@ -0,0 +1,6 @@ +import { createId } from "../utils/id.js"; + +/** @param {readonly string[]} path */ +export function createPathId(path) { + return createId(path.join(" ")); +} diff --git a/website_next/learn/scroll-spy.js b/website_next/learn/scroll-spy.js index 8a0158459..7797f4866 100644 --- a/website_next/learn/scroll-spy.js +++ b/website_next/learn/scroll-spy.js @@ -2,6 +2,7 @@ const thresholds = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]; /** @param {HTMLElement} main */ export function initScrollSpy(main) { + const nav = /** @type {HTMLElement} */ (main.querySelector("nav")); const sections = [...main.querySelectorAll("section[id]")]; const sectionStates = sections.map((section) => ({ section, @@ -46,6 +47,23 @@ export function initScrollSpy(main) { return /** @type {HTMLAnchorElement} */ (links.get(hash)); } + /** @param {HTMLElement} link */ + function scrollLinkIntoNav(link) { + const style = getComputedStyle(nav); + const top = Number.parseFloat(style.paddingTop); + const bottom = Number.parseFloat(style.paddingBottom); + const navRect = nav.getBoundingClientRect(); + const linkRect = link.getBoundingClientRect(); + + if (linkRect.top < navRect.top + top) { + nav.scrollBy({ top: linkRect.top - navRect.top - top }); + } + + if (linkRect.bottom > navRect.bottom - bottom) { + nav.scrollBy({ top: linkRect.bottom - navRect.bottom + bottom }); + } + } + /** @param {string} hash */ function setCurrentHash(hash) { if (hash === current) return; @@ -54,7 +72,7 @@ export function initScrollSpy(main) { const link = getLink(hash); link.setAttribute("aria-current", "location"); - link.scrollIntoView({ block: "nearest", inline: "nearest" }); + scrollLinkIntoNav(link); history.replaceState(null, "", hash); current = hash; diff --git a/website_next/learn/style.css b/website_next/learn/style.css index 365e0b372..57cf6bea7 100644 --- a/website_next/learn/style.css +++ b/website_next/learn/style.css @@ -1,6 +1,16 @@ main.learn { - --offset: 6rem; + --offset: 4rem; --content-width: 52rem; + --heading-padding-bottom: 0.5rem; + --topic-font-size: 2rem; + --topic-padding-top: 4.5rem; + --topic-sticky-size: calc( + var(--topic-padding-top) + var(--topic-font-size) + + var(--heading-padding-bottom) + 1px + ); + --detail-font-size: 1.5rem; + --detail-padding-top: calc(var(--topic-sticky-size) + 0.75rem); + --detail-padding-bottom: 0.375rem; display: grid; grid-template-columns: 14rem minmax(0, 1fr); @@ -15,7 +25,7 @@ main.learn { content: ""; position: sticky; top: 0; - z-index: 2; + z-index: 4; display: block; height: var(--offset); margin-top: calc(-1 * var(--offset)); @@ -42,18 +52,21 @@ main.learn { margin-top: 8rem; } - section section { + > section > section { counter-increment: topic; + counter-reset: detail; + scroll-margin-top: var(--offset); + } + + > section > section > section { + counter-increment: detail; scroll-margin-top: var(--offset); } } h1, - h2 { - position: sticky; - top: var(--offset); - padding-bottom: 0.5rem; - background: var(--black); + h2, + h3 { line-height: 1; a { @@ -88,10 +101,19 @@ main.learn { } } + h1, + h2, + h3 { + position: sticky; + top: var(--offset); + background: var(--black); + } + h1 { z-index: 3; + padding-bottom: var(--heading-padding-bottom); border-bottom: 1px solid var(--dark-gray); - font-size: 2.75rem; + font-size: 3rem; a::before { content: counter(theme, upper-roman) ". "; @@ -99,16 +121,29 @@ main.learn { } h2 { - z-index: 1; - padding-top: 4.5rem; + z-index: 2; + padding-top: var(--topic-padding-top); + padding-bottom: var(--heading-padding-bottom); border-bottom: 1px dashed var(--dark-gray); - font-size: 1.5rem; + font-size: var(--topic-font-size); a::before { content: counter(topic) ". "; } } + h3 { + z-index: 1; + padding-top: var(--detail-padding-top); + padding-bottom: var(--detail-padding-bottom); + border-bottom: 1px dotted var(--dark-gray); + font-size: var(--detail-font-size); + + a::before { + content: counter(detail, lower-alpha) ". "; + } + } + p { margin-top: 1rem; color: var(--dark-white); diff --git a/website_next/main.js b/website_next/main.js index 90bc8e637..7228f79e3 100644 --- a/website_next/main.js +++ b/website_next/main.js @@ -12,24 +12,6 @@ const pageByPath = new Map(); const header = createHeader(); document.body.append(header); -const navLinks = [...header.querySelectorAll("nav a")]; - -/** @param {string} pathname */ -function updateCurrentLink(pathname) { - const currentPath = normalizePath(pathname); - - for (const link of navLinks) { - const linkPath = new URL(/** @type {HTMLAnchorElement} */ (link).href) - .pathname; - - if (linkPath === currentPath) { - link.setAttribute("aria-current", "page"); - } else { - link.removeAttribute("aria-current"); - } - } -} - /** @param {string} pathname */ function getPage(pathname) { let page = pageByPath.get(pathname); @@ -60,7 +42,6 @@ function activatePage(page) { function renderPage() { const pathname = normalizePath(window.location.pathname); activatePage(getPage(pathname)); - updateCurrentLink(pathname); } /** @param {string} pathname */ diff --git a/website_next/routes.js b/website_next/routes.js index 3b6a63fb8..8f56e65b6 100644 --- a/website_next/routes.js +++ b/website_next/routes.js @@ -5,9 +5,9 @@ import { createLearnPage } from "./learn/index.js"; const pages = [ { pathname: "/", createPage: createHomePage }, - { pathname: "/explore", label: "Explore", createPage: createExplorePage }, - { pathname: "/learn", label: "Learn", createPage: createLearnPage }, - { pathname: "/build", label: "Build", createPage: createBuildPage }, + { pathname: "/explore", createPage: createExplorePage }, + { pathname: "/learn", createPage: createLearnPage }, + { pathname: "/build", createPage: createBuildPage }, ]; /** @type {Record HTMLElement>} */ @@ -15,10 +15,6 @@ const routes = Object.fromEntries( pages.map(({ pathname, createPage }) => [pathname, createPage]), ); -export const primaryRoutes = pages.flatMap(({ pathname, label }) => - label ? [{ pathname, label }] : [], -); - /** @param {string} pathname */ export function isRoute(pathname) { return pathname in routes;