diff --git a/website_next/index.html b/website_next/index.html index 7cb6131f3..597755411 100644 --- a/website_next/index.html +++ b/website_next/index.html @@ -10,7 +10,7 @@ diff --git a/website_next/learn/charts/format.js b/website_next/learn/charts/format.js index 34dff581b..40a694c12 100644 --- a/website_next/learn/charts/format.js +++ b/website_next/learn/charts/format.js @@ -6,6 +6,10 @@ const numberFormats = [0, 1, 2, 3].map( minimumFractionDigits: digits, }), ); +const percentFormat = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 2, + minimumFractionDigits: 2, +}); /** * @param {number} value @@ -16,7 +20,7 @@ function formatNumber(value, digits) { } /** @param {number} value */ -export function formatValue(value) { +export function formatNumberValue(value) { if (value === 0) return "0"; const absolute = Math.abs(value); @@ -34,3 +38,8 @@ export function formatValue(value) { return `${formatNumber(scaled, digits)}${suffixes[suffixIndex]}`; } + +/** @param {number} value */ +export function formatPercentValue(value) { + return value === 0 ? "0%" : `${percentFormat.format(value)}%`; +} diff --git a/website_next/learn/charts/highlight.js b/website_next/learn/charts/highlight.js index 0dad1627f..1f7fac22c 100644 --- a/website_next/learn/charts/highlight.js +++ b/website_next/learn/charts/highlight.js @@ -4,8 +4,9 @@ */ export function createSeriesHighlight(items, menu) { const seriesNodes = /** @type {SeriesNode[]} */ (items.map(() => [])); - /** @type {number | undefined} */ - let previewIndex; + const noSeries = -1; + let selectedSeries = noSeries; + let previewedSeries = noSeries; /** @param {number} index */ function scrollToItem(index) { @@ -27,7 +28,7 @@ export function createSeriesHighlight(items, menu) { } /** @param {number} index */ - function activate(index) { + function highlightSeries(index) { for (const [itemIndex, item] of items.entries()) { setActive(item, itemIndex === index); } @@ -39,38 +40,67 @@ export function createSeriesHighlight(items, menu) { }); } - function clear() { - for (const item of items) clearState(item); + function clearHighlight() { + for (const item of items) clearElementState(item); for (const nodes of seriesNodes) { - for (const node of nodes) clearState(node); + for (const node of nodes) clearElementState(node); } + } - previewIndex = undefined; + function restoreSelectedHighlight() { + if (selectedSeries === noSeries) { + clearHighlight(); + } else { + highlightSeries(selectedSeries); + } + } + + function clearInteractionHighlight() { + clearPreview(); + restoreSelectedHighlight(); } /** @param {number} index */ - function previewItem(index) { - if (index === previewIndex) return; + function selectSeries(index) { + selectedSeries = index; + + items.forEach((item, itemIndex) => { + item.setAttribute( + "aria-pressed", + (itemIndex === selectedSeries).toString(), + ); + }); + + restoreSelectedHighlight(); + } + + /** @param {number} index */ + function previewSeries(index) { + if (index === previewedSeries) return; clearPreview(); scrollToItem(index); items[index].dataset.preview = ""; - previewIndex = index; + previewedSeries = index; } function clearPreview() { - if (previewIndex === undefined) return; + if (previewedSeries === noSeries) return; - delete items[previewIndex].dataset.preview; - previewIndex = undefined; + delete items[previewedSeries].dataset.preview; + previewedSeries = noSeries; } items.forEach((item, index) => { - item.addEventListener("pointerenter", () => activate(index)); - item.addEventListener("pointerleave", clear); - item.addEventListener("focus", () => activate(index)); - item.addEventListener("blur", clear); + item.setAttribute("aria-pressed", "false"); + item.addEventListener("pointerenter", () => highlightSeries(index)); + item.addEventListener("pointerleave", clearInteractionHighlight); + item.addEventListener("focus", () => highlightSeries(index)); + item.addEventListener("blur", clearInteractionHighlight); + item.addEventListener("click", () => { + selectSeries(selectedSeries === index ? noSeries : index); + }); }); /** @@ -78,11 +108,12 @@ export function createSeriesHighlight(items, menu) { * @param {number} index */ function addNode(node, index) { + if (selectedSeries !== noSeries) setActive(node, index === selectedSeries); seriesNodes[index].push(node); } function clearNodes() { - clear(); + clearInteractionHighlight(); for (const nodes of seriesNodes) { nodes.length = 0; @@ -93,7 +124,7 @@ export function createSeriesHighlight(items, menu) { addNode, clearPreview, clearNodes, - preview: previewItem, + preview: previewSeries, }; } @@ -112,7 +143,7 @@ function setActive(element, active) { } /** @param {HTMLElement | SVGElement} element */ -function clearState(element) { +function clearElementState(element) { delete element.dataset.active; delete element.dataset.muted; delete element.dataset.preview; diff --git a/website_next/learn/charts/index.js b/website_next/learn/charts/index.js index b28d0f5dc..3032e043a 100644 --- a/website_next/learn/charts/index.js +++ b/website_next/learn/charts/index.js @@ -28,90 +28,103 @@ import { FALLBACK_VIEWBOX_HEIGHT, VIEWBOX_WIDTH } from "./viewbox.js"; /** @param {Chart} chart */ export function createChart(chart) { const figure = document.createElement("figure"); - const plot = document.createElement("div"); - 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, chart.defaultType); - let currentScale = getDefaultScale(chartKey, chart.defaultScale); - let currentOrder = getDefaultOrder(chartKey); - const { legend, menu, items, readout } = createLegend(chart); + /** @type {ReturnType | undefined} */ + let renderer; figure.dataset.chart = "series"; - plot.dataset.chart = "plot"; - figure.dataset.timeframe = currentTimeframe; - figure.dataset.view = currentView; - figure.dataset.scale = currentScale; - figure.dataset.order = currentOrder; - svg.setAttribute( - "viewBox", - `0 0 ${VIEWBOX_WIDTH} ${FALLBACK_VIEWBOX_HEIGHT}`, - ); - svg.setAttribute("role", "img"); - svg.setAttribute("aria-label", chart.title); - svg.setAttribute("tabindex", "0"); - status.setAttribute("aria-live", "polite"); - status.setAttribute("role", "status"); - const renderer = createChartRenderer({ - svg, - readout, - menu, - items, - status, - chart, - getView: () => currentView, - getScale: () => currentScale, - getOrder: () => currentOrder, - getTimeframe: () => currentTimeframe, - }); + function mount() { + if (renderer) return renderer; - /** - * @template {string} T - * @param {string} dataKey - * @param {(chartKey: string, value: T) => void} save - * @param {T} value - */ - function saveChartSetting(dataKey, save, value) { - save(chartKey, value); - figure.dataset[dataKey] = value; + const plot = document.createElement("div"); + const svg = createSvgElement("svg"); + const controls = document.createElement("footer"); + const chartControls = document.createElement("div"); + const timeControls = document.createElement("div"); + const status = document.createElement("p"); + let currentTimeframe = getDefaultTimeframe(chartKey); + let currentView = getDefaultView(chartKey, chart.defaultType); + let currentScale = getDefaultScale(chartKey, chart.defaultScale); + let currentOrder = getDefaultOrder(chartKey); + const { legend, menu, items, readout } = createLegend(chart); + + plot.dataset.chart = "plot"; + figure.dataset.timeframe = currentTimeframe; + figure.dataset.view = currentView; + figure.dataset.scale = currentScale; + figure.dataset.order = currentOrder; + svg.setAttribute( + "viewBox", + `0 0 ${VIEWBOX_WIDTH} ${FALLBACK_VIEWBOX_HEIGHT}`, + ); + svg.setAttribute("role", "img"); + svg.setAttribute("aria-label", chart.title); + svg.setAttribute("tabindex", "0"); + status.setAttribute("aria-live", "polite"); + status.setAttribute("role", "status"); + + const nextRenderer = createChartRenderer({ + svg, + readout, + menu, + items, + status, + chart, + getView: () => currentView, + getScale: () => currentScale, + getOrder: () => currentOrder, + getTimeframe: () => currentTimeframe, + }); + + /** + * @template {string} T + * @param {string} dataKey + * @param {(chartKey: string, value: T) => void} save + * @param {T} value + */ + function saveChartSetting(dataKey, save, value) { + save(chartKey, value); + figure.dataset[dataKey] = value; + } + + const viewControl = createViewControl(currentView, (view) => { + currentView = view; + saveChartSetting("view", saveView, view); + nextRenderer.renderCurrent(); + }); + const scaleControl = createScaleControl(currentScale, (scale) => { + currentScale = scale; + saveChartSetting("scale", saveScale, scale); + nextRenderer.renderCurrent(); + }); + const orderControl = createOrderControl(currentOrder, (order) => { + currentOrder = order; + saveChartSetting("order", saveOrder, order); + nextRenderer.renderCurrent(); + }); + const timeframeControl = createTimeframeControl( + currentTimeframe, + (timeframe) => { + currentTimeframe = timeframe; + saveChartSetting("timeframe", saveTimeframe, timeframe); + void nextRenderer.loadCurrent(); + }, + ); + + chartControls.append(viewControl, scaleControl, orderControl); + timeControls.append(timeframeControl, createFullscreenButton(figure)); + controls.append(chartControls, timeControls); + plot.append(svg, status); + figure.replaceChildren(legend, plot, controls); + renderer = nextRenderer; + + return renderer; } - const viewControl = createViewControl(currentView, (view) => { - currentView = view; - saveChartSetting("view", saveView, view); - renderer.renderCurrent(); - }); - const scaleControl = createScaleControl(currentScale, (scale) => { - currentScale = scale; - saveChartSetting("scale", saveScale, scale); - renderer.renderCurrent(); - }); - const orderControl = createOrderControl(currentOrder, (order) => { - currentOrder = order; - saveChartSetting("order", saveOrder, order); - renderer.renderCurrent(); - }); - const timeframeControl = createTimeframeControl( - currentTimeframe, - (timeframe) => { - currentTimeframe = timeframe; - saveChartSetting("timeframe", saveTimeframe, timeframe); - void renderer.loadCurrent(); - }, - ); - chartControls.append(viewControl, scaleControl, orderControl); - timeControls.append(timeframeControl, createFullscreenButton(figure)); - controls.append(chartControls, timeControls); - plot.append(svg, status); - figure.append(legend, plot, controls); onChartVisibility(figure, { - show: renderer.resume, - hide: renderer.suspend, + show: () => mount().resume(), + hide: () => renderer?.suspend(), }); return figure; diff --git a/website_next/learn/charts/intersection.js b/website_next/learn/charts/intersection.js index 1d7a85b00..fbf3b4e9d 100644 --- a/website_next/learn/charts/intersection.js +++ b/website_next/learn/charts/intersection.js @@ -1,20 +1,21 @@ +const lifecycleByElement = new WeakMap(); +const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const lifecycle = lifecycleByElement.get(entry.target); + lifecycle?.[entry.isIntersecting ? "show" : "hide"](); + } + }, + { + rootMargin: "800px 0px", + }, +); + /** * @param {Element} element * @param {{ show: () => void, hide: () => void }} lifecycle */ export function onChartVisibility(element, lifecycle) { - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting) { - lifecycle.show(); - } else { - lifecycle.hide(); - } - }, - { - rootMargin: "800px 0px", - }, - ); - + lifecycleByElement.set(element, lifecycle); observer.observe(element); } diff --git a/website_next/learn/charts/legend/index.js b/website_next/learn/charts/legend/index.js index 36ae09aeb..a08b15792 100644 --- a/website_next/learn/charts/legend/index.js +++ b/website_next/learn/charts/legend/index.js @@ -5,7 +5,7 @@ export function createLegend(chart) { const legend = document.createElement("figcaption"); const header = document.createElement("header"); - const title = document.createElement("h4"); + const title = document.createElement("h5"); const separator = document.createElement("span"); const unit = document.createElement("span"); const time = document.createElement("time"); @@ -17,6 +17,7 @@ export function createLegend(chart) { const value = document.createElement("output"); button.type = "button"; + button.setAttribute("aria-label", `Highlight ${series.label}`); button.style.setProperty("--color", series.color()); label.append(series.label); button.append(label, value); diff --git a/website_next/learn/charts/legend/style.css b/website_next/learn/charts/legend/style.css index 45c86b3a7..a9728b67f 100644 --- a/website_next/learn/charts/legend/style.css +++ b/website_next/learn/charts/legend/style.css @@ -10,7 +10,7 @@ main.learn { gap: 1rem; } - h4 { + h5 { margin: 0; font-family: var(--font-mono); font-size: inherit; @@ -111,7 +111,7 @@ main.learn { &:fullscreen { figcaption { - h4 { + h5 { color: var(--white); font-family: var(--font-serif); font-size: 2rem; diff --git a/website_next/learn/charts/renderer.js b/website_next/learn/charts/renderer.js index 370f65853..6870a7db1 100644 --- a/website_next/learn/charts/renderer.js +++ b/website_next/learn/charts/renderer.js @@ -61,7 +61,7 @@ export function createChartRenderer({ svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${height}`); group.replaceChildren(); highlight.clearNodes(); - scrubber ??= createScrubber(svg, readout, highlight); + scrubber ??= createScrubber(svg, readout, highlight, chart.unit.format); scrubber.setSeries( renderPlot( getView(), diff --git a/website_next/learn/charts/scrubber/index.js b/website_next/learn/charts/scrubber/index.js index 1a59e4482..46c5840ec 100644 --- a/website_next/learn/charts/scrubber/index.js +++ b/website_next/learn/charts/scrubber/index.js @@ -1,4 +1,3 @@ -import { formatValue } from "../format.js"; import { clamp } from "../math.js"; import { createSvgElement } from "../svg.js"; import { VIEWBOX_WIDTH } from "../viewbox.js"; @@ -59,12 +58,13 @@ function updateTime(time, date) { /** * @param {Readout} readout * @param {ReturnType[]} points + * @param {(value: number) => string} format */ -function updateReadout(readout, points) { +function updateReadout(readout, points, format) { updateTime(readout.time, points[0].date); readout.rows.forEach(({ value }, index) => { - value.textContent = formatValue(points[index].value); + value.textContent = format(points[index].value); }); } @@ -72,8 +72,9 @@ function updateReadout(readout, points) { * @param {SVGSVGElement} svg * @param {Readout} readout * @param {SeriesHighlight} highlight + * @param {(value: number) => string} format */ -export function createScrubber(svg, readout, highlight) { +export function createScrubber(svg, readout, highlight, format) { const group = createSvgElement("g"); const shade = createSvgElement("rect"); const guide = createSvgElement("line"); @@ -128,7 +129,7 @@ export function createScrubber(svg, readout, highlight) { guide.setAttribute("x2", xText); guide.setAttribute("y1", "0"); guide.setAttribute("y2", height.toString()); - updateReadout(readout, currentPoints); + updateReadout(readout, currentPoints, format); markers.forEach((marker, index) => { const point = currentPoints[index]; diff --git a/website_next/learn/charts/style.css b/website_next/learn/charts/style.css index 6bd74c8e0..21fb84f39 100644 --- a/website_next/learn/charts/style.css +++ b/website_next/learn/charts/style.css @@ -1,11 +1,18 @@ main.learn { figure[data-chart="series"] { + --chart-plot-height: 20rem; + --chart-placeholder-height: calc(var(--chart-plot-height) + 4rem); + line-height: 1; + &:empty { + min-height: var(--chart-placeholder-height); + } + svg { display: block; width: 100%; - height: 20rem; + height: var(--chart-plot-height); outline: 0; cursor: crosshair; overflow: visible; @@ -13,6 +20,11 @@ main.learn { transition: opacity 150ms ease; } + svg:focus-visible { + outline: 1px solid var(--orange); + outline-offset: 0.25rem; + } + svg[aria-busy="true"] { opacity: 0.25; } diff --git a/website_next/learn/charts/units.js b/website_next/learn/charts/units.js index 95f090346..07e8e22f8 100644 --- a/website_next/learn/charts/units.js +++ b/website_next/learn/charts/units.js @@ -1,6 +1,12 @@ +import { formatNumberValue, formatPercentValue } from "./format.js"; + export const units = /** @type {const} */ ({ - btc: { id: "btc", name: "Bitcoin" }, - usd: { id: "usd", name: "US Dollars" }, + addresses: { id: "addresses", name: "Addresses", format: formatNumberValue }, + blocks: { id: "blocks", name: "Blocks", format: formatNumberValue }, + btc: { id: "btc", name: "Bitcoin", format: formatNumberValue }, + percent: { id: "%", name: "Percent", format: formatPercentValue }, + utxos: { id: "utxos", name: "UTXOs", format: formatNumberValue }, + usd: { id: "usd", name: "US Dollars", format: formatNumberValue }, }); /** @typedef {keyof typeof units} ChartUnitKey */ diff --git a/website_next/learn/contents/style.css b/website_next/learn/contents/style.css index ef1e5126b..0dcc2e523 100644 --- a/website_next/learn/contents/style.css +++ b/website_next/learn/contents/style.css @@ -22,28 +22,6 @@ main.learn { padding: 0; } - > ol > li { - counter-reset: content-topic; - } - - > ol > li:not([data-numbered="false"]) { - counter-increment: content-theme; - } - - > 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; - } - li + li { margin-top: 0.25rem; } @@ -61,6 +39,7 @@ main.learn { &::before { opacity: 0.5; + text-transform: none; } &:is(:hover, :active) { @@ -82,21 +61,64 @@ main.learn { } } - > ol > li > a::before { - content: counter(content-theme, upper-roman) ". "; - } + > ol { + > li { + counter-reset: content-topic; - > ol > li[data-numbered="false"] > a::before { - content: "I. "; - visibility: hidden; - } + &:not([data-numbered="false"]) { + counter-increment: content-theme; + } - > ol > li > ol > li > a::before { - content: counter(content-topic) ". "; - } + > a::before { + content: counter(content-theme, upper-roman) ". "; + } - > ol > li > ol > li > ol > li > a::before { - content: counter(content-detail, lower-alpha) ". "; + &[data-numbered="false"] > a::before { + content: "I. "; + visibility: hidden; + } + + > ol { + margin-top: 0.25rem; + margin-left: 1rem; + + > li { + counter-increment: content-topic; + counter-reset: content-detail; + + > a::before { + content: counter(content-topic) ". "; + } + + > ol { + margin-top: 0.25rem; + margin-left: 1rem; + + > li { + counter-increment: content-detail; + counter-reset: content-subtopic; + + > a::before { + content: counter(content-detail, lower-alpha) ". "; + } + + > ol { + margin-top: 0.25rem; + margin-left: 0.5rem; + + > li { + counter-increment: content-subtopic; + + > a::before { + content: counter(content-subtopic, lower-alpha) ". "; + } + } + } + } + } + } + } + } } } } diff --git a/website_next/learn/data/address-count.js b/website_next/learn/data/address-count.js new file mode 100644 index 000000000..51520b49c --- /dev/null +++ b/website_next/learn/data/address-count.js @@ -0,0 +1,93 @@ +import { + createCohortSeries, + createCohortSeriesFromKeys, +} from "./cohort-series.js"; +import { addressableTypes, amountRanges } from "./groups.js"; +import { createRollingWindowSeries } from "./rolling-windows.js"; +import { colors } from "../../utils/colors.js"; + +export const fundedSeries = createCohortSeries([ + { + label: "Funded", + color: colors.orange, + metric: (client) => client.series.addrs.funded.all, + }, +]); + +export const newSeries = createRollingWindowSeries( + (key) => (client) => client.series.addrs.new.all.sum[key], +); + +export const changeSeries = createRollingWindowSeries( + (key) => (client) => client.series.addrs.delta.all.absolute[key], +); + +export const growthRateSeries = createRollingWindowSeries( + (key) => (client) => client.series.addrs.delta.all.rate[key].percent, +); + +export const activeSeries = createRollingWindowSeries( + (key) => (client) => client.series.addrs.activity.all.active[key], +); + +export const sendingSeries = createRollingWindowSeries( + (key) => (client) => client.series.addrs.activity.all.sending[key], +); + +export const receivingSeries = createRollingWindowSeries( + (key) => (client) => client.series.addrs.activity.all.receiving[key], +); + +export const bidirectionalSeries = createRollingWindowSeries( + (key) => (client) => client.series.addrs.activity.all.bidirectional[key], +); + +export const reactivatedSeries = createRollingWindowSeries( + (key) => (client) => client.series.addrs.activity.all.reactivated[key], +); + +export const stateSeries = createCohortSeries([ + { + label: "Funded", + color: colors.green, + metric: (client) => client.series.addrs.funded.all, + }, + { + label: "Empty", + color: colors.red, + metric: (client) => client.series.addrs.empty.all, + }, + { + label: "Total", + color: colors.orange, + metric: (client) => client.series.addrs.total.all, + }, +]); + +export const balanceSeries = createCohortSeriesFromKeys( + amountRanges, + (key) => (client) => client.series.cohorts.addr.amountRange[key].addrCount.base, +); + +export const typeSeries = createCohortSeriesFromKeys( + addressableTypes, + (key) => (client) => client.series.addrs.funded[key], +); + +export const reuseSeries = createCohortSeries([ + { + label: "Reused", + color: colors.yellow, + metric: (client) => client.series.addrs.reused.count.funded.all, + }, + { + label: "Respent", + color: colors.fuchsia, + metric: (client) => client.series.addrs.respent.count.funded.all, + }, + { + label: "Exposed", + color: colors.orange, + metric: (client) => client.series.addrs.exposed.count.funded.all, + }, +]); diff --git a/website_next/learn/capitalization.js b/website_next/learn/data/capitalization.js similarity index 98% rename from website_next/learn/capitalization.js rename to website_next/learn/data/capitalization.js index 9fe8313b8..53bb32367 100644 --- a/website_next/learn/capitalization.js +++ b/website_next/learn/data/capitalization.js @@ -9,7 +9,7 @@ import { epochs, spendableTypes, } from "./groups.js"; -import { colors } from "../utils/colors.js"; +import { colors } from "../../utils/colors.js"; export const capitalizationSeries = createCohortSeries([ { diff --git a/website_next/learn/cohort-series.js b/website_next/learn/data/cohort-series.js similarity index 83% rename from website_next/learn/cohort-series.js rename to website_next/learn/data/cohort-series.js index 1f8fa7e0f..37198f9a1 100644 --- a/website_next/learn/cohort-series.js +++ b/website_next/learn/data/cohort-series.js @@ -1,4 +1,4 @@ -import { colors } from "../utils/colors.js"; +import { colors } from "../../utils/colors.js"; const palette = [ colors.red, @@ -49,5 +49,5 @@ export function createCohortSeriesFromKeys(items, createMetric) { ); } -/** @typedef {import("./charts/index.js").ChartSeries["color"]} ChartColor */ -/** @typedef {import("./charts/index.js").ChartSeries["metric"]} Metric */ +/** @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/data/cohorts.js similarity index 97% rename from website_next/learn/cohorts.js rename to website_next/learn/data/cohorts.js index f2860966c..59b8a9ace 100644 --- a/website_next/learn/cohorts.js +++ b/website_next/learn/data/cohorts.js @@ -10,7 +10,7 @@ import { epochs, outputTypes, } from "./groups.js"; -import { colors } from "../utils/colors.js"; +import { colors } from "../../utils/colors.js"; export const exposedSupplySeries = createCohortSeries([ { diff --git a/website_next/learn/groups.js b/website_next/learn/data/groups.js similarity index 100% rename from website_next/learn/groups.js rename to website_next/learn/data/groups.js diff --git a/website_next/learn/data.js b/website_next/learn/data/index.js similarity index 53% rename from website_next/learn/data.js rename to website_next/learn/data/index.js index 26221b4d8..922b75327 100644 --- a/website_next/learn/data.js +++ b/website_next/learn/data/index.js @@ -1,9 +1,15 @@ +import { addressCountSection } from "./sections/address-count.js"; import { capitalizationSection } from "./sections/capitalization.js"; import { introductionSection } from "./sections/introduction.js"; +import { miningPoolsSection } from "./sections/mining-pools.js"; import { supplySection } from "./sections/supply.js"; +import { utxoSetSection } from "./sections/utxo-set.js"; export const sections = [ introductionSection, supplySection, + utxoSetSection, + addressCountSection, + miningPoolsSection, capitalizationSection, ]; diff --git a/website_next/learn/data/mining-pools.js b/website_next/learn/data/mining-pools.js new file mode 100644 index 000000000..f5a14079c --- /dev/null +++ b/website_next/learn/data/mining-pools.js @@ -0,0 +1,108 @@ +import { createCohortSeries } from "./cohort-series.js"; +import { createRollingWindowSeries } from "./rolling-windows.js"; +import { colors } from "../../utils/colors.js"; +import { brk } from "../../utils/client.js"; + +const poolNames = brk.POOL_ID_TO_POOL_NAME; + +/** + * @template {keyof typeof poolNames} Key + * @template Pool + * @param {Record} pools + */ +function createPools(pools) { + const entries = []; + + for (const key in pools) { + entries.push({ + name: poolNames[key], + pool: pools[key], + }); + } + + return entries; +} + +/** + * @param {(window: WindowKey) => TimeframeMetric} createMetric + */ +function createWindowSeries(createMetric) { + return createRollingWindowSeries((window) => () => createMetric(window)); +} + +export const majorPools = createPools(brk.series.pools.major); + +export const minorPools = createPools(brk.series.pools.minor); + +export const majorPoolDominanceSeries = createCohortSeries( + majorPools.map(({ name, pool }) => ({ + label: name, + metric: () => pool.dominance._1m.percent, + })), +); + +export const majorPoolBlocksMinedSeries = createCohortSeries( + majorPools.map(({ name, pool }) => ({ + label: name, + metric: () => pool.blocksMined.sum._1m, + })), +); + +export const majorPoolRewardsSeries = createCohortSeries( + majorPools.map(({ name, pool }) => ({ + label: name, + metric: () => pool.rewards.sum._1m.btc, + })), +); + +/** @param {MajorPool} pool */ +export function createMajorPoolDominanceSeries(pool) { + const series = createWindowSeries( + (window) => pool.dominance[window].percent, + ); + + series.push({ + label: "All time", + color: colors.orange, + metric: () => pool.dominance.percent, + }); + + return series; +} + +/** @param {MajorPool} pool */ +export function createMajorPoolBlocksMinedSeries(pool) { + return createWindowSeries( + (window) => pool.blocksMined.sum[window], + ); +} + +/** @param {MajorPool} pool */ +export function createMajorPoolRewardsSeries(pool) { + return createWindowSeries( + (window) => pool.rewards.sum[window].btc, + ); +} + +/** @param {MinorPool} pool */ +export function createMinorPoolDominanceSeries(pool) { + return createCohortSeries([ + { + label: "All time", + color: colors.orange, + metric: () => pool.dominance.percent, + }, + ]); +} + +/** @param {MinorPool} pool */ +export function createMinorPoolBlocksMinedSeries(pool) { + return createWindowSeries( + (window) => pool.blocksMined.sum[window], + ); +} + +/** @typedef {import("./rolling-windows.js").RollingWindowKey} WindowKey */ +/** @typedef {typeof brk.series.pools.major.unknown} MajorPool */ +/** @typedef {typeof brk.series.pools.minor.blockfills} MinorPool */ +/** @typedef {import("../charts/timeframes.js").TimeframeMetric} TimeframeMetric */ diff --git a/website_next/learn/data/rolling-windows.js b/website_next/learn/data/rolling-windows.js new file mode 100644 index 000000000..5418c5993 --- /dev/null +++ b/website_next/learn/data/rolling-windows.js @@ -0,0 +1,23 @@ +import { createCohortSeries } from "./cohort-series.js"; +import { colors } from "../../utils/colors.js"; + +export const rollingWindows = /** @type {const} */ ([ + ["24h", "_24h", colors.sky], + ["1w", "_1w", colors.cyan], + ["1m", "_1m", colors.blue], + ["1y", "_1y", colors.violet], +]); + +/** @param {(key: RollingWindowKey) => Metric} createMetric */ +export function createRollingWindowSeries(createMetric) { + return createCohortSeries( + rollingWindows.map(([label, key, color]) => ({ + label, + color, + metric: createMetric(key), + })), + ); +} + +/** @typedef {(typeof rollingWindows)[number][1]} RollingWindowKey */ +/** @typedef {import("./cohort-series.js").Metric} Metric */ diff --git a/website_next/learn/data/sections/address-count.js b/website_next/learn/data/sections/address-count.js new file mode 100644 index 000000000..45c55b415 --- /dev/null +++ b/website_next/learn/data/sections/address-count.js @@ -0,0 +1,178 @@ +import { + activeSeries, + balanceSeries, + bidirectionalSeries, + changeSeries, + fundedSeries, + growthRateSeries, + newSeries, + reactivatedSeries, + receivingSeries, + reuseSeries, + sendingSeries, + stateSeries, + typeSeries, +} from "../address-count.js"; +import { units } from "../../charts/units.js"; + +const line = /** @type {const} */ ("line"); + +export const addressCountSection = { + title: "Address Count", + description: + "Address count measures Bitcoin addresses, not people or entities. A funded address currently has a non-zero balance, while empty addresses have received or spent coins before but no longer hold BTC. These charts show how the address set grows, turns over, and distributes across balances and script types.", + chart: { + title: "Funded addresses", + unit: units.addresses, + defaultType: line, + series: fundedSeries, + }, + children: [ + { + title: "Activity", + description: + "Shows how addresses appear and participate in transactions over time. These charts focus on address movement and usage, not the current distribution of address balances.", + children: [ + { + title: "New", + description: + "Counts addresses that appear for the first time during each rolling window. A new address is not necessarily a new user, but it does show fresh address creation on-chain.", + chart: { + title: "New addresses", + unit: units.addresses, + defaultType: line, + series: newSeries, + }, + }, + { + title: "Change", + description: + "Shows the rolling net change in funded address count. The count rises when more addresses receive a non-zero balance, and falls when more addresses are emptied.", + chart: { + title: "Funded address count change", + unit: units.addresses, + defaultType: line, + series: changeSeries, + }, + }, + { + title: "Growth Rate", + description: + "Shows the rolling percentage change of funded address count. It measures the same expansion or contraction as Change, but normalizes it by the size of the funded address set.", + chart: { + title: "Funded address growth rate", + unit: units.percent, + defaultType: line, + series: growthRateSeries, + }, + }, + { + title: "Active", + description: + "Counts addresses that are active during each rolling window. Active addresses can send, receive, do both, or return after inactivity.", + chart: { + title: "Active addresses", + unit: units.addresses, + defaultType: line, + series: activeSeries, + }, + children: [ + { + title: "Sending", + description: + "Counts addresses that spend from at least one output during each rolling window. This shows address-side transaction participation from senders.", + chart: { + title: "Sending addresses", + unit: units.addresses, + defaultType: line, + series: sendingSeries, + }, + }, + { + title: "Receiving", + description: + "Counts addresses that receive at least one output during each rolling window. This shows address-side transaction participation from recipients.", + chart: { + title: "Receiving addresses", + unit: units.addresses, + defaultType: line, + series: receivingSeries, + }, + }, + { + title: "Bidirectional", + description: + "Counts addresses that both send and receive during each rolling window. This can highlight addresses with more two-sided transaction behavior.", + chart: { + title: "Bidirectional addresses", + unit: units.addresses, + defaultType: line, + series: bidirectionalSeries, + }, + }, + { + title: "Reactivated", + description: + "Counts addresses that become active again after a quiet period. This helps show when older address activity returns.", + chart: { + title: "Reactivated addresses", + unit: units.addresses, + defaultType: line, + series: reactivatedSeries, + }, + }, + ], + }, + ], + }, + { + title: "Distribution", + description: + "Shows how addresses are split across states, balances, and address types. These charts describe the current address set rather than how addresses are moving.", + children: [ + { + title: "State", + description: + "Splits addresses into funded, empty, and total counts. Funded addresses currently hold BTC; empty addresses have no current balance; total includes both.", + chart: { + title: "Address count by state", + unit: units.addresses, + defaultType: line, + series: stateSeries, + }, + }, + { + title: "Balance", + description: + "Groups funded addresses by the BTC amount held at each address. Addresses are not people or entities, but this still shows how address balances are distributed on-chain.", + chart: { + title: "Address count by balance", + unit: units.addresses, + series: balanceSeries, + }, + }, + { + title: "Type", + description: + "Groups funded addresses by address type. The type reflects the script format used by the address, such as legacy, SegWit, or Taproot.", + chart: { + title: "Funded address count by type", + unit: units.addresses, + series: typeSeries, + }, + }, + ], + }, + { + title: "Reuse", + description: + "Shows address patterns that can reduce privacy or reveal public-key information. These counts are address-level signals, not direct counts of people.", + chart: { + title: "Address count by reuse", + unit: units.addresses, + defaultType: line, + series: reuseSeries, + }, + }, + ], +}; diff --git a/website_next/learn/sections/capitalization.js b/website_next/learn/data/sections/capitalization.js similarity index 93% rename from website_next/learn/sections/capitalization.js rename to website_next/learn/data/sections/capitalization.js index 1e4285ddc..06c8c2b29 100644 --- a/website_next/learn/sections/capitalization.js +++ b/website_next/learn/data/sections/capitalization.js @@ -1,5 +1,5 @@ import { capitalizationSeries } from "../capitalization.js"; -import { units } from "../charts/units.js"; +import { units } from "../../charts/units.js"; import { marketCapSection } from "./capitalization/market.js"; import { realizedCapSection } from "./capitalization/realized.js"; diff --git a/website_next/learn/sections/capitalization/market.js b/website_next/learn/data/sections/capitalization/market.js similarity index 98% rename from website_next/learn/sections/capitalization/market.js rename to website_next/learn/data/sections/capitalization/market.js index da09d3992..5860cdb97 100644 --- a/website_next/learn/sections/capitalization/market.js +++ b/website_next/learn/data/sections/capitalization/market.js @@ -9,7 +9,7 @@ import { marketCapTypeSeries, marketCapUtxoBalanceSeries, } from "../../capitalization.js"; -import { units } from "../../charts/units.js"; +import { units } from "../../../charts/units.js"; export const marketCapSection = { title: "Market Cap", diff --git a/website_next/learn/sections/capitalization/realized.js b/website_next/learn/data/sections/capitalization/realized.js similarity index 98% rename from website_next/learn/sections/capitalization/realized.js rename to website_next/learn/data/sections/capitalization/realized.js index 278716cc0..44de80a9d 100644 --- a/website_next/learn/sections/capitalization/realized.js +++ b/website_next/learn/data/sections/capitalization/realized.js @@ -9,7 +9,7 @@ import { realizedCapTypeSeries, realizedCapUtxoBalanceSeries, } from "../../capitalization.js"; -import { units } from "../../charts/units.js"; +import { units } from "../../../charts/units.js"; export const realizedCapSection = { title: "Realized Cap", diff --git a/website_next/learn/sections/introduction.js b/website_next/learn/data/sections/introduction.js similarity index 100% rename from website_next/learn/sections/introduction.js rename to website_next/learn/data/sections/introduction.js diff --git a/website_next/learn/data/sections/mining-pools.js b/website_next/learn/data/sections/mining-pools.js new file mode 100644 index 000000000..539c53a35 --- /dev/null +++ b/website_next/learn/data/sections/mining-pools.js @@ -0,0 +1,151 @@ +import { + createMajorPoolBlocksMinedSeries, + createMajorPoolDominanceSeries, + createMajorPoolRewardsSeries, + createMinorPoolBlocksMinedSeries, + createMinorPoolDominanceSeries, + majorPools, + majorPoolBlocksMinedSeries, + majorPoolDominanceSeries, + majorPoolRewardsSeries, + minorPools, +} from "../mining-pools.js"; +import { units } from "../../charts/units.js"; + +const line = /** @type {const} */ ("line"); + +/** @param {string} name */ +function createPoolDescription(name) { + return `${name} is tracked from pool attribution in mined blocks. These charts show share of blocks, blocks found, and rewards where available. Pool attribution is useful for understanding block production, but it is not the same as knowing who owns the underlying mining hardware.`; +} + +/** + * @param {(typeof majorPools)[number]} item + */ +function createMajorPoolSection({ name, pool }) { + return { + title: name, + description: createPoolDescription(name), + children: [ + { + title: "Dominance", + description: + "Dominance is the pool's share of mined blocks over each rolling window. It is estimated from blocks attributed to the pool, so it is best read as block-production share.", + chart: { + title: `${name} dominance`, + unit: units.percent, + defaultType: line, + series: createMajorPoolDominanceSeries(pool), + }, + }, + { + title: "Blocks Mined", + description: + "Counts how many blocks were attributed to the pool in each rolling window. This is the raw activity behind the dominance percentage.", + chart: { + title: `${name} blocks mined`, + unit: units.blocks, + defaultType: line, + series: createMajorPoolBlocksMinedSeries(pool), + }, + }, + { + title: "Rewards", + description: + "Sums the BTC earned by blocks attributed to the pool. Rewards include both the block subsidy and transaction fees.", + chart: { + title: `${name} rewards`, + unit: units.btc, + defaultType: line, + series: createMajorPoolRewardsSeries(pool), + }, + }, + ], + }; +} + +/** + * @param {(typeof minorPools)[number]} item + */ +function createMinorPoolSection({ name, pool }) { + return { + title: name, + description: createPoolDescription(name), + children: [ + { + title: "Dominance", + description: + "Shows the pool's all-time share of mined blocks. Minor pools expose a smaller historical metric set, so rolling dominance is not shown here.", + chart: { + title: `${name} dominance`, + unit: units.percent, + defaultType: line, + series: createMinorPoolDominanceSeries(pool), + }, + }, + { + title: "Blocks Mined", + description: + "Counts how many blocks were attributed to the pool in each rolling window. For minor pools, this is usually the most useful activity view.", + chart: { + title: `${name} blocks mined`, + unit: units.blocks, + defaultType: line, + series: createMinorPoolBlocksMinedSeries(pool), + }, + }, + ], + }; +} + +export const miningPoolsSection = { + title: "Mining Pools", + description: + "Mining pools coordinate miners so they can find blocks more steadily and split rewards. Pool charts show which pools are producing blocks, how their share changes, and how much BTC is paid to pools that are large enough to track in detail.", + children: [ + { + title: "Major", + description: + "Major pools have enough historical activity to track dominance, blocks mined, and rewards. This makes them useful for studying mining concentration and how block production changes over time.", + children: [ + { + title: "Dominance", + description: + "Compares the rolling monthly block-production share of all major pools. This is the clearest overview of mining-pool concentration.", + chart: { + title: "Major pool dominance", + unit: units.percent, + series: majorPoolDominanceSeries, + }, + }, + { + title: "Blocks Mined", + description: + "Compares rolling monthly blocks mined by major pools. This shows the raw block counts behind dominance percentages.", + chart: { + title: "Major pool blocks mined", + unit: units.blocks, + series: majorPoolBlocksMinedSeries, + }, + }, + { + title: "Rewards", + description: + "Compares rolling monthly BTC rewards earned by major pools. Rewards include both subsidy and transaction fees.", + chart: { + title: "Major pool rewards", + unit: units.btc, + series: majorPoolRewardsSeries, + }, + }, + ...majorPools.map(createMajorPoolSection), + ], + }, + { + title: "Minor", + description: + "Minor pools are smaller or less persistent pools. They matter because the long tail shows how broad or narrow block production is beyond the largest names, even when each pool is individually small.", + children: minorPools.map(createMinorPoolSection), + }, + ], +}; diff --git a/website_next/learn/sections/supply.js b/website_next/learn/data/sections/supply.js similarity index 98% rename from website_next/learn/sections/supply.js rename to website_next/learn/data/sections/supply.js index 235a381ea..5509be702 100644 --- a/website_next/learn/sections/supply.js +++ b/website_next/learn/data/sections/supply.js @@ -13,7 +13,7 @@ import { circulatingSupplySeries, supplyProfitabilitySeries, } from "../supply.js"; -import { units } from "../charts/units.js"; +import { units } from "../../charts/units.js"; export const supplySection = { title: "Supply", diff --git a/website_next/learn/data/sections/utxo-set.js b/website_next/learn/data/sections/utxo-set.js new file mode 100644 index 000000000..b5e5525d1 --- /dev/null +++ b/website_next/learn/data/sections/utxo-set.js @@ -0,0 +1,222 @@ +import { + ageSeries, + balanceSeries, + changeSeries, + classSeries, + epochSeries, + growthRateSeries, + spendingRateAgeSeries, + spendingRateBalanceSeries, + spendingRateClassSeries, + spendingRateEpochSeries, + spendingRateSeries, + spendingRateTermSeries, + spendingRateTypeSeries, + spentSeries, + termSeries, + totalSeries, + typeSeries, +} from "../utxo-set.js"; +import { units } from "../../charts/units.js"; + +const line = /** @type {const} */ ("line"); + +export const utxoSetSection = { + title: "UTXO Set", + description: + "The UTXO set is the collection of all spendable bitcoin outputs that exist right now. Each UTXO is a separate coin fragment created by a transaction and later consumed when it is spent. Counting UTXOs shows how Bitcoin is split into pieces, which is different from counting how much BTC those pieces contain.", + chart: { + title: "UTXO set", + unit: units.utxos, + defaultType: line, + series: totalSeries, + }, + children: [ + { + title: "Activity", + description: + "Shows how the UTXO set changes as transactions create new outputs and consume old ones. These charts focus on movement and turnover, not the current composition of the set.", + children: [ + { + title: "Change", + description: + "Shows the rolling net change in the UTXO set. The count rises when transactions create more spendable outputs than they consume, and falls when spending consolidates many old outputs into fewer new ones.", + chart: { + title: "UTXO set change", + unit: units.utxos, + defaultType: line, + series: changeSeries, + }, + }, + { + title: "Growth Rate", + description: + "Shows the rolling percentage change of the UTXO set. It measures the same net expansion or contraction as Change, but normalizes it by the size of the set so different periods are easier to compare.", + chart: { + title: "UTXO set growth rate", + unit: units.percent, + defaultType: line, + series: growthRateSeries, + }, + }, + { + title: "Spent", + description: + "Counts how many UTXOs were spent during each rolling window. This measures how much of the existing set was consumed as transaction inputs, regardless of the BTC value inside those outputs.", + chart: { + title: "Spent UTXOs", + unit: units.utxos, + defaultType: line, + series: spentSeries, + }, + }, + { + title: "Spending Rate", + description: + "Shows how quickly the UTXO set is being consumed. Instead of counting spent outputs directly, it expresses spending as a rate, which makes busy and quiet periods easier to compare.", + chart: { + title: "UTXO set spending rate", + unit: units.percent, + defaultType: line, + series: spendingRateSeries, + }, + children: [ + { + title: "Term", + description: + "Splits spending rate between short-term and long-term holder cohorts. This shows whether recent or dormant UTXOs are being consumed faster.", + chart: { + title: "UTXO spending rate by term", + unit: units.percent, + defaultType: line, + series: spendingRateTermSeries, + }, + }, + { + title: "Age", + description: + "Groups spending rate by how long UTXOs have stayed unspent. This shows which age bands are turning over fastest.", + chart: { + title: "UTXO spending rate by age", + unit: units.percent, + defaultType: line, + series: spendingRateAgeSeries, + }, + }, + { + title: "Balance", + description: + "Groups spending rate by the BTC amount held in each UTXO. This shows whether small or large outputs are being consumed faster.", + chart: { + title: "UTXO spending rate by balance", + unit: units.percent, + defaultType: line, + series: spendingRateBalanceSeries, + }, + }, + { + title: "Type", + description: + "Groups spending rate by output type. This shows how quickly UTXOs from each script format are being consumed.", + chart: { + title: "UTXO spending rate by type", + unit: units.percent, + defaultType: line, + series: spendingRateTypeSeries, + }, + }, + { + title: "Epoch", + description: + "Groups spending rate by the halving epoch when coins were mined. This shows which issuance periods are turning over fastest.", + chart: { + title: "UTXO spending rate by epoch", + unit: units.percent, + defaultType: line, + series: spendingRateEpochSeries, + }, + }, + { + title: "Class", + description: + "Groups spending rate by the calendar year when coins were mined. This shows which issuance years are turning over fastest.", + chart: { + title: "UTXO spending rate by class", + unit: units.percent, + defaultType: line, + series: spendingRateClassSeries, + }, + }, + ], + }, + ], + }, + { + title: "Distribution", + description: + "Shows how the current UTXO set is split across different groups. These charts describe the composition of existing spendable outputs, not how quickly they are changing.", + children: [ + { + title: "Term", + description: + "Splits the UTXO set between short-term and long-term holder cohorts. This counts pieces, not BTC, so it shows whether recent and dormant supply is made of many small outputs or fewer larger ones.", + chart: { + title: "UTXO set by term", + unit: units.utxos, + series: termSeries, + }, + }, + { + title: "Age", + description: + "Groups UTXOs by how long they have stayed unspent. A young UTXO was created recently, while an old UTXO has survived many blocks without being consumed in a transaction.", + chart: { + title: "UTXO set by age", + unit: units.utxos, + series: ageSeries, + }, + }, + { + title: "Balance", + description: + "Groups UTXOs by the BTC amount held in each output. This shows the size distribution of spendable pieces, from tiny fragments to very large outputs.", + chart: { + title: "UTXO set by balance", + unit: units.utxos, + series: balanceSeries, + }, + }, + { + title: "Type", + description: + "Groups UTXOs by output type. The type is the script format that defines how the output can be spent, such as legacy, SegWit, or Taproot.", + chart: { + title: "UTXO set by type", + unit: units.utxos, + series: typeSeries, + }, + }, + { + title: "Epoch", + description: + "Groups UTXOs by the halving epoch when their coins were mined. This shows how many currently spendable pieces trace back to each issuance period.", + chart: { + title: "UTXO set by epoch", + unit: units.utxos, + series: epochSeries, + }, + }, + { + title: "Class", + description: + "Groups UTXOs by the calendar year when their coins were mined. This shows how the current set of spendable pieces is distributed across issuance years.", + chart: { + title: "UTXO set by class", + unit: units.utxos, + series: classSeries, + }, + }, + ], + }, + ], +}; diff --git a/website_next/learn/supply.js b/website_next/learn/data/supply.js similarity index 92% rename from website_next/learn/supply.js rename to website_next/learn/data/supply.js index df0d61aed..cf3cc7432 100644 --- a/website_next/learn/supply.js +++ b/website_next/learn/data/supply.js @@ -1,5 +1,5 @@ import { createCohortSeries } from "./cohort-series.js"; -import { colors } from "../utils/colors.js"; +import { colors } from "../../utils/colors.js"; export const circulatingSupplySeries = createCohortSeries([ { diff --git a/website_next/learn/data/utxo-set.js b/website_next/learn/data/utxo-set.js new file mode 100644 index 000000000..1504b12c9 --- /dev/null +++ b/website_next/learn/data/utxo-set.js @@ -0,0 +1,130 @@ +import { + createCohortSeries, + createCohortSeriesFromKeys, +} from "./cohort-series.js"; +import { + ageRanges, + amountRanges, + classes, + epochs, + spendableTypes, +} from "./groups.js"; +import { createRollingWindowSeries } from "./rolling-windows.js"; +import { colors } from "../../utils/colors.js"; + +export const totalSeries = createCohortSeries([ + { + label: "UTXOs", + color: colors.orange, + metric: (client) => client.series.cohorts.utxo.all.outputs.unspentCount.base, + }, +]); + +export const changeSeries = createRollingWindowSeries( + (key) => (client) => + client.series.cohorts.utxo.all.outputs.unspentCount.delta.absolute[key], +); + +export const growthRateSeries = createRollingWindowSeries( + (key) => (client) => + client.series.cohorts.utxo.all.outputs.unspentCount.delta.rate[key].percent, +); + +export const spentSeries = createRollingWindowSeries( + (key) => (client) => + client.series.cohorts.utxo.all.outputs.spentCount.sum[key], +); + +export const spendingRateSeries = createCohortSeries([ + { + label: "Spending rate", + color: colors.orange, + metric: (client) => client.series.cohorts.utxo.all.outputs.spendingRate, + }, +]); + +export const spendingRateTermSeries = createCohortSeries([ + { + label: "STH", + color: colors.yellow, + metric: (client) => client.series.cohorts.utxo.sth.outputs.spendingRate, + }, + { + label: "LTH", + color: colors.fuchsia, + metric: (client) => client.series.cohorts.utxo.lth.outputs.spendingRate, + }, +]); + +export const spendingRateAgeSeries = createCohortSeriesFromKeys( + ageRanges, + (key) => (client) => + client.series.cohorts.utxo.ageRange[key].outputs.spendingRate, +); + +export const spendingRateBalanceSeries = createCohortSeriesFromKeys( + amountRanges, + (key) => (client) => + client.series.cohorts.utxo.amountRange[key].outputs.spendingRate, +); + +export const spendingRateTypeSeries = createCohortSeriesFromKeys( + spendableTypes, + (key) => (client) => + client.series.cohorts.utxo.type[key].outputs.spendingRate, +); + +export const spendingRateEpochSeries = createCohortSeriesFromKeys( + epochs, + (key) => (client) => + client.series.cohorts.utxo.epoch[key].outputs.spendingRate, +); + +export const spendingRateClassSeries = createCohortSeriesFromKeys( + classes, + (key) => (client) => + client.series.cohorts.utxo.class[key].outputs.spendingRate, +); + +export const termSeries = createCohortSeries([ + { + label: "STH", + color: colors.yellow, + metric: (client) => client.series.cohorts.utxo.sth.outputs.unspentCount.base, + }, + { + label: "LTH", + color: colors.fuchsia, + metric: (client) => client.series.cohorts.utxo.lth.outputs.unspentCount.base, + }, +]); + +export const ageSeries = createCohortSeriesFromKeys( + ageRanges, + (key) => (client) => + client.series.cohorts.utxo.ageRange[key].outputs.unspentCount.base, +); + +export const balanceSeries = createCohortSeriesFromKeys( + amountRanges, + (key) => (client) => + client.series.cohorts.utxo.amountRange[key].outputs.unspentCount.base, +); + +export const typeSeries = createCohortSeriesFromKeys( + spendableTypes, + (key) => (client) => + client.series.cohorts.utxo.type[key].outputs.unspentCount.base, +); + +export const epochSeries = createCohortSeriesFromKeys( + epochs, + (key) => (client) => + client.series.cohorts.utxo.epoch[key].outputs.unspentCount.base, +); + +export const classSeries = createCohortSeriesFromKeys( + classes, + (key) => (client) => + client.series.cohorts.utxo.class[key].outputs.unspentCount.base, +); diff --git a/website_next/learn/index.js b/website_next/learn/index.js index e87ea9a10..58eeae5a3 100644 --- a/website_next/learn/index.js +++ b/website_next/learn/index.js @@ -1,5 +1,5 @@ import { createContents } from "./contents/index.js"; -import { sections } from "./data.js"; +import { sections } from "./data/index.js"; import { createChart as createDataChart } from "./charts/index.js"; import { initHashLinks } from "./hash-links.js"; import { initScrollSpy } from "./scroll-spy.js"; diff --git a/website_next/learn/style.css b/website_next/learn/style.css index cd4a7bed1..93fc0ae1d 100644 --- a/website_next/learn/style.css +++ b/website_next/learn/style.css @@ -11,6 +11,13 @@ main.learn { --detail-font-size: 1.5rem; --detail-padding-top: calc(var(--topic-sticky-size) + 0.75rem); --detail-padding-bottom: 0.375rem; + --detail-sticky-size: calc( + var(--detail-padding-top) + var(--detail-font-size) + + var(--detail-padding-bottom) + 1px + ); + --subtopic-font-size: 1rem; + --subtopic-padding-top: calc(var(--detail-sticky-size) + 0.375rem); + --subtopic-padding-bottom: 0.25rem; display: grid; grid-template-columns: 14rem minmax(0, 1fr); @@ -25,7 +32,7 @@ main.learn { content: ""; position: sticky; top: 0; - z-index: 4; + z-index: 9; display: block; height: var(--offset); margin-top: calc(-1 * var(--offset)); @@ -57,10 +64,6 @@ main.learn { padding: 0; border: 0; font-size: 4rem; - - a::before { - content: none; - } } > p { @@ -84,18 +87,86 @@ main.learn { > section > section > section { counter-increment: detail; + counter-reset: subtopic; scroll-margin-top: var(--offset); } - section[id] { - > h1, - > h2, - > h3 { + > section > section > section > section { + counter-increment: subtopic; + scroll-margin-top: var(--offset); + } + + section[id]:not([data-numbered="false"]) { + > :is(h1, h2, h3, h4) { position: sticky; top: var(--offset); line-height: 1; background: var(--black); + } + > h1 { + z-index: 8; + padding-bottom: var(--heading-padding-bottom); + border-bottom: 1px solid var(--gray); + font-size: 3rem; + + a::before { + content: counter(theme, upper-roman) ". "; + } + } + + > h2 { + z-index: 7; + padding-top: var(--topic-padding-top); + padding-bottom: var(--heading-padding-bottom); + border-bottom: 1px dashed var(--gray); + font-size: var(--topic-font-size); + + a::before { + content: counter(topic) ". "; + } + } + + > h3 { + z-index: 6; + padding-top: var(--detail-padding-top); + padding-bottom: var(--detail-padding-bottom); + border-bottom: 1px dotted var(--gray); + font-size: var(--detail-font-size); + + a::before { + content: counter(detail, lower-alpha) ". "; + } + } + + > h4 { + z-index: 5; + padding-top: var(--subtopic-padding-top); + padding-bottom: var(--subtopic-padding-bottom); + border-bottom: 1px dotted var(--gray); + font-size: var(--subtopic-font-size); + + a::before { + content: counter(subtopic, lower-alpha) ". "; + } + } + + > p { + margin-top: 1rem; + color: var(--dark-white); + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + } + + > figure { + margin-top: 2rem; + color: var(--gray); + font-size: var(--font-size-xs); + } + } + + section[id] { + > :is(h1, h2, h3, h4) { a { position: relative; display: inline-block; @@ -127,54 +198,6 @@ main.learn { } } } - - > h1 { - z-index: 3; - padding-bottom: var(--heading-padding-bottom); - border-bottom: 1px solid var(--gray); - font-size: 3rem; - - a::before { - content: counter(theme, upper-roman) ". "; - } - } - - > h2 { - z-index: 2; - padding-top: var(--topic-padding-top); - padding-bottom: var(--heading-padding-bottom); - border-bottom: 1px dashed var(--gray); - 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(--gray); - font-size: var(--detail-font-size); - - a::before { - content: counter(detail, lower-alpha) ". "; - } - } - - > p { - margin-top: 1rem; - color: var(--dark-white); - font-size: var(--font-size-sm); - line-height: var(--line-height-sm); - } - - > figure { - margin-top: 2rem; - color: var(--gray); - font-size: var(--font-size-xs); - } } } } diff --git a/website_next/utils/id.js b/website_next/utils/id.js index 9ac10c1a3..dc563240e 100644 --- a/website_next/utils/id.js +++ b/website_next/utils/id.js @@ -1,4 +1,7 @@ /** @param {string} value */ export function createId(value) { - return value.toLowerCase().replaceAll(" ", "-"); + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); }