mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-10 06:53:33 -07:00
website: redesign part 13
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -40,5 +40,5 @@
|
||||
"url": "git+https://github.com/bitcoinresearchkit/brk.git"
|
||||
},
|
||||
"type": "module",
|
||||
"version": "0.3.2"
|
||||
"version": "0.3.3"
|
||||
}
|
||||
|
||||
@@ -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,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,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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createId } from "../utils/id.js";
|
||||
|
||||
/** @param {readonly string[]} path */
|
||||
export function createPathId(path) {
|
||||
return createId(path.join(" "));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user