website: redesign part 13

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