website: redesign part 25

This commit is contained in:
nym21
2026-06-09 11:26:14 +02:00
parent b0b261fe9f
commit e54843291e
33 changed files with 1259 additions and 226 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
<meta name="referrer" content="no-referrer" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
content="width=device-width, initial-scale=1.0"
/>
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="mobile-web-app-capable" content="yes" />
+10 -1
View File
@@ -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)}%`;
}
+51 -20
View File
@@ -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;
+89 -76
View File
@@ -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<typeof createChartRenderer> | 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;
+14 -13
View File
@@ -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);
}
+2 -1
View File
@@ -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);
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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(),
+6 -5
View File
@@ -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<typeof getPointAtStep>[]} 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];
+13 -1
View File
@@ -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;
}
+8 -2
View File
@@ -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 */
+56 -34
View File
@@ -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) ". ";
}
}
}
}
}
}
}
}
}
}
}
+93
View File
@@ -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,
},
]);
@@ -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([
{
@@ -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 */
@@ -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([
{
@@ -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,
];
+108
View File
@@ -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<Key, Pool>} 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 */
@@ -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 */
@@ -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,
},
},
],
};
@@ -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";
@@ -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",
@@ -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",
@@ -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),
},
],
};
@@ -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",
@@ -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,
},
},
],
},
],
};
@@ -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([
{
+130
View File
@@ -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,
);
+1 -1
View File
@@ -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";
+80 -57
View File
@@ -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);
}
}
}
}
+4 -1
View File
@@ -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, "");
}