website: redesign part 11

This commit is contained in:
nym21
2026-06-06 22:29:33 +02:00
parent 66dc7cd8f5
commit 041c542046
49 changed files with 17275 additions and 193 deletions
+83
View File
@@ -0,0 +1,83 @@
import { createLinePathData, formatCoordinate } from "../path.js";
import { createSvgElement } from "../svg.js";
import { VIEWBOX_WIDTH } from "../viewbox.js";
import { createStackedSeries } from "../stacked/series.js";
/**
* @param {number} value
* @param {number} min
* @param {number} max
*/
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
/** @param {{ x: number, y0: number, y1: number }[]} points */
function getBarWidth(points) {
return points.length > 1 ? (VIEWBOX_WIDTH / (points.length - 1)) * 0.8 : 1;
}
/**
* @param {{ x: number, y0: number, y1: number }[]} points
* @param {number} width
*/
function createBarPathData(points, width) {
return points
.map(({ x, y0, y1 }) => {
const left = clamp(x - width / 2, 0, VIEWBOX_WIDTH - width);
const right = left + width;
const top = Math.min(y0, y1);
const bottom = Math.max(y0, y1);
return (
`M${formatCoordinate(left)} ${formatCoordinate(top)}` +
`H${formatCoordinate(right)}V${formatCoordinate(bottom)}` +
`H${formatCoordinate(left)}Z`
);
})
.join(" ");
}
/**
* @param {SVGGElement} group
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {SeriesHighlight} highlight
* @param {{ reversed: boolean }} options
*/
export function renderBarPlot(group, loadedSeries, height, highlight, options) {
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
loadedSeries,
height,
options.reversed,
);
for (const index of stackIndexes) {
const { color, points } = plottedSeries[index];
const path = createSvgElement("path");
path.dataset.chart = "bar";
path.dataset.series = index.toString();
path.style.setProperty("--color", color);
path.setAttribute("d", createBarPathData(points, getBarWidth(points)));
highlight.add(path, index);
group.append(path);
}
for (const index of lineIndexes) {
const { color, points } = plottedSeries[index];
const path = createSvgElement("path");
path.dataset.chart = "line";
path.dataset.series = index.toString();
path.style.setProperty("--color", color);
path.setAttribute("d", createLinePathData(points));
highlight.add(path, index);
group.append(path);
}
return plottedSeries;
}
/** @typedef {import("../highlight.js").SeriesHighlight} SeriesHighlight */
/** @typedef {import("../index.js").LoadedSeries} LoadedSeries */
+8
View File
@@ -0,0 +1,8 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="bar"] {
fill: var(--color, var(--orange));
stroke: none;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* @param {ChartSeries[]} series
* @returns {ChartSeries[]}
*/
export function createSeries(series) {
return series;
}
/**
* @param {ChartSeries} series
* @returns {ChartSeries}
*/
export function referenceLine(series) {
return { ...series, role: "line" };
}
/** @typedef {import("./index.js").ChartSeries} ChartSeries */
+43
View File
@@ -0,0 +1,43 @@
import { formatCoordinate } from "../path.js";
import { createSvgElement } from "../svg.js";
import { createLineSeries } from "../line/series.js";
const radius = 1;
/** @param {{ x: number, y: number }[]} points */
function createDotsPathData(points) {
return points
.map(
({ x, y }) =>
`M${formatCoordinate(x - radius)} ${formatCoordinate(y)}` +
`a${radius} ${radius} 0 1 0 ${radius * 2} 0` +
`a${radius} ${radius} 0 1 0 ${radius * -2} 0`,
)
.join(" ");
}
/**
* @param {SVGGElement} group
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {SeriesHighlight} highlight
*/
export function renderDotsPlot(group, loadedSeries, height, highlight) {
const plottedSeries = createLineSeries(loadedSeries, height);
plottedSeries.forEach(({ color, points }, index) => {
const path = createSvgElement("path");
path.dataset.chart = "dots";
path.dataset.series = index.toString();
path.style.setProperty("--color", color);
path.setAttribute("d", createDotsPathData(points));
highlight.add(path, index);
group.append(path);
});
return plottedSeries;
}
/** @typedef {import("../highlight.js").SeriesHighlight} SeriesHighlight */
/** @typedef {import("../index.js").LoadedSeries} LoadedSeries */
+8
View File
@@ -0,0 +1,8 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="dots"] {
fill: var(--color, var(--orange));
stroke: none;
}
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* @param {number} value
* @param {number} digits
*/
function formatNumber(value, digits) {
return value.toLocaleString("en-us", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
}
/** @param {number} value */
export function formatValue(value) {
const absolute = Math.abs(value);
if (absolute < 10) return formatNumber(value, 3);
if (absolute < 1_000) return formatNumber(value, 2);
if (absolute < 10_000) return formatNumber(value, 1);
if (absolute < 1_000_000) return formatNumber(value, 0);
if (absolute >= 1e27) return "Inf.";
const log = Math.floor(Math.log10(absolute) - 6);
const suffixes = ["M", "B", "T", "P", "E", "Z", "Y"];
const suffixIndex = Math.floor(log / 3);
const digits = 3 - (log % 3);
const scaled = value / (1_000_000 * 1_000 ** suffixIndex);
return `${formatNumber(scaled, digits)}${suffixes[suffixIndex]}`;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* @param {HTMLElement} target
* @param {() => void} onChange
*/
function listen(target, onChange) {
document.addEventListener("fullscreenchange", () => {
if (document.fullscreenElement === target || !document.fullscreenElement) {
onChange();
}
});
}
/** @param {HTMLElement} target */
export function createFullscreenButton(target) {
const button = document.createElement("button");
function update() {
const active = document.fullscreenElement === target;
button.textContent = active ? "Exit" : "Full";
button.setAttribute("aria-pressed", active.toString());
}
button.type = "button";
button.dataset.chart = "fullscreen";
button.addEventListener("click", () => {
if (document.fullscreenElement === target) {
void document.exitFullscreen();
} else {
void target.requestFullscreen();
}
});
listen(target, update);
update();
return button;
}
+102
View File
@@ -0,0 +1,102 @@
/** @returns {SeriesNode} */
function createSeriesNode() {
return [];
}
/**
* @param {HTMLElement[]} items
*/
export function createSeriesHighlight(items) {
const seriesNodes = items.map(createSeriesNode);
/** @param {number} index */
function scrollToItem(index) {
items[index].scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
}
/** @param {number} index */
function activate(index) {
for (const [itemIndex, item] of items.entries()) {
setActive(item, itemIndex === index);
}
seriesNodes.forEach((nodes, nodeIndex) => {
for (const node of nodes) {
setActive(node, nodeIndex === index);
}
});
}
function clear() {
for (const item of items) clearState(item);
for (const nodes of seriesNodes) {
for (const node of nodes) clearState(node);
}
}
items.forEach((item, index) => {
item.addEventListener("pointerenter", () => activate(index));
item.addEventListener("pointerleave", clear);
item.addEventListener("focus", () => activate(index));
item.addEventListener("blur", clear);
});
/**
* @param {SVGPathElement | SVGCircleElement} node
* @param {number} index
*/
function add(node, index) {
seriesNodes[index].push(node);
node.addEventListener("pointerenter", () => {
scrollToItem(index);
activate(index);
});
node.addEventListener("pointerleave", clear);
}
function clearNodes() {
clear();
for (const nodes of seriesNodes) {
nodes.length = 0;
}
}
return {
add,
clearNodes,
};
}
/**
* @param {HTMLElement | SVGElement} element
* @param {boolean} active
*/
function setActive(element, active) {
if (active) {
element.dataset.active = "";
delete element.dataset.muted;
} else {
delete element.dataset.active;
element.dataset.muted = "";
}
}
/** @param {HTMLElement | SVGElement} element */
function clearState(element) {
delete element.dataset.active;
delete element.dataset.muted;
}
/** @typedef {(SVGPathElement | SVGCircleElement)[]} SeriesNode */
/**
* @typedef {Object} SeriesHighlight
* @property {(node: SVGPathElement | SVGCircleElement, index: number) => void} add
* @property {() => void} clearNodes
*/
+268
View File
@@ -0,0 +1,268 @@
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 { createLegend } from "./legend.js";
import { renderLinePlot } from "./line/index.js";
import { createScrubber } from "./scrubber.js";
import { renderDotsPlot } from "./dots/index.js";
import { createSvgElement } from "./svg.js";
import { renderStackedPlot } from "./stacked/index.js";
import {
createTimeframeControl,
fetchTimeframe,
getDefaultTimeframe,
saveTimeframe,
} from "./timeframes.js";
import {
createViewControl,
getDefaultView,
saveView,
} from "./views.js";
import {
FALLBACK_VIEWBOX_HEIGHT,
getViewBoxHeight,
VIEWBOX_WIDTH,
} from "./viewbox.js";
/** @typedef {import("./legend.js").Readout} Readout */
/** @typedef {import("./timeframes.js").TimeframeValue} TimeframeValue */
/** @typedef {import("./views.js").ChartView} ChartView */
/**
* @param {ChartResult} result
* @returns {{ date: Date, value: number }[]}
*/
function createEntries(result) {
/** @type {{ date: Date, value: number }[]} */
const entries = [];
/** @type {number | undefined} */
let lastValue;
for (const [date, value] of result.dateEntries()) {
if (typeof value === "number" && Number.isFinite(value)) lastValue = value;
if (lastValue !== undefined) entries.push({ date, value: lastValue });
}
return entries;
}
/**
* @param {Chart} chart
* @param {TimeframeValue} timeframe
* @returns {Promise<LoadedSeries[]>}
*/
async function loadSeries(chart, timeframe) {
return Promise.all(
chart.series.map(async (item) => ({
series: item,
color: item.color(),
entries: createEntries(await fetchTimeframe(item.metric(brk), timeframe)),
})),
);
}
/** @param {Chart} chart */
function createLoadedSeriesCache(chart) {
/** @type {Map<TimeframeValue, Promise<LoadedSeries[]>>} */
const cache = new Map();
/** @param {TimeframeValue} timeframe */
return function getLoadedSeries(timeframe) {
let promise = cache.get(timeframe);
if (!promise) {
promise = loadSeries(chart, timeframe).catch((error) => {
cache.delete(timeframe);
throw error;
});
cache.set(timeframe, promise);
}
return promise;
};
}
/**
* @param {ChartView} view
* @param {SVGGElement} group
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {SeriesHighlight} highlight
*/
function renderPlot(view, group, loadedSeries, height, highlight) {
switch (view) {
case "line":
return renderLinePlot(group, loadedSeries, height, highlight);
case "bar":
case "bar-reversed":
return renderBarPlot(group, loadedSeries, height, highlight, {
reversed: view === "bar-reversed",
});
case "dots":
return renderDotsPlot(group, loadedSeries, height, highlight);
default:
return renderStackedPlot(group, loadedSeries, height, highlight, {
reversed: view === "stacked-reversed",
});
}
}
/**
* @param {SVGSVGElement} svg
* @param {Readout} readout
* @param {HTMLElement[]} items
* @param {HTMLElement} status
* @param {Chart} chart
* @param {() => ChartView} getView
* @param {() => TimeframeValue} getTimeframe
*/
function createChartRenderer(
svg,
readout,
items,
status,
chart,
getView,
getTimeframe,
) {
const group = createSvgElement("g");
const highlight = createSeriesHighlight(items);
const getLoadedSeries = createLoadedSeriesCache(chart);
/** @type {LoadedSeries[]} */
let loadedSeries = [];
/** @type {ReturnType<typeof createScrubber> | undefined} */
let scrubber;
let loadId = 0;
svg.append(group);
function renderCurrent() {
if (!loadedSeries.length) return;
const height = getViewBoxHeight(svg);
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${height}`);
group.replaceChildren();
highlight.clearNodes();
scrubber ??= createScrubber(svg, readout, highlight);
scrubber.setSeries(
renderPlot(getView(), group, loadedSeries, height, highlight),
height,
);
}
async function loadCurrent() {
const id = (loadId += 1);
svg.setAttribute("aria-busy", "true");
try {
const nextSeries = await getLoadedSeries(getTimeframe());
if (id !== loadId) return;
loadedSeries = nextSeries;
renderCurrent();
status.textContent = "";
} catch (error) {
if (id !== loadId) return;
console.error(error);
status.textContent = "Chart unavailable";
} finally {
if (id === loadId) svg.removeAttribute("aria-busy");
}
}
new ResizeObserver(renderCurrent).observe(svg);
return {
loadCurrent,
renderCurrent,
};
}
/** @param {Chart} chart */
export function createChart(chart) {
const figure = document.createElement("figure");
const svg = createSvgElement("svg");
const controls = document.createElement("footer");
const timeControls = document.createElement("div");
const status = document.createElement("p");
const chartKey = chart.title;
let currentTimeframe = getDefaultTimeframe(chartKey);
let currentView = getDefaultView(chartKey);
const { legend, items, readout } = createLegend(chart);
figure.dataset.chart = "series";
figure.dataset.timeframe = currentTimeframe;
figure.dataset.view = currentView;
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,
items,
status,
chart,
() => currentView,
() => currentTimeframe,
);
const viewControl = createViewControl(currentView, (view) => {
currentView = view;
saveView(chartKey, view);
figure.dataset.view = view;
renderer.renderCurrent();
});
const timeframeControl = createTimeframeControl(
currentTimeframe,
(timeframe) => {
currentTimeframe = timeframe;
saveTimeframe(chartKey, timeframe);
figure.dataset.timeframe = timeframe;
void renderer.loadCurrent();
},
);
timeControls.append(timeframeControl, createFullscreenButton(figure));
controls.append(viewControl, timeControls);
figure.append(legend, svg, controls, status);
onFirstIntersection(figure, () => void renderer.loadCurrent());
return figure;
}
/**
* @typedef {Object} Chart
* @property {string} title
* @property {ChartSeries[]} series
*/
/**
* @typedef {Object} ChartSeries
* @property {string} label
* @property {() => string} color
* @property {"line"} [role]
* @property {(client: typeof brk) => import("./timeframes.js").TimeframeMetric} metric
*/
/**
* @typedef {Object} ChartResult
* @property {() => Iterable<[Date, number | null | undefined]>} dateEntries
*/
/**
* @typedef {Object} LoadedSeries
* @property {ChartSeries} series
* @property {string} color
* @property {{ date: Date, value: number }[]} entries
*/
/** @typedef {import("./highlight.js").SeriesHighlight} SeriesHighlight */
+14
View File
@@ -0,0 +1,14 @@
/**
* @param {Element} element
* @param {() => void} callback
*/
export function onFirstIntersection(element, callback) {
const observer = new IntersectionObserver((entries) => {
if (!entries[0].isIntersecting) return;
observer.disconnect();
callback();
});
observer.observe(element);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* @param {Chart} chart
* @returns {{ legend: HTMLElement, items: HTMLElement[], readout: Readout }}
*/
export function createLegend(chart) {
const legend = document.createElement("figcaption");
const header = document.createElement("header");
const title = document.createElement("span");
const time = document.createElement("time");
const menu = document.createElement("menu");
const rows = chart.series.map((series) => {
const item = document.createElement("li");
const button = document.createElement("button");
const label = document.createElement("span");
const value = document.createElement("output");
button.type = "button";
button.style.setProperty("--color", series.color());
label.append(series.label);
button.append(label, value);
item.append(button);
menu.append(item);
return { button, value };
});
const items = rows.map(({ button }) => button);
title.append(chart.title);
header.append(title);
header.append(time);
legend.append(header, menu);
return { legend, items, readout: { time, rows } };
}
/**
* @typedef {Object} Readout
* @property {HTMLTimeElement} time
* @property {{ value: HTMLOutputElement }[]} rows
*/
/** @typedef {import("./index.js").Chart} Chart */
+29
View File
@@ -0,0 +1,29 @@
import { createLinePathData } from "../path.js";
import { createSvgElement } from "../svg.js";
import { createLineSeries } from "./series.js";
/**
* @param {SVGGElement} group
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {SeriesHighlight} highlight
*/
export function renderLinePlot(group, loadedSeries, height, highlight) {
const plottedSeries = createLineSeries(loadedSeries, height);
plottedSeries.forEach(({ color, points }, index) => {
const path = createSvgElement("path");
path.dataset.chart = "line";
path.dataset.series = index.toString();
path.style.setProperty("--color", color);
path.setAttribute("d", createLinePathData(points));
highlight.add(path, index);
group.append(path);
});
return plottedSeries;
}
/** @typedef {import("../highlight.js").SeriesHighlight} SeriesHighlight */
/** @typedef {import("../index.js").LoadedSeries} LoadedSeries */
+53
View File
@@ -0,0 +1,53 @@
import { VIEWBOX_WIDTH } from "../viewbox.js";
/** @param {LoadedSeries[]} series */
function createValueBounds(series) {
let min = Infinity;
let max = -Infinity;
for (const { entries } of series) {
for (const { value } of entries) {
min = Math.min(min, value);
max = Math.max(max, value);
}
}
return { min, max };
}
/**
* @param {{ date: Date, value: number }[]} entries
* @param {{ min: number, max: number }} bounds
* @param {number} height
*/
function createPoints(entries, bounds, height) {
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,
}));
}
/**
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
*/
export function createLineSeries(loadedSeries, height) {
const bounds = createValueBounds(loadedSeries);
return loadedSeries.map(({ series, color, entries }) => ({
series,
color,
points: createPoints(entries, bounds, height),
}));
}
/** @typedef {import("../index.js").LoadedSeries} LoadedSeries */
+12
View File
@@ -0,0 +1,12 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="line"] {
fill: none;
stroke: var(--color, var(--orange));
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
/** @param {number} value */
export function formatCoordinate(value) {
return value.toFixed(2);
}
/**
* @param {string} command
* @param {number} x
* @param {number} y
*/
function createPathCommand(command, x, y) {
return `${command}${formatCoordinate(x)} ${formatCoordinate(y)}`;
}
/** @param {{ x: number, y: number }[]} points */
export function createLinePathData(points) {
return points
.map(({ x, y }, index) => createPathCommand(index ? "L" : "M", x, y))
.join(" ");
}
/** @param {{ x: number, y0: number, y1: number }[]} points */
export function createAreaPathData(points) {
const commands = points.map(({ x, y1 }, index) =>
createPathCommand(index ? "L" : "M", x, y1),
);
for (let index = points.length - 1; index >= 0; index -= 1) {
const { x, y0 } = points[index];
commands.push(createPathCommand("L", x, y0));
}
return `${commands.join(" ")} Z`;
}
+38
View File
@@ -0,0 +1,38 @@
let groupId = 0;
/**
* @template {string} T
* @param {Object} args
* @param {string} args.legend
* @param {{ value: T, label: string }[]} args.options
* @param {T} args.currentValue
* @param {(value: T) => void} args.onChange
*/
export function createRadioGroup(args) {
const fieldset = document.createElement("fieldset");
const legend = document.createElement("legend");
const name = `chart-control-${(groupId += 1)}`;
legend.append(args.legend);
fieldset.append(legend);
for (const option of args.options) {
const label = document.createElement("label");
const input = document.createElement("input");
const text = document.createElement("span");
input.type = "radio";
input.name = name;
input.value = option.value;
input.checked = option.value === args.currentValue;
input.addEventListener("change", () => {
if (input.checked) args.onChange(option.value);
});
text.append(option.label);
label.append(input, text);
fieldset.append(label);
}
return fieldset;
}
+163
View File
@@ -0,0 +1,163 @@
import { formatValue } from "./format.js";
import { createSvgElement } from "./svg.js";
import { VIEWBOX_WIDTH } from "./viewbox.js";
/** @typedef {import("./highlight.js").SeriesHighlight} SeriesHighlight */
/** @typedef {import("./legend.js").Readout} Readout */
const dateFormat = new Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
/**
* @param {number} value
* @param {number} min
* @param {number} max
*/
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
/**
* @param {ScrubberSeries} series
* @param {number} ratio
*/
function getPointAtRatio(series, ratio) {
return series.points[Math.round(ratio * (series.points.length - 1))];
}
/**
* @param {HTMLTimeElement} time
* @param {Date} date
*/
function updateTime(time, date) {
time.textContent = dateFormat.format(date);
time.dateTime = date.toISOString().slice(0, 10);
}
/**
* @param {Readout} readout
* @param {ReturnType<typeof getPointAtRatio>[]} points
*/
function updateReadout(readout, points) {
updateTime(readout.time, points[0].date);
readout.rows.forEach(({ value }, index) => {
value.textContent = formatValue(points[index].value);
});
}
/**
* @param {SVGSVGElement} svg
* @param {Readout} readout
* @param {SeriesHighlight} highlight
*/
export function createScrubber(svg, readout, highlight) {
const group = createSvgElement("g");
const guide = createSvgElement("line");
/** @type {ScrubberSeries[]} */
let series = [];
/** @type {SVGCircleElement[]} */
let markers = [];
let height = 0;
let stepCount = 0;
group.dataset.scrubber = "root";
guide.dataset.scrubber = "guide";
group.append(guide);
svg.append(group);
/**
* @param {number} ratio
* @param {boolean} [scrubbing]
*/
function update(ratio, scrubbing = true) {
const nextRatio = clamp(ratio, 0, 1);
const points = series.map((item) => getPointAtRatio(item, nextRatio));
const x = points[0].x.toFixed(2);
svg.dataset.index = Math.round(nextRatio * stepCount).toString();
guide.setAttribute("x1", x);
guide.setAttribute("x2", x);
guide.setAttribute("y1", "0");
guide.setAttribute("y2", height.toString());
updateReadout(readout, points);
markers.forEach((marker, index) => {
const point = points[index];
marker.setAttribute("cx", point.x.toFixed(2));
marker.setAttribute("cy", point.y.toFixed(2));
});
if (scrubbing) {
svg.dataset.scrubbing = "true";
} else {
delete svg.dataset.scrubbing;
}
}
function hide() {
update(1, false);
}
/**
* @param {ScrubberSeries[]} nextSeries
* @param {number} nextHeight
*/
function setSeries(nextSeries, nextHeight) {
series = nextSeries;
height = nextHeight;
stepCount = Math.max(...series.map(({ points }) => points.length - 1));
markers = series.map(({ color }, index) => {
const marker = createSvgElement("circle");
marker.dataset.series = index.toString();
marker.dataset.scrubber = "marker";
marker.style.setProperty("--color", color);
marker.setAttribute("r", "3");
highlight.add(marker, index);
return marker;
});
group.replaceChildren(guide, ...markers);
update(1, false);
}
/** @param {PointerEvent} event */
function updateFromPointer(event) {
const { left, width } = svg.getBoundingClientRect();
const x = ((event.clientX - left) / width) * VIEWBOX_WIDTH;
update(x / VIEWBOX_WIDTH);
}
svg.addEventListener("pointermove", updateFromPointer);
svg.addEventListener("pointerleave", hide);
svg.addEventListener("focus", () => update(1));
svg.addEventListener("blur", hide);
svg.addEventListener("keydown", (event) => {
const current = Number(svg.dataset.index || stepCount);
if (event.key === "ArrowLeft") {
event.preventDefault();
update((current - 1) / stepCount);
}
if (event.key === "ArrowRight") {
event.preventDefault();
update((current + 1) / stepCount);
}
});
return { setSeries };
}
/**
* @typedef {Object} ScrubberSeries
* @property {string} color
* @property {{ date: Date, value: number, x: number, y: number }[]} points
*/
@@ -0,0 +1,53 @@
import { createAreaPathData, createLinePathData } from "../path.js";
import { createSvgElement } from "../svg.js";
import { createStackedSeries } from "./series.js";
/**
* @param {SVGGElement} group
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {SeriesHighlight} highlight
* @param {{ reversed: boolean }} options
*/
export function renderStackedPlot(
group,
loadedSeries,
height,
highlight,
options,
) {
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
loadedSeries,
height,
options.reversed,
);
for (const index of stackIndexes) {
const { color, points } = plottedSeries[index];
const path = createSvgElement("path");
path.dataset.chart = "stacked";
path.dataset.series = index.toString();
path.style.setProperty("--color", color);
path.setAttribute("d", createAreaPathData(points));
highlight.add(path, index);
group.append(path);
}
for (const index of lineIndexes) {
const { color, points } = plottedSeries[index];
const path = createSvgElement("path");
path.dataset.chart = "line";
path.dataset.series = index.toString();
path.style.setProperty("--color", color);
path.setAttribute("d", createLinePathData(points));
highlight.add(path, index);
group.append(path);
}
return plottedSeries;
}
/** @typedef {import("../highlight.js").SeriesHighlight} SeriesHighlight */
/** @typedef {import("../index.js").LoadedSeries} LoadedSeries */
+135
View File
@@ -0,0 +1,135 @@
import { VIEWBOX_WIDTH } from "../viewbox.js";
/**
* @param {LoadedSeries[]} series
* @param {number[]} stackIndexes
* @param {number[]} lineIndexes
*/
function createStackBounds(series, stackIndexes, lineIndexes) {
const length = series[0].entries.length;
let min = 0;
let max = 0;
for (let index = 0; index < length; index += 1) {
let negative = 0;
let positive = 0;
for (const seriesIndex of stackIndexes) {
const value = series[seriesIndex].entries[index].value;
if (value < 0) negative += value;
else positive += value;
}
min = Math.min(min, negative);
max = Math.max(max, positive);
for (const seriesIndex of lineIndexes) {
const value = series[seriesIndex].entries[index].value;
min = Math.min(min, value);
max = Math.max(max, 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;
}
/** @returns {StackedPoint[]} */
function createStackedPoints() {
return [];
}
/**
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {boolean} reversed
*/
export function createStackedSeries(loadedSeries, height, reversed) {
const indexes = loadedSeries.map((_, index) => index);
const lineIndexes = indexes.filter(
(index) => loadedSeries[index].series.role === "line",
);
const stackIndexes = indexes.filter(
(index) => loadedSeries[index].series.role !== "line",
);
const bounds = createStackBounds(loadedSeries, stackIndexes, lineIndexes);
const length = loadedSeries[0].entries.length;
const xScale = VIEWBOX_WIDTH / (length - 1);
const order = [...stackIndexes];
const plottedSeries = loadedSeries.map(({ series, color }) => ({
series,
color,
points: createStackedPoints(),
}));
if (reversed) order.reverse();
for (let index = 0; index < length; index += 1) {
let negative = 0;
let positive = 0;
const x = index * xScale;
for (const seriesIndex of order) {
const { date, value } = loadedSeries[seriesIndex].entries[index];
const start = value < 0 ? negative : positive;
const end = start + value;
if (value < 0) negative = end;
else positive = end;
plottedSeries[seriesIndex].points.push({
date,
value,
x,
y: scaleY(end, bounds, height),
y0: scaleY(start, bounds, height),
y1: scaleY(end, bounds, height),
});
}
for (const seriesIndex of lineIndexes) {
const { date, value } = loadedSeries[seriesIndex].entries[index];
const y = scaleY(value, bounds, height);
plottedSeries[seriesIndex].points.push({
date,
value,
x,
y,
y0: y,
y1: y,
});
}
}
return {
lineIndexes,
plottedSeries,
stackIndexes,
};
}
/** @typedef {import("../index.js").LoadedSeries} LoadedSeries */
/**
* @typedef {Object} StackedPoint
* @property {Date} date
* @property {number} value
* @property {number} x
* @property {number} y
* @property {number} y0
* @property {number} y1
*/
@@ -0,0 +1,11 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="stacked"] {
fill: var(--color, var(--orange));
stroke: var(--black);
stroke-linejoin: round;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
}
}
+19
View File
@@ -0,0 +1,19 @@
/** @param {string} name */
export function createChartStorage(name) {
const prefix = `bitview:chart-${name}`;
return {
/** @param {string} chartKey */
get(chartKey) {
return localStorage.getItem(`${prefix}:${chartKey}`);
},
/**
* @param {string} chartKey
* @param {string} value
*/
set(chartKey, value) {
localStorage.setItem(`${prefix}:${chartKey}`, value);
},
};
}
+258
View File
@@ -0,0 +1,258 @@
main.learn {
figure[data-chart="series"] {
line-height: 1;
svg {
display: block;
width: 100%;
height: 20rem;
cursor: crosshair;
overflow: visible;
touch-action: pan-y;
}
svg:focus-visible {
outline: 1px solid var(--orange);
outline-offset: 0.5rem;
}
p[role="status"]:empty {
display: none;
}
> footer {
display: flex;
flex-wrap: wrap;
align-items: start;
justify-content: space-between;
gap: 0.5rem 1rem;
margin: 0.5rem 0 0;
> div {
display: flex;
flex-wrap: wrap;
gap: 0.125rem 0.5rem;
}
fieldset {
display: flex;
gap: 0.25rem;
margin: 0;
padding: 0;
border: 0;
text-transform: uppercase;
legend {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
label {
position: relative;
display: block;
cursor: pointer;
}
input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
span {
display: block;
padding: 0.25rem;
border-radius: 0.25rem;
color: var(--gray);
}
label:hover span {
color: var(--white);
background: var(--dark-gray);
}
label:has(:checked) span {
color: var(--black);
background: var(--white);
}
label:active span {
color: var(--black);
background: var(--orange);
}
label:has(:focus-visible) span {
outline: 1px solid var(--orange);
outline-offset: 0.125rem;
}
}
button[data-chart="fullscreen"] {
padding: 0.25rem;
border: 0;
border-radius: 0.25rem;
color: var(--gray);
background: none;
font: inherit;
line-height: inherit;
text-transform: uppercase;
cursor: pointer;
&:hover {
color: var(--white);
background: var(--dark-gray);
}
&[aria-pressed="true"] {
color: var(--black);
background: var(--green);
}
&:active {
color: var(--black);
background: var(--orange);
}
&:focus-visible {
outline: 1px solid var(--orange);
outline-offset: 0.125rem;
}
}
}
&:fullscreen {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: var(--black);
svg {
flex: 1;
height: auto;
min-height: 0;
}
figcaption menu {
padding-bottom: 0.5rem;
}
}
figcaption {
text-transform: uppercase;
header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
}
time {
color: var(--off-color);
}
menu {
display: flex;
gap: 0.5rem;
padding: 0;
padding-bottom: 1rem;
overflow-x: auto;
scrollbar-width: thin;
list-style: none;
}
li {
flex: 0 0 auto;
}
button {
padding: 0.25rem;
border: 0;
border-radius: 0.25rem;
color: inherit;
background: none;
font: inherit;
text-align: inherit;
text-transform: inherit;
cursor: pointer;
&:is(:hover, :focus-visible, [data-active]) {
color: var(--black);
background: var(--color);
span,
output {
color: inherit;
}
}
&:focus-visible {
outline: 1px solid var(--orange);
outline-offset: 0.125rem;
}
&[data-muted] {
opacity: 0.35;
}
> span {
display: block;
color: var(--color);
text-align: left;
&::before {
content: "";
display: inline-block;
width: 0.5em;
height: 0.5em;
margin-right: 0.35em;
margin-bottom: 0.1rem;
border-radius: 50%;
background: currentColor;
}
}
> output {
display: block;
margin-top: 0.25rem;
color: var(--white);
font-variant-numeric: tabular-nums;
text-align: right;
}
}
}
svg [data-series][data-muted] {
opacity: 0.2;
}
[data-scrubber] {
opacity: 0;
pointer-events: none;
}
svg[data-scrubbing="true"] [data-scrubber] {
opacity: 1;
}
[data-scrubber="guide"] {
stroke: var(--light-gray);
stroke-dasharray: 2 4;
vector-effect: non-scaling-stroke;
}
[data-scrubber="marker"] {
fill: var(--black);
stroke: var(--color, var(--orange));
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
const SVG_NS = "http://www.w3.org/2000/svg";
/**
* @template {keyof SVGElementTagNameMap} Name
* @param {Name} name
* @returns {SVGElementTagNameMap[Name]}
*/
export function createSvgElement(name) {
return document.createElementNS(SVG_NS, name);
}
+88
View File
@@ -0,0 +1,88 @@
import { createRadioGroup } from "./radio.js";
import { createChartStorage } from "./storage.js";
const storage = createChartStorage("timeframe");
/** @type {TimeframeValue} */
const defaultTimeframe = "all";
/** @type {Record<TimeframeValue, TimeframeConfig>} */
const timeframes = {
"1d": { index: "minute10", count: 144 },
"1w": { index: "hour1", count: 168 },
"1m": { index: "hour4", count: 186 },
"1y": { index: "day1", count: 366 },
"4y": { index: "day3", count: 488 },
"8y": { index: "week1", count: 418 },
all: { index: "week1" },
};
/** @type {{ value: TimeframeValue, label: string }[]} */
const options = [
{ value: "1d", label: "1d" },
{ value: "1w", label: "1w" },
{ value: "1m", label: "1m" },
{ value: "1y", label: "1y" },
{ value: "4y", label: "4y" },
{ value: "8y", label: "8y" },
{ value: "all", label: "all" },
];
/** @param {string} chartKey */
export function getDefaultTimeframe(chartKey) {
const value = storage.get(chartKey);
return (
options.find((timeframe) => timeframe.value === value)?.value ??
defaultTimeframe
);
}
/**
* @param {string} chartKey
* @param {TimeframeValue} timeframe
*/
export function saveTimeframe(chartKey, timeframe) {
storage.set(chartKey, timeframe);
}
/**
* @param {TimeframeValue} currentTimeframe
* @param {(timeframe: TimeframeValue) => void} onChange
*/
export function createTimeframeControl(currentTimeframe, onChange) {
return createRadioGroup({
legend: "Time",
options,
currentValue: currentTimeframe,
onChange,
});
}
/**
* @param {TimeframeMetric} metric
* @param {TimeframeValue} timeframe
*/
export function fetchTimeframe(metric, timeframe) {
const { count, index } = timeframes[timeframe];
const endpoint = metric.by[index];
return count ? endpoint.last(count).fetch() : endpoint.fetch();
}
/** @typedef {"1d" | "1w" | "1m" | "1y" | "4y" | "8y" | "all"} TimeframeValue */
/** @typedef {"minute10" | "hour1" | "hour4" | "day1" | "day3" | "week1"} TimeframeIndex */
/**
* @typedef {Object} TimeframeConfig
* @property {TimeframeIndex} index
* @property {number} [count]
*/
/**
* @typedef {Object} TimeframeEndpoint
* @property {() => Promise<import("./index.js").ChartResult>} fetch
* @property {(count: number) => { fetch: () => Promise<import("./index.js").ChartResult> }} last
*/
/**
* @typedef {Object} TimeframeMetric
* @property {Record<TimeframeIndex, TimeframeEndpoint>} by
*/
+11
View File
@@ -0,0 +1,11 @@
export const VIEWBOX_WIDTH = 640;
export const FALLBACK_VIEWBOX_HEIGHT = 220;
/** @param {SVGSVGElement} svg */
export function getViewBoxHeight(svg) {
const { width, height } = svg.getBoundingClientRect();
return width && height
? (VIEWBOX_WIDTH * height) / width
: FALLBACK_VIEWBOX_HEIGHT;
}
+45
View File
@@ -0,0 +1,45 @@
import { createRadioGroup } from "./radio.js";
import { createChartStorage } from "./storage.js";
const storage = createChartStorage("view");
/** @type {ChartView} */
const defaultView = "stacked";
/** @type {{ value: ChartView, label: string }[]} */
const views = [
{ value: "line", label: "Line" },
{ value: "stacked", label: "Stack↑" },
{ value: "stacked-reversed", label: "Stack↓" },
{ value: "bar", label: "Bars↑" },
{ value: "bar-reversed", label: "Bars↓" },
{ value: "dots", label: "Dots" },
];
/** @param {string} chartKey */
export function getDefaultView(chartKey) {
const value = storage.get(chartKey);
return views.find((view) => view.value === value)?.value ?? defaultView;
}
/**
* @param {string} chartKey
* @param {ChartView} view
*/
export function saveView(chartKey, view) {
storage.set(chartKey, view);
}
/**
* @param {ChartView} currentView
* @param {(view: ChartView) => void} onChange
*/
export function createViewControl(currentView, onChange) {
return createRadioGroup({
legend: "View",
options: views,
currentValue: currentView,
onChange,
});
}
/** @typedef {"line" | "stacked" | "stacked-reversed" | "bar" | "bar-reversed" | "dots"} ChartView */
+194
View File
@@ -0,0 +1,194 @@
import { createSeries } from "./charts/config.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",
color: colors.sky,
metric: (client) => client.series.cohorts.utxo.sth.supply.total.btc,
},
{
label: "LTH",
color: colors.orange,
metric: (client) => client.series.cohorts.utxo.lth.supply.total.btc,
},
]);
export const ageSeries = createCohortSeriesFromKeys(
ageRanges,
(key) => (client) =>
client.series.cohorts.utxo.ageRange[key].supply.total.btc,
);
export const utxoBalanceSeries = createCohortSeriesFromKeys(
amountRanges,
(key) => (client) =>
client.series.cohorts.utxo.amountRange[key].supply.total.btc,
);
export const addressBalanceSeries = createCohortSeriesFromKeys(
amountRanges,
(key) => (client) =>
client.series.cohorts.addr.amountRange[key].supply.total.btc,
);
export const typeSeries = createCohortSeriesFromKeys(
types,
(key) => (client) =>
key === "opReturn"
? client.series.outputs.value.opReturn.cumulative.btc
: client.series.cohorts.utxo.type[key].supply.total.btc,
);
export const epochSeries = createCohortSeriesFromKeys(
epochs,
(key) => (client) => client.series.cohorts.utxo.epoch[key].supply.total.btc,
);
export const classSeries = createCohortSeriesFromKeys(
classes,
(key) => (client) => client.series.cohorts.utxo.class[key].supply.total.btc,
);
+6 -6
View File
@@ -1,18 +1,18 @@
import { createId } from "../../utils/id.js";
/**
* @param {{ title: string, children: Section[] }} section
*/
/** @param {Section} section */
function createContentsItem(section) {
const item = document.createElement("li");
const anchor = document.createElement("a");
const children = section.children ?? [];
anchor.href = `#${createId(section.title)}`;
anchor.append(section.title);
if (section.children.length) {
if (children.length) {
const list = document.createElement("ol");
for (const child of section.children) {
for (const child of children) {
list.append(createContentsItem(child));
}
item.append(list);
@@ -40,5 +40,5 @@ export function createContents(sections) {
/**
* @typedef {Object} Section
* @property {string} title
* @property {Section[]} children
* @property {Section[]} [children]
*/
+6 -6
View File
@@ -25,7 +25,7 @@ main.learn {
}
ol ol {
margin-top: 0.5rem;
margin-top: 0.25rem;
margin-left: 1rem;
color: var(--gray);
}
@@ -35,7 +35,7 @@ main.learn {
}
li + li {
margin-top: 0.5rem;
margin-top: 0.25rem;
}
a {
@@ -44,16 +44,16 @@ main.learn {
color: inherit;
text-decoration: none;
margin-right: 1rem;
margin-block: -0.25rem;
margin-left: -0.5rem;
padding: 0.25rem;
padding-left: 0.5rem;
&::before {
opacity: 0.5;
}
&:is(:hover, :active) {
margin-block: -0.25rem;
margin-left: -0.5rem;
padding: 0.25rem;
padding-left: 0.5rem;
border-radius: 0.25rem;
}
+105 -37
View File
@@ -1,30 +1,124 @@
import {
addressBalanceSeries,
ageSeries,
classSeries,
epochSeries,
termSeries,
typeSeries,
utxoBalanceSeries,
} from "./cohorts.js";
import { colors } from "../utils/colors.js";
/** @param {typeof import("../utils/client.js").brk} client */
function metricCirculatingSupply(client) {
return client.series.supply.circulating.btc;
}
/** @param {typeof import("../utils/client.js").brk} client */
function metricSupplyInProfit(client) {
return client.series.cohorts.utxo.profitability.profit.all.supply.all.btc;
}
/** @param {typeof import("../utils/client.js").brk} client */
function metricSupplyInLoss(client) {
return client.series.cohorts.utxo.profitability.loss.all.supply.all.btc;
}
export const sections = [
{
title: "Supply",
description:
"How bitcoin moves from issuance into long-term ownership, profit, loss, and distribution.",
chart: "Circulating supply",
chart: {
title: "Circulating supply",
series: [
{
label: "Circulating",
color: colors.orange,
metric: metricCirculatingSupply,
},
],
},
children: [
{
title: "Profitability",
description:
"Which coins sit in profit or loss, and how that balance changes through cycles.",
chart: "Supply in profit",
children: [],
chart: {
title: "Profitability",
series: [
{
label: "In profit",
color: colors.green,
metric: metricSupplyInProfit,
},
{
label: "In loss",
color: colors.red,
metric: metricSupplyInLoss,
},
],
},
},
{
title: "Term",
description:
"Supply split between recently moved coins and long-term holder coins.",
chart: {
title: "Supply by term",
series: termSeries,
},
},
{
title: "Age",
description:
"How long coins have remained still, from fresh movement to deep dormancy.",
chart: "Supply by age",
children: [],
chart: {
title: "Supply by age",
series: ageSeries,
},
},
{
title: "Distribution",
title: "UTXO Balance",
description: "Supply grouped by the amount held in each unspent output.",
chart: {
title: "Supply by UTXO balance",
series: utxoBalanceSeries,
},
},
{
title: "Address Balance",
description: "Supply grouped by the balance held at each address.",
chart: {
title: "Supply by address balance",
series: addressBalanceSeries,
},
},
{
title: "Type",
description: "Supply grouped by output script type.",
chart: {
title: "Supply by type",
series: typeSeries,
},
},
{
title: "Epoch",
description:
"How supply is spread across addresses, scripts, cohorts, and balance ranges.",
chart: "Supply distribution",
children: [],
"Supply grouped by the halving epoch in which coins were created.",
chart: {
title: "Supply by epoch",
series: epochSeries,
},
},
{
title: "Class",
description:
"Supply grouped by the calendar year in which coins were created.",
chart: {
title: "Supply by class",
series: classSeries,
},
},
],
},
@@ -39,28 +133,26 @@ export const sections = [
description:
"The current market value of circulating bitcoin at spot price.",
chart: "Market capitalization",
children: [],
},
{
title: "Realized Cap",
description:
"The aggregate value of coins priced where they last moved on-chain.",
chart: "Realized capitalization",
children: [],
},
{
title: "Value Bands",
description:
"How market value compares with cost basis and historical valuation ranges.",
chart: "Valuation bands",
children: [],
},
],
},
{
title: "Activity",
description:
"How often the chain is used, how value moves, and how demand appears in fees and transactions.",
"How often the chain is used, how value moves, and how demand appears " +
"in fees and transactions.",
chart: "Network activity",
children: [
{
@@ -68,21 +160,18 @@ export const sections = [
description:
"Confirmed transaction count, throughput, and block-level settlement patterns.",
chart: "Transaction count",
children: [],
},
{
title: "Fees",
description:
"The cost users pay for block space and what that reveals about demand.",
chart: "Fee rate",
children: [],
},
{
title: "Addresses",
description:
"Address creation, reuse, activity, and balance changes across the network.",
chart: "Active addresses",
children: [],
},
],
},
@@ -97,21 +186,18 @@ export const sections = [
description:
"Estimated computational power securing the network over time.",
chart: "Hashrate",
children: [],
},
{
title: "Difficulty",
description:
"How Bitcoin adjusts mining difficulty to keep block production steady.",
chart: "Difficulty",
children: [],
},
{
title: "Rewards",
description:
"Subsidy, fees, and the changing economics of block production.",
chart: "Miner rewards",
children: [],
},
],
},
@@ -126,21 +212,18 @@ export const sections = [
description:
"Bitcoin price across time, cycles, drawdowns, and all-time highs.",
chart: "Price",
children: [],
},
{
title: "Returns",
description:
"How returns vary by holding period, entry point, and cycle phase.",
chart: "Returns",
children: [],
},
{
title: "Volatility",
description:
"The scale and rhythm of price movement across different windows.",
chart: "Volatility",
children: [],
},
],
},
@@ -155,21 +238,18 @@ export const sections = [
description:
"Address and entity balances grouped by size, concentration, and historical change.",
chart: "Balance cohorts",
children: [],
},
{
title: "Entities",
description:
"Estimated ownership clusters and how their behavior changes through market regimes.",
chart: "Entity supply",
children: [],
},
{
title: "Custody",
description:
"Coins associated with exchanges, funds, miners, and other observable custody groups.",
chart: "Custody balances",
children: [],
},
],
},
@@ -184,21 +264,18 @@ export const sections = [
description:
"Coins held by entities that tend to spend, trade, or redistribute frequently.",
chart: "Liquid supply",
children: [],
},
{
title: "Illiquid Supply",
description:
"Coins held by entities with low spending history and stronger accumulation behavior.",
chart: "Illiquid supply",
children: [],
},
{
title: "Exchange Flow",
description:
"Deposits, withdrawals, and balance changes across known exchange clusters.",
chart: "Exchange netflow",
children: [],
},
],
},
@@ -213,21 +290,18 @@ export const sections = [
description:
"Distance from prior highs and the depth of cycle retracements over time.",
chart: "Drawdown",
children: [],
},
{
title: "Stress",
description:
"Periods where losses, volatility, and fee pressure concentrate together.",
chart: "Network stress",
children: [],
},
{
title: "Leverage",
description:
"Market conditions that indicate amplified exposure and forced positioning risk.",
chart: "Leverage proxy",
children: [],
},
],
},
@@ -242,21 +316,18 @@ export const sections = [
description:
"Supply issuance changes and their relationship to market and miner behavior.",
chart: "Halving cycles",
children: [],
},
{
title: "Phases",
description:
"Bull, bear, recovery, and transition periods described through on-chain behavior.",
chart: "Cycle phases",
children: [],
},
{
title: "Comparisons",
description:
"Cycle-to-cycle comparisons normalized by time, price, drawdown, or supply behavior.",
chart: "Cycle comparison",
children: [],
},
],
},
@@ -271,21 +342,18 @@ export const sections = [
description:
"Recently moved coins and holders more sensitive to price, volatility, and liquidity.",
chart: "Short-term holder supply",
children: [],
},
{
title: "Long Term",
description:
"Older coins and holders with stronger dormancy, conviction, or lower spend frequency.",
chart: "Long-term holder supply",
children: [],
},
{
title: "Cost Basis",
description:
"Estimated acquisition prices across cohorts and how they frame profit and loss.",
chart: "Cohort cost basis",
children: [],
},
],
},
+56
View File
@@ -0,0 +1,56 @@
import { getEventAnchor, isPlainLeftClick } from "../utils/event.js";
/**
* @param {HTMLElement} main
* @param {string} hash
*/
function getHashTarget(main, hash) {
const target = document.getElementById(hash.slice(1));
return target && main.contains(target) ? target : null;
}
/**
* @param {Element} target
* @param {ScrollBehavior} behavior
*/
function scrollToTarget(target, behavior) {
target.scrollIntoView({ behavior, block: "start" });
}
/** @param {HTMLElement} main */
export function initHashLinks(main) {
const initialHash = window.location.hash;
main.addEventListener("click", (event) => {
if (!isPlainLeftClick(event)) return;
const anchor = getEventAnchor(event);
if (!anchor) return;
const url = new URL(anchor.href);
if (url.origin !== window.location.origin) return;
if (url.pathname !== window.location.pathname || !url.hash) return;
const target = getHashTarget(main, url.hash);
if (!target) return;
event.preventDefault();
scrollToTarget(target, "smooth");
if (url.hash !== window.location.hash) {
history.pushState(null, "", url.hash);
}
});
window.addEventListener("popstate", () => {
if (main.hidden) return;
const target = getHashTarget(main, window.location.hash);
if (target) scrollToTarget(target, "auto");
});
requestAnimationFrame(() => {
const target = getHashTarget(main, initialHash);
if (target) scrollToTarget(target, "auto");
});
}
+18 -12
View File
@@ -1,17 +1,21 @@
import { createContents } from "./contents/index.js";
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";
/** @param {string} label */
function createChart(label) {
/** @param {Section["chart"]} chart */
function createFigure(chart) {
if (typeof chart !== "string") return createDataChart(chart);
const figure = document.createElement("figure");
const chart = document.createElement("div");
const placeholder = document.createElement("div");
const caption = document.createElement("figcaption");
chart.append(label);
caption.append(label);
figure.append(chart, caption);
placeholder.append(chart);
caption.append(chart);
figure.append(placeholder, caption);
return figure;
}
@@ -22,19 +26,20 @@ function createChart(label) {
*/
function createSection(section, level = 1) {
const element = document.createElement("section");
const title = document.createElement(level === 1 ? "h1" : "h2");
const heading = document.createElement(level === 1 ? "h1" : "h2");
const anchor = document.createElement("a");
const description = document.createElement("p");
const children = section.children ?? [];
const id = createId(section.title);
element.id = id;
anchor.href = `#${id}`;
anchor.append(section.title);
title.append(anchor);
heading.append(anchor);
description.append(section.description);
element.append(title, description, createChart(section.chart));
element.append(heading, description, createFigure(section.chart));
for (const child of section.children) {
for (const child of children) {
element.append(createSection(child, level + 1));
}
@@ -51,6 +56,7 @@ export function createLearnPage() {
}
main.append(createContents(sections), article);
initHashLinks(main);
initScrollSpy(main);
return main;
}
@@ -59,6 +65,6 @@ export function createLearnPage() {
* @typedef {Object} Section
* @property {string} title
* @property {string} description
* @property {string} chart
* @property {Section[]} children
* @property {string | import("./charts/index.js").Chart} chart
* @property {Section[]} [children]
*/
+3 -3
View File
@@ -1,3 +1,5 @@
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 sections = [...main.querySelectorAll("section[id]")];
@@ -96,9 +98,7 @@ export function initScrollSpy(main) {
update();
},
{
threshold: [
0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1,
],
threshold: thresholds,
},
);
+6 -1
View File
@@ -112,13 +112,18 @@ main.learn {
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);
text-transform: uppercase;
&:not([data-chart]) {
text-transform: uppercase;
}
> div {
height: 18rem;