mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-09 01:58:14 -07:00
website: redesign part 13
This commit is contained in:
@@ -7579,7 +7579,7 @@ function createTransferPattern(client, acc) {
|
|||||||
* @extends BrkClientBase
|
* @extends BrkClientBase
|
||||||
*/
|
*/
|
||||||
class BrkClient extends BrkClientBase {
|
class BrkClient extends BrkClientBase {
|
||||||
VERSION = "v0.3.2";
|
VERSION = "v0.3.3";
|
||||||
|
|
||||||
INDEXES = /** @type {const} */ ([
|
INDEXES = /** @type {const} */ ([
|
||||||
"minute10",
|
"minute10",
|
||||||
|
|||||||
@@ -40,5 +40,5 @@
|
|||||||
"url": "git+https://github.com/bitcoinresearchkit/brk.git"
|
"url": "git+https://github.com/bitcoinresearchkit/brk.git"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.3.2"
|
"version": "0.3.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6724,7 +6724,7 @@ class SeriesTree:
|
|||||||
class BrkClient(BrkClientBase):
|
class BrkClient(BrkClientBase):
|
||||||
"""Main BRK client with series tree and API methods."""
|
"""Main BRK client with series tree and API methods."""
|
||||||
|
|
||||||
VERSION = "v0.3.2"
|
VERSION = "v0.3.3"
|
||||||
|
|
||||||
INDEXES = [
|
INDEXES = [
|
||||||
"minute10",
|
"minute10",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "brk-client"
|
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"
|
description = "Bitcoin on-chain analytics client — thousands of metrics, block explorer, and address index"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createCube } from "../cube/index.js";
|
import { createCube } from "../cube/index.js";
|
||||||
import { primaryRoutes } from "../routes.js";
|
|
||||||
|
|
||||||
export function createHeader() {
|
export function createHeader() {
|
||||||
const header = document.createElement("header");
|
const header = document.createElement("header");
|
||||||
@@ -12,21 +11,6 @@ export function createHeader() {
|
|||||||
cube.append(createCube());
|
cube.append(createCube());
|
||||||
home.append(cube, "bitview");
|
home.append(cube, "bitview");
|
||||||
|
|
||||||
const nav = document.createElement("nav");
|
header.append(home);
|
||||||
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);
|
|
||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
body {
|
body {
|
||||||
> header {
|
> header {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 1.5rem var(--page-x) auto;
|
top: 1.5rem;
|
||||||
|
left: var(--page-x);
|
||||||
z-index: var(--layer-header);
|
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;
|
line-height: 1;
|
||||||
text-transform: uppercase;
|
mix-blend-mode: difference;
|
||||||
|
|
||||||
> a {
|
> a {
|
||||||
--color: var(--white);
|
--color: var(--white);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
|
||||||
justify-self: start;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -45,44 +41,5 @@ body {
|
|||||||
animation: cube-fill 5s linear infinite alternate;
|
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() {
|
export function createHomePage() {
|
||||||
const main = document.createElement("main");
|
const main = document.createElement("main");
|
||||||
main.className = "home";
|
main.className = "home";
|
||||||
|
|
||||||
const title = document.createElement("h1");
|
const title = document.createElement("h1");
|
||||||
title.append("Home");
|
const nav = document.createElement("nav");
|
||||||
main.append(title);
|
|
||||||
|
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;
|
return main;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,39 @@
|
|||||||
main.home {
|
main.home {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
place-items: center;
|
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 {number} height
|
||||||
* @param {SeriesHighlight} highlight
|
* @param {SeriesHighlight} highlight
|
||||||
* @param {{ reversed: boolean }} options
|
* @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(
|
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
|
||||||
loadedSeries,
|
loadedSeries,
|
||||||
height,
|
height,
|
||||||
options.reversed,
|
options.reversed,
|
||||||
|
scale,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const index of stackIndexes) {
|
for (const index of stackIndexes) {
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ function createDotsPathData(points) {
|
|||||||
* @param {LoadedSeries[]} loadedSeries
|
* @param {LoadedSeries[]} loadedSeries
|
||||||
* @param {number} height
|
* @param {number} height
|
||||||
* @param {SeriesHighlight} highlight
|
* @param {SeriesHighlight} highlight
|
||||||
|
* @param {import("../scale.js").ChartScale} scale
|
||||||
*/
|
*/
|
||||||
export function renderDotsPlot(group, loadedSeries, height, highlight) {
|
export function renderDotsPlot(group, loadedSeries, height, highlight, scale) {
|
||||||
const plottedSeries = createLineSeries(loadedSeries, height);
|
const plottedSeries = createLineSeries(loadedSeries, height, scale);
|
||||||
|
|
||||||
plottedSeries.forEach(({ color, points }, index) => {
|
plottedSeries.forEach(({ color, points }, index) => {
|
||||||
const path = createSvgElement("path");
|
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) => {
|
items.forEach((item, index) => {
|
||||||
item.addEventListener("pointerenter", () => activate(index));
|
item.addEventListener("pointerenter", () => activate(index));
|
||||||
item.addEventListener("pointerleave", clear);
|
item.addEventListener("pointerleave", clear);
|
||||||
@@ -54,9 +64,9 @@ export function createSeriesHighlight(items) {
|
|||||||
seriesNodes[index].push(node);
|
seriesNodes[index].push(node);
|
||||||
node.addEventListener("pointerenter", () => {
|
node.addEventListener("pointerenter", () => {
|
||||||
scrollToItem(index);
|
scrollToItem(index);
|
||||||
activate(index);
|
activateItem(index);
|
||||||
});
|
});
|
||||||
node.addEventListener("pointerleave", clear);
|
node.addEventListener("pointerleave", () => clearItem(index));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearNodes() {
|
function clearNodes() {
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ import { brk } from "../../utils/client.js";
|
|||||||
import { renderBarPlot } from "./bar/index.js";
|
import { renderBarPlot } from "./bar/index.js";
|
||||||
import { createFullscreenButton } from "./fullscreen.js";
|
import { createFullscreenButton } from "./fullscreen.js";
|
||||||
import { createSeriesHighlight } from "./highlight.js";
|
import { createSeriesHighlight } from "./highlight.js";
|
||||||
import { onFirstIntersection } from "./intersection.js";
|
import { onChartVisibility } from "./intersection.js";
|
||||||
import { createLegend } from "./legend.js";
|
import { createLegend } from "./legend.js";
|
||||||
import { renderLinePlot } from "./line/index.js";
|
import { renderLinePlot } from "./line/index.js";
|
||||||
|
import {
|
||||||
|
createScaleControl,
|
||||||
|
getDefaultScale,
|
||||||
|
saveScale,
|
||||||
|
} from "./scale.js";
|
||||||
import { createScrubber } from "./scrubber.js";
|
import { createScrubber } from "./scrubber.js";
|
||||||
import { renderDotsPlot } from "./dots/index.js";
|
import { renderDotsPlot } from "./dots/index.js";
|
||||||
import { createSvgElement } from "./svg.js";
|
import { createSvgElement } from "./svg.js";
|
||||||
@@ -27,6 +32,7 @@ import {
|
|||||||
} from "./viewbox.js";
|
} from "./viewbox.js";
|
||||||
|
|
||||||
/** @typedef {import("./legend.js").Readout} Readout */
|
/** @typedef {import("./legend.js").Readout} Readout */
|
||||||
|
/** @typedef {import("./scale.js").ChartScale} ChartScale */
|
||||||
/** @typedef {import("./timeframes.js").TimeframeValue} TimeframeValue */
|
/** @typedef {import("./timeframes.js").TimeframeValue} TimeframeValue */
|
||||||
/** @typedef {import("./views.js").ChartView} ChartView */
|
/** @typedef {import("./views.js").ChartView} ChartView */
|
||||||
|
|
||||||
@@ -90,22 +96,33 @@ function createLoadedSeriesCache(chart) {
|
|||||||
* @param {LoadedSeries[]} loadedSeries
|
* @param {LoadedSeries[]} loadedSeries
|
||||||
* @param {number} height
|
* @param {number} height
|
||||||
* @param {SeriesHighlight} highlight
|
* @param {SeriesHighlight} highlight
|
||||||
|
* @param {ChartScale} scale
|
||||||
*/
|
*/
|
||||||
function renderPlot(view, group, loadedSeries, height, highlight) {
|
function renderPlot(view, group, loadedSeries, height, highlight, scale) {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "line":
|
case "line":
|
||||||
return renderLinePlot(group, loadedSeries, height, highlight);
|
return renderLinePlot(group, loadedSeries, height, highlight, scale);
|
||||||
case "bar":
|
case "bar":
|
||||||
case "bar-reversed":
|
case "bar-reversed":
|
||||||
return renderBarPlot(group, loadedSeries, height, highlight, {
|
return renderBarPlot(
|
||||||
reversed: view === "bar-reversed",
|
group,
|
||||||
});
|
loadedSeries,
|
||||||
|
height,
|
||||||
|
highlight,
|
||||||
|
{ reversed: view === "bar-reversed" },
|
||||||
|
scale,
|
||||||
|
);
|
||||||
case "dots":
|
case "dots":
|
||||||
return renderDotsPlot(group, loadedSeries, height, highlight);
|
return renderDotsPlot(group, loadedSeries, height, highlight, scale);
|
||||||
default:
|
default:
|
||||||
return renderStackedPlot(group, loadedSeries, height, highlight, {
|
return renderStackedPlot(
|
||||||
reversed: view === "stacked-reversed",
|
group,
|
||||||
});
|
loadedSeries,
|
||||||
|
height,
|
||||||
|
highlight,
|
||||||
|
{ reversed: view === "stacked-reversed" },
|
||||||
|
scale,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +133,7 @@ function renderPlot(view, group, loadedSeries, height, highlight) {
|
|||||||
* @param {HTMLElement} status
|
* @param {HTMLElement} status
|
||||||
* @param {Chart} chart
|
* @param {Chart} chart
|
||||||
* @param {() => ChartView} getView
|
* @param {() => ChartView} getView
|
||||||
|
* @param {() => ChartScale} getScale
|
||||||
* @param {() => TimeframeValue} getTimeframe
|
* @param {() => TimeframeValue} getTimeframe
|
||||||
*/
|
*/
|
||||||
function createChartRenderer(
|
function createChartRenderer(
|
||||||
@@ -125,6 +143,7 @@ function createChartRenderer(
|
|||||||
status,
|
status,
|
||||||
chart,
|
chart,
|
||||||
getView,
|
getView,
|
||||||
|
getScale,
|
||||||
getTimeframe,
|
getTimeframe,
|
||||||
) {
|
) {
|
||||||
const group = createSvgElement("g");
|
const group = createSvgElement("g");
|
||||||
@@ -134,12 +153,14 @@ function createChartRenderer(
|
|||||||
let loadedSeries = [];
|
let loadedSeries = [];
|
||||||
/** @type {ReturnType<typeof createScrubber> | undefined} */
|
/** @type {ReturnType<typeof createScrubber> | undefined} */
|
||||||
let scrubber;
|
let scrubber;
|
||||||
|
const resizeObserver = new ResizeObserver(renderCurrent);
|
||||||
|
let active = false;
|
||||||
let loadId = 0;
|
let loadId = 0;
|
||||||
|
|
||||||
svg.append(group);
|
svg.append(group);
|
||||||
|
|
||||||
function renderCurrent() {
|
function renderCurrent() {
|
||||||
if (!loadedSeries.length) return;
|
if (!active || !loadedSeries.length) return;
|
||||||
|
|
||||||
const height = getViewBoxHeight(svg);
|
const height = getViewBoxHeight(svg);
|
||||||
|
|
||||||
@@ -148,7 +169,7 @@ function createChartRenderer(
|
|||||||
highlight.clearNodes();
|
highlight.clearNodes();
|
||||||
scrubber ??= createScrubber(svg, readout, highlight);
|
scrubber ??= createScrubber(svg, readout, highlight);
|
||||||
scrubber.setSeries(
|
scrubber.setSeries(
|
||||||
renderPlot(getView(), group, loadedSeries, height, highlight),
|
renderPlot(getView(), group, loadedSeries, height, highlight, getScale()),
|
||||||
height,
|
height,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -160,7 +181,7 @@ function createChartRenderer(
|
|||||||
try {
|
try {
|
||||||
const nextSeries = await getLoadedSeries(getTimeframe());
|
const nextSeries = await getLoadedSeries(getTimeframe());
|
||||||
|
|
||||||
if (id !== loadId) return;
|
if (id !== loadId || !active) return;
|
||||||
|
|
||||||
loadedSeries = nextSeries;
|
loadedSeries = nextSeries;
|
||||||
renderCurrent();
|
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 {
|
return {
|
||||||
loadCurrent,
|
loadCurrent,
|
||||||
renderCurrent,
|
renderCurrent,
|
||||||
|
resume,
|
||||||
|
suspend,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,16 +228,19 @@ export function createChart(chart) {
|
|||||||
const figure = document.createElement("figure");
|
const figure = document.createElement("figure");
|
||||||
const svg = createSvgElement("svg");
|
const svg = createSvgElement("svg");
|
||||||
const controls = document.createElement("footer");
|
const controls = document.createElement("footer");
|
||||||
|
const chartControls = document.createElement("div");
|
||||||
const timeControls = document.createElement("div");
|
const timeControls = document.createElement("div");
|
||||||
const status = document.createElement("p");
|
const status = document.createElement("p");
|
||||||
const chartKey = chart.title;
|
const chartKey = chart.title;
|
||||||
let currentTimeframe = getDefaultTimeframe(chartKey);
|
let currentTimeframe = getDefaultTimeframe(chartKey);
|
||||||
let currentView = getDefaultView(chartKey);
|
let currentView = getDefaultView(chartKey);
|
||||||
|
let currentScale = getDefaultScale(chartKey);
|
||||||
const { legend, items, readout } = createLegend(chart);
|
const { legend, items, readout } = createLegend(chart);
|
||||||
|
|
||||||
figure.dataset.chart = "series";
|
figure.dataset.chart = "series";
|
||||||
figure.dataset.timeframe = currentTimeframe;
|
figure.dataset.timeframe = currentTimeframe;
|
||||||
figure.dataset.view = currentView;
|
figure.dataset.view = currentView;
|
||||||
|
figure.dataset.scale = currentScale;
|
||||||
svg.setAttribute(
|
svg.setAttribute(
|
||||||
"viewBox",
|
"viewBox",
|
||||||
`0 0 ${VIEWBOX_WIDTH} ${FALLBACK_VIEWBOX_HEIGHT}`,
|
`0 0 ${VIEWBOX_WIDTH} ${FALLBACK_VIEWBOX_HEIGHT}`,
|
||||||
@@ -214,6 +258,7 @@ export function createChart(chart) {
|
|||||||
status,
|
status,
|
||||||
chart,
|
chart,
|
||||||
() => currentView,
|
() => currentView,
|
||||||
|
() => currentScale,
|
||||||
() => currentTimeframe,
|
() => currentTimeframe,
|
||||||
);
|
);
|
||||||
const viewControl = createViewControl(currentView, (view) => {
|
const viewControl = createViewControl(currentView, (view) => {
|
||||||
@@ -222,6 +267,12 @@ export function createChart(chart) {
|
|||||||
figure.dataset.view = view;
|
figure.dataset.view = view;
|
||||||
renderer.renderCurrent();
|
renderer.renderCurrent();
|
||||||
});
|
});
|
||||||
|
const scaleControl = createScaleControl(currentScale, (scale) => {
|
||||||
|
currentScale = scale;
|
||||||
|
saveScale(chartKey, scale);
|
||||||
|
figure.dataset.scale = scale;
|
||||||
|
renderer.renderCurrent();
|
||||||
|
});
|
||||||
const timeframeControl = createTimeframeControl(
|
const timeframeControl = createTimeframeControl(
|
||||||
currentTimeframe,
|
currentTimeframe,
|
||||||
(timeframe) => {
|
(timeframe) => {
|
||||||
@@ -231,10 +282,14 @@ export function createChart(chart) {
|
|||||||
void renderer.loadCurrent();
|
void renderer.loadCurrent();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
chartControls.append(viewControl, scaleControl);
|
||||||
timeControls.append(timeframeControl, createFullscreenButton(figure));
|
timeControls.append(timeframeControl, createFullscreenButton(figure));
|
||||||
controls.append(viewControl, timeControls);
|
controls.append(chartControls, timeControls);
|
||||||
figure.append(legend, svg, controls, status);
|
figure.append(legend, svg, controls, status);
|
||||||
onFirstIntersection(figure, () => void renderer.loadCurrent());
|
onChartVisibility(figure, {
|
||||||
|
show: renderer.resume,
|
||||||
|
hide: renderer.suspend,
|
||||||
|
});
|
||||||
|
|
||||||
return figure;
|
return figure;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* @param {Element} element
|
* @param {Element} element
|
||||||
* @param {() => void} callback
|
* @param {{ show: () => void, hide: () => void }} lifecycle
|
||||||
*/
|
*/
|
||||||
export function onFirstIntersection(element, callback) {
|
export function onChartVisibility(element, lifecycle) {
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver(
|
||||||
if (!entries[0].isIntersecting) return;
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
observer.disconnect();
|
lifecycle.show();
|
||||||
callback();
|
} else {
|
||||||
});
|
lifecycle.hide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: "800px 0px",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
observer.observe(element);
|
observer.observe(element);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { createLineSeries } from "./series.js";
|
|||||||
* @param {LoadedSeries[]} loadedSeries
|
* @param {LoadedSeries[]} loadedSeries
|
||||||
* @param {number} height
|
* @param {number} height
|
||||||
* @param {SeriesHighlight} highlight
|
* @param {SeriesHighlight} highlight
|
||||||
|
* @param {import("../scale.js").ChartScale} scale
|
||||||
*/
|
*/
|
||||||
export function renderLinePlot(group, loadedSeries, height, highlight) {
|
export function renderLinePlot(group, loadedSeries, height, highlight, scale) {
|
||||||
const plottedSeries = createLineSeries(loadedSeries, height);
|
const plottedSeries = createLineSeries(loadedSeries, height, scale);
|
||||||
|
|
||||||
plottedSeries.forEach(({ color, points }, index) => {
|
plottedSeries.forEach(({ color, points }, index) => {
|
||||||
const path = createSvgElement("path");
|
const path = createSvgElement("path");
|
||||||
|
|||||||
@@ -1,52 +1,52 @@
|
|||||||
import { VIEWBOX_WIDTH } from "../viewbox.js";
|
import { VIEWBOX_WIDTH } from "../viewbox.js";
|
||||||
|
import { scaleY } from "../scale.js";
|
||||||
|
|
||||||
/** @param {LoadedSeries[]} series */
|
/** @param {LoadedSeries[]} series */
|
||||||
function createValueBounds(series) {
|
function createValueBounds(series) {
|
||||||
let min = Infinity;
|
let min = Infinity;
|
||||||
let max = -Infinity;
|
let max = -Infinity;
|
||||||
|
let minPositive = Infinity;
|
||||||
|
|
||||||
for (const { entries } of series) {
|
for (const { entries } of series) {
|
||||||
for (const { value } of entries) {
|
for (const { value } of entries) {
|
||||||
min = Math.min(min, value);
|
min = Math.min(min, value);
|
||||||
max = Math.max(max, 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 {{ date: Date, value: number }[]} entries
|
||||||
* @param {{ min: number, max: number }} bounds
|
* @param {import("../scale.js").ScaleBounds} bounds
|
||||||
* @param {number} height
|
* @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 xScale = VIEWBOX_WIDTH / (entries.length - 1);
|
||||||
const yScale =
|
|
||||||
bounds.max === bounds.min ? 0 : height / (bounds.max - bounds.min);
|
|
||||||
|
|
||||||
return entries.map(({ date, value }, index) => ({
|
return entries.map(({ date, value }, index) => ({
|
||||||
date,
|
date,
|
||||||
value,
|
value,
|
||||||
x: index * xScale,
|
x: index * xScale,
|
||||||
y:
|
y: scaleY(value, bounds, height, scale),
|
||||||
bounds.max === bounds.min
|
|
||||||
? height / 2
|
|
||||||
: height - (value - bounds.min) * yScale,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {LoadedSeries[]} loadedSeries
|
* @param {LoadedSeries[]} loadedSeries
|
||||||
* @param {number} height
|
* @param {number} height
|
||||||
|
* @param {import("../scale.js").ChartScale} scale
|
||||||
*/
|
*/
|
||||||
export function createLineSeries(loadedSeries, height) {
|
export function createLineSeries(loadedSeries, height, scale) {
|
||||||
const bounds = createValueBounds(loadedSeries);
|
const bounds = createValueBounds(loadedSeries);
|
||||||
|
|
||||||
return loadedSeries.map(({ series, color, entries }) => ({
|
return loadedSeries.map(({ series, color, entries }) => ({
|
||||||
series,
|
series,
|
||||||
color,
|
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);
|
update(1, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
series = [];
|
||||||
|
markers = [];
|
||||||
|
group.replaceChildren(guide);
|
||||||
|
delete svg.dataset.index;
|
||||||
|
delete svg.dataset.scrubbing;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {ScrubberSeries[]} nextSeries
|
* @param {ScrubberSeries[]} nextSeries
|
||||||
* @param {number} nextHeight
|
* @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 {number} height
|
||||||
* @param {SeriesHighlight} highlight
|
* @param {SeriesHighlight} highlight
|
||||||
* @param {{ reversed: boolean }} options
|
* @param {{ reversed: boolean }} options
|
||||||
|
* @param {import("../scale.js").ChartScale} scale
|
||||||
*/
|
*/
|
||||||
export function renderStackedPlot(
|
export function renderStackedPlot(
|
||||||
group,
|
group,
|
||||||
@@ -15,11 +16,13 @@ export function renderStackedPlot(
|
|||||||
height,
|
height,
|
||||||
highlight,
|
highlight,
|
||||||
options,
|
options,
|
||||||
|
scale,
|
||||||
) {
|
) {
|
||||||
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
|
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
|
||||||
loadedSeries,
|
loadedSeries,
|
||||||
height,
|
height,
|
||||||
options.reversed,
|
options.reversed,
|
||||||
|
scale,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const index of stackIndexes) {
|
for (const index of stackIndexes) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { VIEWBOX_WIDTH } from "../viewbox.js";
|
import { VIEWBOX_WIDTH } from "../viewbox.js";
|
||||||
|
import { scaleY } from "../scale.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {LoadedSeries[]} series
|
* @param {LoadedSeries[]} series
|
||||||
@@ -9,6 +10,7 @@ function createStackBounds(series, stackIndexes, lineIndexes) {
|
|||||||
const length = series[0].entries.length;
|
const length = series[0].entries.length;
|
||||||
let min = 0;
|
let min = 0;
|
||||||
let max = 0;
|
let max = 0;
|
||||||
|
let minPositive = Infinity;
|
||||||
|
|
||||||
for (let index = 0; index < length; index += 1) {
|
for (let index = 0; index < length; index += 1) {
|
||||||
let negative = 0;
|
let negative = 0;
|
||||||
@@ -23,27 +25,18 @@ function createStackBounds(series, stackIndexes, lineIndexes) {
|
|||||||
|
|
||||||
min = Math.min(min, negative);
|
min = Math.min(min, negative);
|
||||||
max = Math.max(max, positive);
|
max = Math.max(max, positive);
|
||||||
|
if (positive > 0) minPositive = Math.min(minPositive, positive);
|
||||||
|
|
||||||
for (const seriesIndex of lineIndexes) {
|
for (const seriesIndex of lineIndexes) {
|
||||||
const value = series[seriesIndex].entries[index].value;
|
const value = series[seriesIndex].entries[index].value;
|
||||||
|
|
||||||
min = Math.min(min, value);
|
min = Math.min(min, value);
|
||||||
max = Math.max(max, value);
|
max = Math.max(max, value);
|
||||||
|
if (value > 0) minPositive = Math.min(minPositive, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { min, max };
|
return { min, max, minPositive };
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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[]} */
|
/** @returns {StackedPoint[]} */
|
||||||
@@ -55,8 +48,9 @@ function createStackedPoints() {
|
|||||||
* @param {LoadedSeries[]} loadedSeries
|
* @param {LoadedSeries[]} loadedSeries
|
||||||
* @param {number} height
|
* @param {number} height
|
||||||
* @param {boolean} reversed
|
* @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 indexes = loadedSeries.map((_, index) => index);
|
||||||
const lineIndexes = indexes.filter(
|
const lineIndexes = indexes.filter(
|
||||||
(index) => loadedSeries[index].series.role === "line",
|
(index) => loadedSeries[index].series.role === "line",
|
||||||
@@ -94,15 +88,15 @@ export function createStackedSeries(loadedSeries, height, reversed) {
|
|||||||
date,
|
date,
|
||||||
value,
|
value,
|
||||||
x,
|
x,
|
||||||
y: scaleY(end, bounds, height),
|
y: scaleY(end, bounds, height, scale),
|
||||||
y0: scaleY(start, bounds, height),
|
y0: scaleY(start, bounds, height, scale),
|
||||||
y1: scaleY(end, bounds, height),
|
y1: scaleY(end, bounds, height, scale),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const seriesIndex of lineIndexes) {
|
for (const seriesIndex of lineIndexes) {
|
||||||
const { date, value } = loadedSeries[seriesIndex].entries[index];
|
const { date, value } = loadedSeries[seriesIndex].entries[index];
|
||||||
const y = scaleY(value, bounds, height);
|
const y = scaleY(value, bounds, height, scale);
|
||||||
|
|
||||||
plottedSeries[seriesIndex].points.push({
|
plottedSeries[seriesIndex].points.push({
|
||||||
date,
|
date,
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ main.learn {
|
|||||||
> output {
|
> output {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
|
min-height: 1em;
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
text-align: right;
|
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";
|
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([
|
export const termSeries = createCohortSeries([
|
||||||
{
|
{
|
||||||
label: "STH",
|
label: "STH",
|
||||||
@@ -176,7 +43,7 @@ export const addressBalanceSeries = createCohortSeriesFromKeys(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const typeSeries = createCohortSeriesFromKeys(
|
export const typeSeries = createCohortSeriesFromKeys(
|
||||||
types,
|
outputTypes,
|
||||||
(key) => (client) =>
|
(key) => (client) =>
|
||||||
key === "opReturn"
|
key === "opReturn"
|
||||||
? client.series.outputs.value.opReturn.cumulative.btc
|
? 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 item = document.createElement("li");
|
||||||
const anchor = document.createElement("a");
|
const anchor = document.createElement("a");
|
||||||
const children = section.children ?? [];
|
const children = section.children ?? [];
|
||||||
|
const sectionPath = [...path, section.title];
|
||||||
|
|
||||||
anchor.href = `#${createId(section.title)}`;
|
anchor.href = `#${createPathId(sectionPath)}`;
|
||||||
anchor.append(section.title);
|
anchor.append(section.title);
|
||||||
|
|
||||||
if (children.length) {
|
if (children.length) {
|
||||||
const list = document.createElement("ol");
|
const list = document.createElement("ol");
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
list.append(createContentsItem(child));
|
list.append(createContentsItem(child, sectionPath));
|
||||||
}
|
}
|
||||||
item.append(list);
|
item.append(list);
|
||||||
}
|
}
|
||||||
@@ -30,7 +34,7 @@ export function createContents(sections) {
|
|||||||
nav.setAttribute("aria-label", "Learn contents");
|
nav.setAttribute("aria-label", "Learn contents");
|
||||||
|
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
list.append(createContentsItem(section));
|
list.append(createContentsItem(section, []));
|
||||||
}
|
}
|
||||||
|
|
||||||
nav.append(list);
|
nav.append(list);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
main.learn {
|
main.learn {
|
||||||
> nav {
|
> nav {
|
||||||
|
--nav-offset: calc(var(--offset) + 2rem);
|
||||||
|
|
||||||
counter-reset: content-theme;
|
counter-reset: content-theme;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
padding-block: var(--offset);
|
padding-block: var(--nav-offset) var(--offset);
|
||||||
max-height: 100dvh;
|
max-height: 100dvh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@@ -24,16 +26,21 @@ main.learn {
|
|||||||
counter-reset: content-topic;
|
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 {
|
ol ol {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
margin-left: 1rem;
|
margin-left: 1rem;
|
||||||
color: var(--gray);
|
color: var(--gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
ol ol > li {
|
|
||||||
counter-increment: content-topic;
|
|
||||||
}
|
|
||||||
|
|
||||||
li + li {
|
li + li {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
@@ -76,8 +83,12 @@ main.learn {
|
|||||||
content: counter(content-theme, upper-roman) ". ";
|
content: counter(content-theme, upper-roman) ". ";
|
||||||
}
|
}
|
||||||
|
|
||||||
ol ol > li > a::before {
|
> ol > li > ol > li > a::before {
|
||||||
content: counter(content-topic) ". ";
|
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 {
|
import {
|
||||||
addressBalanceSeries,
|
addressBalanceSeries,
|
||||||
ageSeries,
|
ageSeries,
|
||||||
@@ -126,25 +145,157 @@ export const sections = [
|
|||||||
title: "Capitalization",
|
title: "Capitalization",
|
||||||
description:
|
description:
|
||||||
"Different ways to value the network by market price, realized cost, and accumulated flows.",
|
"Different ways to value the network by market price, realized cost, and accumulated flows.",
|
||||||
chart: "Capitalization overview",
|
chart: {
|
||||||
|
title: "Capitalization",
|
||||||
|
series: capitalizationSeries,
|
||||||
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
title: "Market Cap",
|
title: "Market Cap",
|
||||||
description:
|
description:
|
||||||
"The current market value of circulating bitcoin at spot price.",
|
"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",
|
title: "Realized Cap",
|
||||||
description:
|
description:
|
||||||
"The aggregate value of coins priced where they last moved on-chain.",
|
"The aggregate value of coins priced where they last moved on-chain.",
|
||||||
chart: "Realized capitalization",
|
chart: {
|
||||||
},
|
title: "Realized cap",
|
||||||
{
|
series: realizedCapSeries,
|
||||||
title: "Value Bands",
|
},
|
||||||
description:
|
children: [
|
||||||
"How market value compares with cost basis and historical valuation ranges.",
|
{
|
||||||
chart: "Valuation bands",
|
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 { createChart as createDataChart } from "./charts/index.js";
|
||||||
import { initHashLinks } from "./hash-links.js";
|
import { initHashLinks } from "./hash-links.js";
|
||||||
import { initScrollSpy } from "./scroll-spy.js";
|
import { initScrollSpy } from "./scroll-spy.js";
|
||||||
import { createId } from "../utils/id.js";
|
import { createPathId } from "./path.js";
|
||||||
|
|
||||||
/** @param {Section["chart"]} chart */
|
/** @param {Section["chart"]} chart */
|
||||||
function createFigure(chart) {
|
function createFigure(chart) {
|
||||||
@@ -22,15 +22,17 @@ function createFigure(chart) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Section} section
|
* @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 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 anchor = document.createElement("a");
|
||||||
const description = document.createElement("p");
|
const description = document.createElement("p");
|
||||||
const children = section.children ?? [];
|
const children = section.children ?? [];
|
||||||
const id = createId(section.title);
|
const id = createPathId(sectionPath);
|
||||||
|
|
||||||
element.id = id;
|
element.id = id;
|
||||||
anchor.href = `#${id}`;
|
anchor.href = `#${id}`;
|
||||||
@@ -40,7 +42,7 @@ function createSection(section, level = 1) {
|
|||||||
element.append(heading, description, createFigure(section.chart));
|
element.append(heading, description, createFigure(section.chart));
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
element.append(createSection(child, level + 1));
|
element.append(createSection(child, sectionPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
return element;
|
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 */
|
/** @param {HTMLElement} main */
|
||||||
export function initScrollSpy(main) {
|
export function initScrollSpy(main) {
|
||||||
|
const nav = /** @type {HTMLElement} */ (main.querySelector("nav"));
|
||||||
const sections = [...main.querySelectorAll("section[id]")];
|
const sections = [...main.querySelectorAll("section[id]")];
|
||||||
const sectionStates = sections.map((section) => ({
|
const sectionStates = sections.map((section) => ({
|
||||||
section,
|
section,
|
||||||
@@ -46,6 +47,23 @@ export function initScrollSpy(main) {
|
|||||||
return /** @type {HTMLAnchorElement} */ (links.get(hash));
|
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 */
|
/** @param {string} hash */
|
||||||
function setCurrentHash(hash) {
|
function setCurrentHash(hash) {
|
||||||
if (hash === current) return;
|
if (hash === current) return;
|
||||||
@@ -54,7 +72,7 @@ export function initScrollSpy(main) {
|
|||||||
|
|
||||||
const link = getLink(hash);
|
const link = getLink(hash);
|
||||||
link.setAttribute("aria-current", "location");
|
link.setAttribute("aria-current", "location");
|
||||||
link.scrollIntoView({ block: "nearest", inline: "nearest" });
|
scrollLinkIntoNav(link);
|
||||||
|
|
||||||
history.replaceState(null, "", hash);
|
history.replaceState(null, "", hash);
|
||||||
current = hash;
|
current = hash;
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
main.learn {
|
main.learn {
|
||||||
--offset: 6rem;
|
--offset: 4rem;
|
||||||
--content-width: 52rem;
|
--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;
|
display: grid;
|
||||||
grid-template-columns: 14rem minmax(0, 1fr);
|
grid-template-columns: 14rem minmax(0, 1fr);
|
||||||
@@ -15,7 +25,7 @@ main.learn {
|
|||||||
content: "";
|
content: "";
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 4;
|
||||||
display: block;
|
display: block;
|
||||||
height: var(--offset);
|
height: var(--offset);
|
||||||
margin-top: calc(-1 * var(--offset));
|
margin-top: calc(-1 * var(--offset));
|
||||||
@@ -42,18 +52,21 @@ main.learn {
|
|||||||
margin-top: 8rem;
|
margin-top: 8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
section section {
|
> section > section {
|
||||||
counter-increment: topic;
|
counter-increment: topic;
|
||||||
|
counter-reset: detail;
|
||||||
|
scroll-margin-top: var(--offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
> section > section > section {
|
||||||
|
counter-increment: detail;
|
||||||
scroll-margin-top: var(--offset);
|
scroll-margin-top: var(--offset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2 {
|
h2,
|
||||||
position: sticky;
|
h3 {
|
||||||
top: var(--offset);
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
background: var(--black);
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -88,10 +101,19 @@ main.learn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--offset);
|
||||||
|
background: var(--black);
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
padding-bottom: var(--heading-padding-bottom);
|
||||||
border-bottom: 1px solid var(--dark-gray);
|
border-bottom: 1px solid var(--dark-gray);
|
||||||
font-size: 2.75rem;
|
font-size: 3rem;
|
||||||
|
|
||||||
a::before {
|
a::before {
|
||||||
content: counter(theme, upper-roman) ". ";
|
content: counter(theme, upper-roman) ". ";
|
||||||
@@ -99,16 +121,29 @@ main.learn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
padding-top: 4.5rem;
|
padding-top: var(--topic-padding-top);
|
||||||
|
padding-bottom: var(--heading-padding-bottom);
|
||||||
border-bottom: 1px dashed var(--dark-gray);
|
border-bottom: 1px dashed var(--dark-gray);
|
||||||
font-size: 1.5rem;
|
font-size: var(--topic-font-size);
|
||||||
|
|
||||||
a::before {
|
a::before {
|
||||||
content: counter(topic) ". ";
|
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 {
|
p {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
color: var(--dark-white);
|
color: var(--dark-white);
|
||||||
|
|||||||
@@ -12,24 +12,6 @@ const pageByPath = new Map();
|
|||||||
const header = createHeader();
|
const header = createHeader();
|
||||||
document.body.append(header);
|
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 */
|
/** @param {string} pathname */
|
||||||
function getPage(pathname) {
|
function getPage(pathname) {
|
||||||
let page = pageByPath.get(pathname);
|
let page = pageByPath.get(pathname);
|
||||||
@@ -60,7 +42,6 @@ function activatePage(page) {
|
|||||||
function renderPage() {
|
function renderPage() {
|
||||||
const pathname = normalizePath(window.location.pathname);
|
const pathname = normalizePath(window.location.pathname);
|
||||||
activatePage(getPage(pathname));
|
activatePage(getPage(pathname));
|
||||||
updateCurrentLink(pathname);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {string} pathname */
|
/** @param {string} pathname */
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { createLearnPage } from "./learn/index.js";
|
|||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
{ pathname: "/", createPage: createHomePage },
|
{ pathname: "/", createPage: createHomePage },
|
||||||
{ pathname: "/explore", label: "Explore", createPage: createExplorePage },
|
{ pathname: "/explore", createPage: createExplorePage },
|
||||||
{ pathname: "/learn", label: "Learn", createPage: createLearnPage },
|
{ pathname: "/learn", createPage: createLearnPage },
|
||||||
{ pathname: "/build", label: "Build", createPage: createBuildPage },
|
{ pathname: "/build", createPage: createBuildPage },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @type {Record<string, () => HTMLElement>} */
|
/** @type {Record<string, () => HTMLElement>} */
|
||||||
@@ -15,10 +15,6 @@ const routes = Object.fromEntries(
|
|||||||
pages.map(({ pathname, createPage }) => [pathname, createPage]),
|
pages.map(({ pathname, createPage }) => [pathname, createPage]),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const primaryRoutes = pages.flatMap(({ pathname, label }) =>
|
|
||||||
label ? [{ pathname, label }] : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
/** @param {string} pathname */
|
/** @param {string} pathname */
|
||||||
export function isRoute(pathname) {
|
export function isRoute(pathname) {
|
||||||
return pathname in routes;
|
return pathname in routes;
|
||||||
|
|||||||
Reference in New Issue
Block a user