mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-23 04:36:11 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe8f095434 | |||
| 54cc0cb446 | |||
| d64dcb75a9 | |||
| f599115f6c | |||
| 9fc45625ad | |||
| c68d1d1fda |
@@ -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"
|
||||
|
||||
@@ -4,3 +4,10 @@
|
||||
*dump*
|
||||
TODO.md
|
||||
_explorer.js
|
||||
|
||||
# wrangler files
|
||||
.wrangler
|
||||
.dev.vars*
|
||||
!.dev.vars.example
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
@@ -21,6 +21,7 @@ ALWAYS
|
||||
- very well organized
|
||||
- contained
|
||||
- colocated
|
||||
- idiomatic
|
||||
- composed
|
||||
- prefer one concept per file
|
||||
- prefer more files and folders than big files
|
||||
@@ -28,3 +29,4 @@ ALWAYS
|
||||
- easy to understand
|
||||
- maintainability
|
||||
- avoid defensive checks when the code itself guarantees correctness
|
||||
- prefer "@type {const}" + infer to "@type {TYPE}" or "@type {import().type}"
|
||||
|
||||
@@ -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: 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,123 @@
|
||||
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,
|
||||
},
|
||||
]);
|
||||
|
||||
const [marketCap, realizedCap] = capitalizationSeries;
|
||||
|
||||
export const marketCapSeries = [marketCap];
|
||||
|
||||
export const realizedCapSeries = [realizedCap];
|
||||
|
||||
export const marketCapTermSeries = createCohortSeries([
|
||||
{
|
||||
label: "STH",
|
||||
color: colors.yellow,
|
||||
metric: (client) => client.series.cohorts.utxo.sth.supply.total.usd,
|
||||
},
|
||||
{
|
||||
label: "LTH",
|
||||
color: colors.fuchsia,
|
||||
metric: (client) => client.series.cohorts.utxo.lth.supply.total.usd,
|
||||
},
|
||||
]);
|
||||
|
||||
export const realizedCapTermSeries = createCohortSeries([
|
||||
{
|
||||
label: "STH",
|
||||
color: colors.yellow,
|
||||
metric: (client) => client.series.cohorts.utxo.sth.realized.cap.usd,
|
||||
},
|
||||
{
|
||||
label: "LTH",
|
||||
color: colors.fuchsia,
|
||||
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) {
|
||||
@@ -60,7 +69,7 @@ export function renderBarPlot(group, loadedSeries, height, highlight, options) {
|
||||
path.dataset.series = index.toString();
|
||||
path.style.setProperty("--color", color);
|
||||
path.setAttribute("d", createBarPathData(points, getBarWidth(points)));
|
||||
highlight.add(path, index);
|
||||
highlight.addNode(path, index);
|
||||
group.append(path);
|
||||
}
|
||||
|
||||
@@ -72,7 +81,7 @@ export function renderBarPlot(group, loadedSeries, height, highlight, options) {
|
||||
path.dataset.series = index.toString();
|
||||
path.style.setProperty("--color", color);
|
||||
path.setAttribute("d", createLinePathData(points));
|
||||
highlight.add(path, index);
|
||||
highlight.addNode(path, index);
|
||||
group.append(path);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* @param {ChartSeries[]} series
|
||||
* @returns {ChartSeries[]}
|
||||
*/
|
||||
export function createSeries(series) {
|
||||
return series;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ChartSeries} series
|
||||
* @returns {ChartSeries}
|
||||
*/
|
||||
export function referenceLine(series) {
|
||||
return { ...series, role: "line" };
|
||||
}
|
||||
|
||||
/** @typedef {import("./index.js").ChartSeries} ChartSeries */
|
||||
@@ -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");
|
||||
@@ -32,7 +33,7 @@ export function renderDotsPlot(group, loadedSeries, height, highlight) {
|
||||
path.dataset.series = index.toString();
|
||||
path.style.setProperty("--color", color);
|
||||
path.setAttribute("d", createDotsPathData(points));
|
||||
highlight.add(path, index);
|
||||
highlight.addNode(path, index);
|
||||
group.append(path);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const suffixes = ["M", "B", "T", "P", "E", "Z", "Y"];
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @param {number} digits
|
||||
@@ -11,6 +13,8 @@ function formatNumber(value, digits) {
|
||||
|
||||
/** @param {number} value */
|
||||
export function formatValue(value) {
|
||||
if (value === 0) return "0";
|
||||
|
||||
const absolute = Math.abs(value);
|
||||
|
||||
if (absolute < 10) return formatNumber(value, 3);
|
||||
@@ -20,7 +24,6 @@ export function formatValue(value) {
|
||||
if (absolute >= 1e27) return "Inf.";
|
||||
|
||||
const log = Math.floor(Math.log10(absolute) - 6);
|
||||
const suffixes = ["M", "B", "T", "P", "E", "Z", "Y"];
|
||||
const suffixIndex = Math.floor(log / 3);
|
||||
const digits = 3 - (log % 3);
|
||||
const scaled = value / (1_000_000 * 1_000 ** suffixIndex);
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
/**
|
||||
* @param {HTMLElement} target
|
||||
* @param {() => void} onChange
|
||||
*/
|
||||
function listen(target, onChange) {
|
||||
document.addEventListener("fullscreenchange", () => {
|
||||
if (document.fullscreenElement === target || !document.fullscreenElement) {
|
||||
onChange();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {HTMLElement} target */
|
||||
export function createFullscreenButton(target) {
|
||||
const button = document.createElement("button");
|
||||
@@ -30,7 +18,7 @@ export function createFullscreenButton(target) {
|
||||
void target.requestFullscreen();
|
||||
}
|
||||
});
|
||||
listen(target, update);
|
||||
target.addEventListener("fullscreenchange", update);
|
||||
update();
|
||||
|
||||
return button;
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
/** @returns {SeriesNode} */
|
||||
function createSeriesNode() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement[]} items
|
||||
* @param {HTMLElement} menu
|
||||
*/
|
||||
export function createSeriesHighlight(items) {
|
||||
const seriesNodes = items.map(createSeriesNode);
|
||||
export function createSeriesHighlight(items, menu) {
|
||||
const seriesNodes = /** @type {SeriesNode[]} */ (items.map(() => []));
|
||||
/** @type {number | undefined} */
|
||||
let previewIndex;
|
||||
|
||||
/** @param {number} index */
|
||||
function scrollToItem(index) {
|
||||
items[index].scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
const itemRect = items[index].getBoundingClientRect();
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
|
||||
if (itemRect.left < menuRect.left) {
|
||||
menu.scrollBy({
|
||||
left: itemRect.left - menuRect.left,
|
||||
behavior: "smooth",
|
||||
});
|
||||
} else if (itemRect.right > menuRect.right) {
|
||||
menu.scrollBy({
|
||||
left: itemRect.right - menuRect.right,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {number} index */
|
||||
@@ -37,6 +44,25 @@ export function createSeriesHighlight(items) {
|
||||
for (const nodes of seriesNodes) {
|
||||
for (const node of nodes) clearState(node);
|
||||
}
|
||||
|
||||
previewIndex = undefined;
|
||||
}
|
||||
|
||||
/** @param {number} index */
|
||||
function previewItem(index) {
|
||||
if (index === previewIndex) return;
|
||||
|
||||
clearPreview();
|
||||
scrollToItem(index);
|
||||
items[index].dataset.preview = "";
|
||||
previewIndex = index;
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
if (previewIndex === undefined) return;
|
||||
|
||||
delete items[previewIndex].dataset.preview;
|
||||
previewIndex = undefined;
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
@@ -50,13 +76,8 @@ export function createSeriesHighlight(items) {
|
||||
* @param {SVGPathElement | SVGCircleElement} node
|
||||
* @param {number} index
|
||||
*/
|
||||
function add(node, index) {
|
||||
function addNode(node, index) {
|
||||
seriesNodes[index].push(node);
|
||||
node.addEventListener("pointerenter", () => {
|
||||
scrollToItem(index);
|
||||
activate(index);
|
||||
});
|
||||
node.addEventListener("pointerleave", clear);
|
||||
}
|
||||
|
||||
function clearNodes() {
|
||||
@@ -68,8 +89,10 @@ export function createSeriesHighlight(items) {
|
||||
}
|
||||
|
||||
return {
|
||||
add,
|
||||
addNode,
|
||||
clearPreview,
|
||||
clearNodes,
|
||||
preview: previewItem,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,12 +114,15 @@ function setActive(element, active) {
|
||||
function clearState(element) {
|
||||
delete element.dataset.active;
|
||||
delete element.dataset.muted;
|
||||
delete element.dataset.preview;
|
||||
}
|
||||
|
||||
/** @typedef {(SVGPathElement | SVGCircleElement)[]} SeriesNode */
|
||||
|
||||
/**
|
||||
* @typedef {Object} SeriesHighlight
|
||||
* @property {(node: SVGPathElement | SVGCircleElement, index: number) => void} add
|
||||
* @property {(node: SVGPathElement | SVGCircleElement, index: number) => void} addNode
|
||||
* @property {() => void} clearPreview
|
||||
* @property {() => void} clearNodes
|
||||
* @property {(index: number) => void} preview
|
||||
*/
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
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 { createScrubber } from "./scrubber.js";
|
||||
import { renderDotsPlot } from "./dots/index.js";
|
||||
import { createChartRenderer } from "./renderer.js";
|
||||
import {
|
||||
createScaleControl,
|
||||
getDefaultScale,
|
||||
saveScale,
|
||||
} from "./scale.js";
|
||||
import { createSvgElement } from "./svg.js";
|
||||
import { renderStackedPlot } from "./stacked/index.js";
|
||||
import {
|
||||
createTimeframeControl,
|
||||
fetchTimeframe,
|
||||
getDefaultTimeframe,
|
||||
saveTimeframe,
|
||||
} from "./timeframes.js";
|
||||
@@ -20,183 +18,26 @@ import {
|
||||
getDefaultView,
|
||||
saveView,
|
||||
} from "./views.js";
|
||||
import {
|
||||
FALLBACK_VIEWBOX_HEIGHT,
|
||||
getViewBoxHeight,
|
||||
VIEWBOX_WIDTH,
|
||||
} from "./viewbox.js";
|
||||
|
||||
/** @typedef {import("./legend.js").Readout} Readout */
|
||||
/** @typedef {import("./timeframes.js").TimeframeValue} TimeframeValue */
|
||||
/** @typedef {import("./views.js").ChartView} ChartView */
|
||||
|
||||
/**
|
||||
* @param {ChartResult} result
|
||||
* @returns {{ date: Date, value: number }[]}
|
||||
*/
|
||||
function createEntries(result) {
|
||||
/** @type {{ date: Date, value: number }[]} */
|
||||
const entries = [];
|
||||
/** @type {number | undefined} */
|
||||
let lastValue;
|
||||
|
||||
for (const [date, value] of result.dateEntries()) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) lastValue = value;
|
||||
if (lastValue !== undefined) entries.push({ date, value: lastValue });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Chart} chart
|
||||
* @param {TimeframeValue} timeframe
|
||||
* @returns {Promise<LoadedSeries[]>}
|
||||
*/
|
||||
async function loadSeries(chart, timeframe) {
|
||||
return Promise.all(
|
||||
chart.series.map(async (item) => ({
|
||||
series: item,
|
||||
color: item.color(),
|
||||
entries: createEntries(await fetchTimeframe(item.metric(brk), timeframe)),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {Chart} chart */
|
||||
function createLoadedSeriesCache(chart) {
|
||||
/** @type {Map<TimeframeValue, Promise<LoadedSeries[]>>} */
|
||||
const cache = new Map();
|
||||
|
||||
/** @param {TimeframeValue} timeframe */
|
||||
return function getLoadedSeries(timeframe) {
|
||||
let promise = cache.get(timeframe);
|
||||
|
||||
if (!promise) {
|
||||
promise = loadSeries(chart, timeframe).catch((error) => {
|
||||
cache.delete(timeframe);
|
||||
throw error;
|
||||
});
|
||||
cache.set(timeframe, promise);
|
||||
}
|
||||
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ChartView} view
|
||||
* @param {SVGGElement} group
|
||||
* @param {LoadedSeries[]} loadedSeries
|
||||
* @param {number} height
|
||||
* @param {SeriesHighlight} highlight
|
||||
*/
|
||||
function renderPlot(view, group, loadedSeries, height, highlight) {
|
||||
switch (view) {
|
||||
case "line":
|
||||
return renderLinePlot(group, loadedSeries, height, highlight);
|
||||
case "bar":
|
||||
case "bar-reversed":
|
||||
return renderBarPlot(group, loadedSeries, height, highlight, {
|
||||
reversed: view === "bar-reversed",
|
||||
});
|
||||
case "dots":
|
||||
return renderDotsPlot(group, loadedSeries, height, highlight);
|
||||
default:
|
||||
return renderStackedPlot(group, loadedSeries, height, highlight, {
|
||||
reversed: view === "stacked-reversed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SVGSVGElement} svg
|
||||
* @param {Readout} readout
|
||||
* @param {HTMLElement[]} items
|
||||
* @param {HTMLElement} status
|
||||
* @param {Chart} chart
|
||||
* @param {() => ChartView} getView
|
||||
* @param {() => TimeframeValue} getTimeframe
|
||||
*/
|
||||
function createChartRenderer(
|
||||
svg,
|
||||
readout,
|
||||
items,
|
||||
status,
|
||||
chart,
|
||||
getView,
|
||||
getTimeframe,
|
||||
) {
|
||||
const group = createSvgElement("g");
|
||||
const highlight = createSeriesHighlight(items);
|
||||
const getLoadedSeries = createLoadedSeriesCache(chart);
|
||||
/** @type {LoadedSeries[]} */
|
||||
let loadedSeries = [];
|
||||
/** @type {ReturnType<typeof createScrubber> | undefined} */
|
||||
let scrubber;
|
||||
let loadId = 0;
|
||||
|
||||
svg.append(group);
|
||||
|
||||
function renderCurrent() {
|
||||
if (!loadedSeries.length) return;
|
||||
|
||||
const height = getViewBoxHeight(svg);
|
||||
|
||||
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${height}`);
|
||||
group.replaceChildren();
|
||||
highlight.clearNodes();
|
||||
scrubber ??= createScrubber(svg, readout, highlight);
|
||||
scrubber.setSeries(
|
||||
renderPlot(getView(), group, loadedSeries, height, highlight),
|
||||
height,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadCurrent() {
|
||||
const id = (loadId += 1);
|
||||
svg.setAttribute("aria-busy", "true");
|
||||
|
||||
try {
|
||||
const nextSeries = await getLoadedSeries(getTimeframe());
|
||||
|
||||
if (id !== loadId) return;
|
||||
|
||||
loadedSeries = nextSeries;
|
||||
renderCurrent();
|
||||
status.textContent = "";
|
||||
} catch (error) {
|
||||
if (id !== loadId) return;
|
||||
console.error(error);
|
||||
status.textContent = "Chart unavailable";
|
||||
} finally {
|
||||
if (id === loadId) svg.removeAttribute("aria-busy");
|
||||
}
|
||||
}
|
||||
|
||||
new ResizeObserver(renderCurrent).observe(svg);
|
||||
|
||||
return {
|
||||
loadCurrent,
|
||||
renderCurrent,
|
||||
};
|
||||
}
|
||||
import { FALLBACK_VIEWBOX_HEIGHT, VIEWBOX_WIDTH } from "./viewbox.js";
|
||||
|
||||
/** @param {Chart} chart */
|
||||
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);
|
||||
const { legend, items, readout } = createLegend(chart);
|
||||
let currentScale = getDefaultScale(chartKey);
|
||||
const { legend, menu, 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}`,
|
||||
@@ -207,21 +48,29 @@ export function createChart(chart) {
|
||||
status.setAttribute("aria-live", "polite");
|
||||
status.setAttribute("role", "status");
|
||||
|
||||
const renderer = createChartRenderer(
|
||||
const renderer = createChartRenderer({
|
||||
svg,
|
||||
readout,
|
||||
menu,
|
||||
items,
|
||||
status,
|
||||
chart,
|
||||
() => currentView,
|
||||
() => currentTimeframe,
|
||||
);
|
||||
getView: () => currentView,
|
||||
getScale: () => currentScale,
|
||||
getTimeframe: () => currentTimeframe,
|
||||
});
|
||||
const viewControl = createViewControl(currentView, (view) => {
|
||||
currentView = view;
|
||||
saveView(chartKey, view);
|
||||
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 +80,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;
|
||||
}
|
||||
@@ -250,7 +103,7 @@ export function createChart(chart) {
|
||||
* @property {string} label
|
||||
* @property {() => string} color
|
||||
* @property {"line"} [role]
|
||||
* @property {(client: typeof brk) => import("./timeframes.js").TimeframeMetric} metric
|
||||
* @property {(client: typeof import("../../utils/client.js").brk) => import("./timeframes.js").TimeframeMetric} metric
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -264,5 +117,3 @@ export function createChart(chart) {
|
||||
* @property {string} color
|
||||
* @property {{ date: Date, value: number }[]} entries
|
||||
*/
|
||||
|
||||
/** @typedef {import("./highlight.js").SeriesHighlight} SeriesHighlight */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @param {Chart} chart
|
||||
* @returns {{ legend: HTMLElement, items: HTMLElement[], readout: Readout }}
|
||||
* @returns {{ legend: HTMLElement, menu: HTMLElement, items: HTMLElement[], readout: Readout }}
|
||||
*/
|
||||
export function createLegend(chart) {
|
||||
const legend = document.createElement("figcaption");
|
||||
@@ -30,7 +30,7 @@ export function createLegend(chart) {
|
||||
header.append(time);
|
||||
legend.append(header, menu);
|
||||
|
||||
return { legend, items, readout: { time, rows } };
|
||||
return { legend, menu, items, readout: { time, rows } };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
@@ -18,7 +19,7 @@ export function renderLinePlot(group, loadedSeries, height, highlight) {
|
||||
path.dataset.series = index.toString();
|
||||
path.style.setProperty("--color", color);
|
||||
path.setAttribute("d", createLinePathData(points));
|
||||
highlight.add(path, index);
|
||||
highlight.addNode(path, index);
|
||||
group.append(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,60 @@
|
||||
import { brk } from "../../utils/client.js";
|
||||
import { fetchTimeframe } from "./timeframes.js";
|
||||
|
||||
/**
|
||||
* @param {ChartResult} result
|
||||
* @returns {{ date: Date, value: number }[]}
|
||||
*/
|
||||
function createEntries(result) {
|
||||
/** @type {{ date: Date, value: number }[]} */
|
||||
const entries = [];
|
||||
/** @type {number | undefined} */
|
||||
let lastValue;
|
||||
|
||||
for (const [date, value] of result.dateEntries()) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) lastValue = value;
|
||||
if (lastValue !== undefined) entries.push({ date, value: lastValue });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Chart} chart
|
||||
* @param {TimeframeValue} timeframe
|
||||
*/
|
||||
function loadSeries(chart, timeframe) {
|
||||
return Promise.all(
|
||||
chart.series.map(async (item) => ({
|
||||
series: item,
|
||||
color: item.color(),
|
||||
entries: createEntries(await fetchTimeframe(item.metric(brk), timeframe)),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {Chart} chart */
|
||||
export function createSeriesLoader(chart) {
|
||||
/** @type {TimeframeValue | undefined} */
|
||||
let cachedTimeframe;
|
||||
/** @type {Promise<LoadedSeries[]> | undefined} */
|
||||
let cachedPromise;
|
||||
|
||||
/** @param {TimeframeValue} timeframe */
|
||||
return function loadTimeframe(timeframe) {
|
||||
if (timeframe !== cachedTimeframe || !cachedPromise) {
|
||||
cachedTimeframe = timeframe;
|
||||
cachedPromise = loadSeries(chart, timeframe).catch((error) => {
|
||||
if (timeframe === cachedTimeframe) cachedPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return cachedPromise;
|
||||
};
|
||||
}
|
||||
|
||||
/** @typedef {import("./index.js").Chart} Chart */
|
||||
/** @typedef {import("./index.js").ChartResult} ChartResult */
|
||||
/** @typedef {import("./index.js").LoadedSeries} LoadedSeries */
|
||||
/** @typedef {import("./timeframes.js").TimeframeValue} TimeframeValue */
|
||||
@@ -4,7 +4,7 @@ let groupId = 0;
|
||||
* @template {string} T
|
||||
* @param {Object} args
|
||||
* @param {string} args.legend
|
||||
* @param {{ value: T, label: string }[]} args.options
|
||||
* @param {readonly { value: T, label: string }[]} args.options
|
||||
* @param {T} args.currentValue
|
||||
* @param {(value: T) => void} args.onChange
|
||||
*/
|
||||
@@ -15,6 +15,11 @@ export function createRadioGroup(args) {
|
||||
|
||||
legend.append(args.legend);
|
||||
fieldset.append(legend);
|
||||
fieldset.addEventListener("change", (event) => {
|
||||
const input = /** @type {HTMLInputElement} */ (event.target);
|
||||
|
||||
args.onChange(/** @type {T} */ (input.value));
|
||||
});
|
||||
|
||||
for (const option of args.options) {
|
||||
const label = document.createElement("label");
|
||||
@@ -25,9 +30,6 @@ export function createRadioGroup(args) {
|
||||
input.name = name;
|
||||
input.value = option.value;
|
||||
input.checked = option.value === args.currentValue;
|
||||
input.addEventListener("change", () => {
|
||||
if (input.checked) args.onChange(option.value);
|
||||
});
|
||||
|
||||
text.append(option.label);
|
||||
label.append(input, text);
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { renderBarPlot } from "./bar/index.js";
|
||||
import { createSeriesHighlight } from "./highlight.js";
|
||||
import { createSeriesLoader } from "./loader.js";
|
||||
import { renderLinePlot } from "./line/index.js";
|
||||
import { createScrubber } from "./scrubber.js";
|
||||
import { renderDotsPlot } from "./dots/index.js";
|
||||
import { createSvgElement } from "./svg.js";
|
||||
import { renderStackedPlot } from "./stacked/index.js";
|
||||
import { getViewBoxHeight, VIEWBOX_WIDTH } from "./viewbox.js";
|
||||
|
||||
/**
|
||||
* @param {ChartView} view
|
||||
* @param {SVGGElement} group
|
||||
* @param {LoadedSeries[]} loadedSeries
|
||||
* @param {number} height
|
||||
* @param {SeriesHighlight} highlight
|
||||
* @param {ChartScale} scale
|
||||
*/
|
||||
function renderPlot(view, group, loadedSeries, height, highlight, scale) {
|
||||
switch (view) {
|
||||
case "line":
|
||||
return renderLinePlot(group, loadedSeries, height, highlight, scale);
|
||||
case "bar":
|
||||
case "bar-reversed":
|
||||
return renderBarPlot(
|
||||
group,
|
||||
loadedSeries,
|
||||
height,
|
||||
highlight,
|
||||
{ reversed: view === "bar-reversed" },
|
||||
scale,
|
||||
);
|
||||
case "dots":
|
||||
return renderDotsPlot(group, loadedSeries, height, highlight, scale);
|
||||
default:
|
||||
return renderStackedPlot(
|
||||
group,
|
||||
loadedSeries,
|
||||
height,
|
||||
highlight,
|
||||
{ reversed: view === "stacked-reversed" },
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {SVGSVGElement} args.svg
|
||||
* @param {Readout} args.readout
|
||||
* @param {HTMLElement} args.menu
|
||||
* @param {HTMLElement[]} args.items
|
||||
* @param {HTMLElement} args.status
|
||||
* @param {Chart} args.chart
|
||||
* @param {() => ChartView} args.getView
|
||||
* @param {() => ChartScale} args.getScale
|
||||
* @param {() => TimeframeValue} args.getTimeframe
|
||||
*/
|
||||
export function createChartRenderer({
|
||||
svg,
|
||||
readout,
|
||||
menu,
|
||||
items,
|
||||
status,
|
||||
chart,
|
||||
getView,
|
||||
getScale,
|
||||
getTimeframe,
|
||||
}) {
|
||||
const group = createSvgElement("g");
|
||||
const highlight = createSeriesHighlight(items, menu);
|
||||
const loadSeries = createSeriesLoader(chart);
|
||||
/** @type {LoadedSeries[]} */
|
||||
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 (!active || !loadedSeries.length) return;
|
||||
|
||||
const height = getViewBoxHeight(svg);
|
||||
|
||||
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${height}`);
|
||||
group.replaceChildren();
|
||||
highlight.clearNodes();
|
||||
scrubber ??= createScrubber(svg, readout, highlight);
|
||||
scrubber.setSeries(
|
||||
renderPlot(
|
||||
getView(),
|
||||
group,
|
||||
loadedSeries,
|
||||
height,
|
||||
highlight,
|
||||
getScale(),
|
||||
),
|
||||
height,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadCurrent() {
|
||||
const id = (loadId += 1);
|
||||
svg.setAttribute("aria-busy", "true");
|
||||
|
||||
try {
|
||||
const nextSeries = await loadSeries(getTimeframe());
|
||||
|
||||
if (id !== loadId || !active) return;
|
||||
|
||||
loadedSeries = nextSeries;
|
||||
renderCurrent();
|
||||
status.textContent = "";
|
||||
} catch (error) {
|
||||
if (id !== loadId) return;
|
||||
console.error(error);
|
||||
status.textContent = "Chart unavailable";
|
||||
} finally {
|
||||
if (id === loadId) svg.removeAttribute("aria-busy");
|
||||
}
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (active) return;
|
||||
|
||||
active = true;
|
||||
resizeObserver.observe(svg);
|
||||
void loadCurrent();
|
||||
}
|
||||
|
||||
function suspend() {
|
||||
if (!active) return;
|
||||
|
||||
active = false;
|
||||
loadedSeries = [];
|
||||
loadId += 1;
|
||||
resizeObserver.disconnect();
|
||||
group.replaceChildren();
|
||||
highlight.clearNodes();
|
||||
scrubber?.clear();
|
||||
svg.removeAttribute("aria-busy");
|
||||
}
|
||||
|
||||
return {
|
||||
loadCurrent,
|
||||
renderCurrent,
|
||||
resume,
|
||||
suspend,
|
||||
};
|
||||
}
|
||||
|
||||
/** @typedef {import("./index.js").Chart} Chart */
|
||||
/** @typedef {import("./index.js").LoadedSeries} LoadedSeries */
|
||||
/** @typedef {import("./legend.js").Readout} Readout */
|
||||
/** @typedef {import("./scale.js").ChartScale} ChartScale */
|
||||
/** @typedef {import("./timeframes.js").TimeframeValue} TimeframeValue */
|
||||
/** @typedef {import("./views.js").ChartView} ChartView */
|
||||
/** @typedef {import("./highlight.js").SeriesHighlight} SeriesHighlight */
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createRadioGroup } from "./radio.js";
|
||||
import { createChartStorage } from "./storage.js";
|
||||
|
||||
const storage = createChartStorage("scale");
|
||||
const defaultScale = "linear";
|
||||
const scales = /** @type {const} */ ([
|
||||
{ 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 {(typeof scales)[number]["value"]} ChartScale */
|
||||
@@ -74,6 +74,8 @@ export function createScrubber(svg, readout, highlight) {
|
||||
* @param {boolean} [scrubbing]
|
||||
*/
|
||||
function update(ratio, scrubbing = true) {
|
||||
if (!series.length) return;
|
||||
|
||||
const nextRatio = clamp(ratio, 0, 1);
|
||||
const points = series.map((item) => getPointAtRatio(item, nextRatio));
|
||||
const x = points[0].x.toFixed(2);
|
||||
@@ -103,6 +105,15 @@ export function createScrubber(svg, readout, highlight) {
|
||||
update(1, false);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
series = [];
|
||||
markers = [];
|
||||
highlight.clearPreview();
|
||||
group.replaceChildren(guide);
|
||||
delete svg.dataset.index;
|
||||
delete svg.dataset.scrubbing;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ScrubberSeries[]} nextSeries
|
||||
* @param {number} nextHeight
|
||||
@@ -118,7 +129,7 @@ export function createScrubber(svg, readout, highlight) {
|
||||
marker.dataset.scrubber = "marker";
|
||||
marker.style.setProperty("--color", color);
|
||||
marker.setAttribute("r", "3");
|
||||
highlight.add(marker, index);
|
||||
highlight.addNode(marker, index);
|
||||
|
||||
return marker;
|
||||
});
|
||||
@@ -131,14 +142,25 @@ export function createScrubber(svg, readout, highlight) {
|
||||
function updateFromPointer(event) {
|
||||
const { left, width } = svg.getBoundingClientRect();
|
||||
const x = ((event.clientX - left) / width) * VIEWBOX_WIDTH;
|
||||
const index = Number(
|
||||
/** @type {SVGElement} */ (event.target).dataset.series,
|
||||
);
|
||||
|
||||
if (Number.isInteger(index)) highlight.preview(index);
|
||||
else highlight.clearPreview();
|
||||
update(x / VIEWBOX_WIDTH);
|
||||
}
|
||||
|
||||
svg.addEventListener("pointermove", updateFromPointer);
|
||||
svg.addEventListener("pointerleave", hide);
|
||||
svg.addEventListener("pointerleave", () => {
|
||||
highlight.clearPreview();
|
||||
hide();
|
||||
});
|
||||
svg.addEventListener("focus", () => update(1));
|
||||
svg.addEventListener("blur", hide);
|
||||
svg.addEventListener("blur", () => {
|
||||
highlight.clearPreview();
|
||||
hide();
|
||||
});
|
||||
svg.addEventListener("keydown", (event) => {
|
||||
const current = Number(svg.dataset.index || stepCount);
|
||||
|
||||
@@ -153,7 +175,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) {
|
||||
@@ -30,7 +33,7 @@ export function renderStackedPlot(
|
||||
path.dataset.series = index.toString();
|
||||
path.style.setProperty("--color", color);
|
||||
path.setAttribute("d", createAreaPathData(points));
|
||||
highlight.add(path, index);
|
||||
highlight.addNode(path, index);
|
||||
group.append(path);
|
||||
}
|
||||
|
||||
@@ -42,7 +45,7 @@ export function renderStackedPlot(
|
||||
path.dataset.series = index.toString();
|
||||
path.style.setProperty("--color", color);
|
||||
path.setAttribute("d", createLinePathData(points));
|
||||
highlight.add(path, index);
|
||||
highlight.addNode(path, index);
|
||||
group.append(path);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,16 +6,12 @@ main.learn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 20rem;
|
||||
outline: 0;
|
||||
cursor: crosshair;
|
||||
overflow: visible;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
svg:focus-visible {
|
||||
outline: 1px solid var(--orange);
|
||||
outline-offset: 0.5rem;
|
||||
}
|
||||
|
||||
p[role="status"]:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -160,9 +156,9 @@ main.learn {
|
||||
|
||||
menu {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0;
|
||||
padding-bottom: 1rem;
|
||||
padding-top: 0.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
list-style: none;
|
||||
@@ -173,7 +169,7 @@ main.learn {
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.25rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border: 0;
|
||||
border-radius: 0.25rem;
|
||||
color: inherit;
|
||||
@@ -183,7 +179,7 @@ main.learn {
|
||||
text-transform: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:is(:hover, :focus-visible, [data-active]) {
|
||||
&:is(:hover, :focus-visible, [data-active], [data-preview]) {
|
||||
color: var(--black);
|
||||
background: var(--color);
|
||||
|
||||
@@ -222,6 +218,9 @@ main.learn {
|
||||
> output {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
margin-left: auto;
|
||||
width: 7ch;
|
||||
min-height: 1em;
|
||||
color: var(--white);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
|
||||
@@ -2,10 +2,8 @@ import { createRadioGroup } from "./radio.js";
|
||||
import { createChartStorage } from "./storage.js";
|
||||
|
||||
const storage = createChartStorage("timeframe");
|
||||
/** @type {TimeframeValue} */
|
||||
const defaultTimeframe = "all";
|
||||
/** @type {Record<TimeframeValue, TimeframeConfig>} */
|
||||
const timeframes = {
|
||||
const timeframes = /** @type {const} */ ({
|
||||
"1d": { index: "minute10", count: 144 },
|
||||
"1w": { index: "hour1", count: 168 },
|
||||
"1m": { index: "hour4", count: 186 },
|
||||
@@ -13,9 +11,8 @@ const timeframes = {
|
||||
"4y": { index: "day3", count: 488 },
|
||||
"8y": { index: "week1", count: 418 },
|
||||
all: { index: "week1" },
|
||||
};
|
||||
/** @type {{ value: TimeframeValue, label: string }[]} */
|
||||
const options = [
|
||||
});
|
||||
const options = /** @type {const} */ ([
|
||||
{ value: "1d", label: "1d" },
|
||||
{ value: "1w", label: "1w" },
|
||||
{ value: "1m", label: "1m" },
|
||||
@@ -23,7 +20,7 @@ const options = [
|
||||
{ value: "4y", label: "4y" },
|
||||
{ value: "8y", label: "8y" },
|
||||
{ value: "all", label: "all" },
|
||||
];
|
||||
]);
|
||||
|
||||
/** @param {string} chartKey */
|
||||
export function getDefaultTimeframe(chartKey) {
|
||||
@@ -61,20 +58,16 @@ export function createTimeframeControl(currentTimeframe, onChange) {
|
||||
* @param {TimeframeValue} timeframe
|
||||
*/
|
||||
export function fetchTimeframe(metric, timeframe) {
|
||||
const { count, index } = timeframes[timeframe];
|
||||
const endpoint = metric.by[index];
|
||||
const config = timeframes[timeframe];
|
||||
const endpoint = metric.by[config.index];
|
||||
|
||||
return count ? endpoint.last(count).fetch() : endpoint.fetch();
|
||||
return "count" in config
|
||||
? endpoint.last(config.count).fetch()
|
||||
: endpoint.fetch();
|
||||
}
|
||||
|
||||
/** @typedef {"1d" | "1w" | "1m" | "1y" | "4y" | "8y" | "all"} TimeframeValue */
|
||||
/** @typedef {"minute10" | "hour1" | "hour4" | "day1" | "day3" | "week1"} TimeframeIndex */
|
||||
|
||||
/**
|
||||
* @typedef {Object} TimeframeConfig
|
||||
* @property {TimeframeIndex} index
|
||||
* @property {number} [count]
|
||||
*/
|
||||
/** @typedef {(typeof options)[number]["value"]} TimeframeValue */
|
||||
/** @typedef {(typeof timeframes)[TimeframeValue]["index"]} TimeframeIndex */
|
||||
|
||||
/**
|
||||
* @typedef {Object} TimeframeEndpoint
|
||||
|
||||
@@ -2,17 +2,15 @@ import { createRadioGroup } from "./radio.js";
|
||||
import { createChartStorage } from "./storage.js";
|
||||
|
||||
const storage = createChartStorage("view");
|
||||
/** @type {ChartView} */
|
||||
const defaultView = "stacked";
|
||||
/** @type {{ value: ChartView, label: string }[]} */
|
||||
const views = [
|
||||
const views = /** @type {const} */ ([
|
||||
{ value: "line", label: "Line" },
|
||||
{ value: "stacked", label: "Stack↑" },
|
||||
{ value: "stacked-reversed", label: "Stack↓" },
|
||||
{ value: "bar", label: "Bars↑" },
|
||||
{ value: "bar-reversed", label: "Bars↓" },
|
||||
{ value: "dots", label: "Dots" },
|
||||
];
|
||||
]);
|
||||
|
||||
/** @param {string} chartKey */
|
||||
export function getDefaultView(chartKey) {
|
||||
@@ -42,4 +40,4 @@ export function createViewControl(currentView, onChange) {
|
||||
});
|
||||
}
|
||||
|
||||
/** @typedef {"line" | "stacked" | "stacked-reversed" | "bar" | "bar-reversed" | "dots"} ChartView */
|
||||
/** @typedef {(typeof views)[number]["value"]} ChartView */
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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 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 */
|
||||
+14
-147
@@ -1,158 +1,25 @@
|
||||
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",
|
||||
color: colors.sky,
|
||||
color: colors.yellow,
|
||||
metric: (client) => client.series.cohorts.utxo.sth.supply.total.btc,
|
||||
},
|
||||
{
|
||||
label: "LTH",
|
||||
color: colors.orange,
|
||||
color: colors.fuchsia,
|
||||
metric: (client) => client.series.cohorts.utxo.lth.supply.total.btc,
|
||||
},
|
||||
]);
|
||||
@@ -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,24 @@
|
||||
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)}`;
|
||||
if (section.numbered === false) item.dataset.numbered = "false";
|
||||
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 +35,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);
|
||||
@@ -40,5 +45,6 @@ export function createContents(sections) {
|
||||
/**
|
||||
* @typedef {Object} Section
|
||||
* @property {string} title
|
||||
* @property {boolean} [numbered]
|
||||
* @property {Section[]} [children]
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
@@ -20,20 +22,28 @@ main.learn {
|
||||
}
|
||||
|
||||
> ol > li {
|
||||
counter-increment: content-theme;
|
||||
counter-reset: content-topic;
|
||||
}
|
||||
|
||||
> ol > li:not([data-numbered="false"]) {
|
||||
counter-increment: content-theme;
|
||||
}
|
||||
|
||||
> ol > li > ol > li {
|
||||
counter-increment: content-topic;
|
||||
counter-reset: content-detail;
|
||||
}
|
||||
|
||||
> ol > li > ol > li > ol > li {
|
||||
counter-increment: content-detail;
|
||||
}
|
||||
|
||||
ol ol {
|
||||
margin-top: 0.25rem;
|
||||
margin-left: 1rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
ol ol > li {
|
||||
counter-increment: content-topic;
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
@@ -76,8 +86,17 @@ main.learn {
|
||||
content: counter(content-theme, upper-roman) ". ";
|
||||
}
|
||||
|
||||
ol ol > li > a::before {
|
||||
> ol > li[data-numbered="false"] > a::before {
|
||||
content: "I. ";
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
> ol > li > ol > li > a::before {
|
||||
content: counter(content-topic) ". ";
|
||||
}
|
||||
|
||||
> ol > li > ol > li > ol > li > a::before {
|
||||
content: counter(content-detail, lower-alpha) ". ";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+182
-230
@@ -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,
|
||||
@@ -25,10 +44,16 @@ function metricSupplyInLoss(client) {
|
||||
}
|
||||
|
||||
export const sections = [
|
||||
{
|
||||
title: "Introduction",
|
||||
numbered: false,
|
||||
description:
|
||||
"Bitcoin can be measured from many angles, but a single number rarely explains much on its own. This page introduces core Bitcoin concepts through data that changes over time. Each chart is meant to answer a simple question: what is being measured, how has it changed, and how does it compare across different groups? The goal is to make the system easier to read, from the supply itself to the way coins move, age, concentrate, and gain value.",
|
||||
},
|
||||
{
|
||||
title: "Supply",
|
||||
description:
|
||||
"How bitcoin moves from issuance into long-term ownership, profit, loss, and distribution.",
|
||||
"Bitcoin has a fixed issuance schedule. This chart shows how many BTC are in circulation over time, so you can see supply rising toward the 21 million limit.",
|
||||
chart: {
|
||||
title: "Circulating supply",
|
||||
series: [
|
||||
@@ -43,7 +68,7 @@ export const sections = [
|
||||
{
|
||||
title: "Profitability",
|
||||
description:
|
||||
"Which coins sit in profit or loss, and how that balance changes through cycles.",
|
||||
"Shows whether coins are in profit or loss based on the price when they last moved on-chain. A coin is in profit when today's price is higher than its last moved price, and in loss when today's price is lower.",
|
||||
chart: {
|
||||
title: "Profitability",
|
||||
series: [
|
||||
@@ -63,7 +88,7 @@ export const sections = [
|
||||
{
|
||||
title: "Term",
|
||||
description:
|
||||
"Supply split between recently moved coins and long-term holder coins.",
|
||||
"Splits supply between coins that moved recently and coins that have stayed still longer. This helps separate more active supply from long-term holder supply.",
|
||||
chart: {
|
||||
title: "Supply by term",
|
||||
series: termSeries,
|
||||
@@ -72,7 +97,7 @@ export const sections = [
|
||||
{
|
||||
title: "Age",
|
||||
description:
|
||||
"How long coins have remained still, from fresh movement to deep dormancy.",
|
||||
"Groups coins by how long they have stayed still since their last on-chain movement. Older coins are usually more dormant, while younger coins have moved more recently.",
|
||||
chart: {
|
||||
title: "Supply by age",
|
||||
series: ageSeries,
|
||||
@@ -80,7 +105,8 @@ export const sections = [
|
||||
},
|
||||
{
|
||||
title: "UTXO Balance",
|
||||
description: "Supply grouped by the amount held in each unspent output.",
|
||||
description:
|
||||
"Groups supply by the size of each unspent output. A UTXO is a spendable piece of bitcoin created by a transaction, so this shows the size distribution of coin fragments.",
|
||||
chart: {
|
||||
title: "Supply by UTXO balance",
|
||||
series: utxoBalanceSeries,
|
||||
@@ -88,7 +114,8 @@ export const sections = [
|
||||
},
|
||||
{
|
||||
title: "Address Balance",
|
||||
description: "Supply grouped by the balance held at each address.",
|
||||
description:
|
||||
"Groups supply by the total BTC held at each address. An address is not the same as a person or entity, but this still helps show how balances are distributed on-chain.",
|
||||
chart: {
|
||||
title: "Supply by address balance",
|
||||
series: addressBalanceSeries,
|
||||
@@ -96,7 +123,8 @@ export const sections = [
|
||||
},
|
||||
{
|
||||
title: "Type",
|
||||
description: "Supply grouped by output script type.",
|
||||
description:
|
||||
"Groups supply by Bitcoin output type. The output type is the script format that defines how coins can be spent.",
|
||||
chart: {
|
||||
title: "Supply by type",
|
||||
series: typeSeries,
|
||||
@@ -105,7 +133,7 @@ export const sections = [
|
||||
{
|
||||
title: "Epoch",
|
||||
description:
|
||||
"Supply grouped by the halving epoch in which coins were created.",
|
||||
"Groups supply by the halving epoch when coins were mined. A halving epoch is a period between two subsidy halvings, when the amount of new BTC paid to miners changes.",
|
||||
chart: {
|
||||
title: "Supply by epoch",
|
||||
series: epochSeries,
|
||||
@@ -114,7 +142,7 @@ export const sections = [
|
||||
{
|
||||
title: "Class",
|
||||
description:
|
||||
"Supply grouped by the calendar year in which coins were created.",
|
||||
"Groups supply by the calendar year when coins were mined. This shows how much of today's supply comes from each issuance year.",
|
||||
chart: {
|
||||
title: "Supply by class",
|
||||
series: classSeries,
|
||||
@@ -125,235 +153,159 @@ export const sections = [
|
||||
{
|
||||
title: "Capitalization",
|
||||
description:
|
||||
"Different ways to value the network by market price, realized cost, and accumulated flows.",
|
||||
chart: "Capitalization overview",
|
||||
"Shows ways to value Bitcoin in US dollars. Market cap uses today's price, while realized cap uses the price when coins last moved on-chain.",
|
||||
chart: {
|
||||
title: "Capitalization",
|
||||
series: capitalizationSeries,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
title: "Market Cap",
|
||||
description:
|
||||
"The current market value of circulating bitcoin at spot price.",
|
||||
chart: "Market capitalization",
|
||||
"Market cap is circulating supply multiplied by the current bitcoin price. It answers: what is all circulating BTC worth at today's market price?",
|
||||
chart: {
|
||||
title: "Market cap",
|
||||
series: marketCapSeries,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
title: "Term",
|
||||
description:
|
||||
"Splits market cap between coins that moved recently and coins that have stayed still longer. This shows how much current market value sits with active supply versus long-term holder supply.",
|
||||
chart: {
|
||||
title: "Market cap by term",
|
||||
series: marketCapTermSeries,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Age",
|
||||
description:
|
||||
"Groups market cap by how long coins have stayed still since their last on-chain movement. It shows which age bands hold the most current market value.",
|
||||
chart: {
|
||||
title: "Market cap by age",
|
||||
series: marketCapAgeSeries,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "UTXO Balance",
|
||||
description:
|
||||
"Groups market cap by the size of each unspent output. This shows how current market value is distributed across small and large spendable coin fragments.",
|
||||
chart: {
|
||||
title: "Market cap by UTXO balance",
|
||||
series: marketCapUtxoBalanceSeries,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Address Balance",
|
||||
description:
|
||||
"Groups market cap by the total BTC held at each address. Addresses are not people or entities, but this still helps show how current market value is distributed across address balances.",
|
||||
chart: {
|
||||
title: "Market cap by address balance",
|
||||
series: marketCapAddressBalanceSeries,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Type",
|
||||
description:
|
||||
"Groups market cap by Bitcoin output type. This shows how much current market value is held in each script format.",
|
||||
chart: {
|
||||
title: "Market cap by type",
|
||||
series: marketCapTypeSeries,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Epoch",
|
||||
description:
|
||||
"Groups market cap by the halving epoch when coins were mined. This shows the current value of coins created during each issuance period.",
|
||||
chart: {
|
||||
title: "Market cap by epoch",
|
||||
series: marketCapEpochSeries,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Class",
|
||||
description:
|
||||
"Groups market cap by the calendar year when coins were mined. This shows the current value of supply created in each year.",
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Activity",
|
||||
description:
|
||||
"How often the chain is used, how value moves, and how demand appears " +
|
||||
"in fees and transactions.",
|
||||
chart: "Network activity",
|
||||
children: [
|
||||
{
|
||||
title: "Transactions",
|
||||
description:
|
||||
"Confirmed transaction count, throughput, and block-level settlement patterns.",
|
||||
chart: "Transaction count",
|
||||
},
|
||||
{
|
||||
title: "Fees",
|
||||
description:
|
||||
"The cost users pay for block space and what that reveals about demand.",
|
||||
chart: "Fee rate",
|
||||
},
|
||||
{
|
||||
title: "Addresses",
|
||||
description:
|
||||
"Address creation, reuse, activity, and balance changes across the network.",
|
||||
chart: "Active addresses",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Mining",
|
||||
description:
|
||||
"The security budget, difficulty adjustments, pool behavior, and miner revenue.",
|
||||
chart: "Mining overview",
|
||||
children: [
|
||||
{
|
||||
title: "Hashrate",
|
||||
description:
|
||||
"Estimated computational power securing the network over time.",
|
||||
chart: "Hashrate",
|
||||
},
|
||||
{
|
||||
title: "Difficulty",
|
||||
description:
|
||||
"How Bitcoin adjusts mining difficulty to keep block production steady.",
|
||||
chart: "Difficulty",
|
||||
},
|
||||
{
|
||||
title: "Rewards",
|
||||
description:
|
||||
"Subsidy, fees, and the changing economics of block production.",
|
||||
chart: "Miner rewards",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Market",
|
||||
description:
|
||||
"Price behavior, returns, volatility, and the market context around on-chain patterns.",
|
||||
chart: "Market overview",
|
||||
children: [
|
||||
{
|
||||
title: "Price",
|
||||
description:
|
||||
"Bitcoin price across time, cycles, drawdowns, and all-time highs.",
|
||||
chart: "Price",
|
||||
},
|
||||
{
|
||||
title: "Returns",
|
||||
description:
|
||||
"How returns vary by holding period, entry point, and cycle phase.",
|
||||
chart: "Returns",
|
||||
},
|
||||
{
|
||||
title: "Volatility",
|
||||
description:
|
||||
"The scale and rhythm of price movement across different windows.",
|
||||
chart: "Volatility",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Ownership",
|
||||
description:
|
||||
"How coins are held across balances, entities, custody patterns, and long-term cohorts.",
|
||||
chart: "Ownership overview",
|
||||
children: [
|
||||
{
|
||||
title: "Balances",
|
||||
description:
|
||||
"Address and entity balances grouped by size, concentration, and historical change.",
|
||||
chart: "Balance cohorts",
|
||||
},
|
||||
{
|
||||
title: "Entities",
|
||||
description:
|
||||
"Estimated ownership clusters and how their behavior changes through market regimes.",
|
||||
chart: "Entity supply",
|
||||
},
|
||||
{
|
||||
title: "Custody",
|
||||
description:
|
||||
"Coins associated with exchanges, funds, miners, and other observable custody groups.",
|
||||
chart: "Custody balances",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Liquidity",
|
||||
description:
|
||||
"How available supply changes as coins move between liquid, illiquid, and exchange venues.",
|
||||
chart: "Liquidity overview",
|
||||
children: [
|
||||
{
|
||||
title: "Liquid Supply",
|
||||
description:
|
||||
"Coins held by entities that tend to spend, trade, or redistribute frequently.",
|
||||
chart: "Liquid supply",
|
||||
},
|
||||
{
|
||||
title: "Illiquid Supply",
|
||||
description:
|
||||
"Coins held by entities with low spending history and stronger accumulation behavior.",
|
||||
chart: "Illiquid supply",
|
||||
},
|
||||
{
|
||||
title: "Exchange Flow",
|
||||
description:
|
||||
"Deposits, withdrawals, and balance changes across known exchange clusters.",
|
||||
chart: "Exchange netflow",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Risk",
|
||||
description:
|
||||
"Stress, leverage, drawdown, and valuation conditions that shape market fragility.",
|
||||
chart: "Risk overview",
|
||||
children: [
|
||||
{
|
||||
title: "Drawdown",
|
||||
description:
|
||||
"Distance from prior highs and the depth of cycle retracements over time.",
|
||||
chart: "Drawdown",
|
||||
},
|
||||
{
|
||||
title: "Stress",
|
||||
description:
|
||||
"Periods where losses, volatility, and fee pressure concentrate together.",
|
||||
chart: "Network stress",
|
||||
},
|
||||
{
|
||||
title: "Leverage",
|
||||
description:
|
||||
"Market conditions that indicate amplified exposure and forced positioning risk.",
|
||||
chart: "Leverage proxy",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Cycles",
|
||||
description:
|
||||
"How Bitcoin behaves across halvings, adoption waves, liquidity regimes, and market phases.",
|
||||
chart: "Cycle overview",
|
||||
children: [
|
||||
{
|
||||
title: "Halvings",
|
||||
description:
|
||||
"Supply issuance changes and their relationship to market and miner behavior.",
|
||||
chart: "Halving cycles",
|
||||
},
|
||||
{
|
||||
title: "Phases",
|
||||
description:
|
||||
"Bull, bear, recovery, and transition periods described through on-chain behavior.",
|
||||
chart: "Cycle phases",
|
||||
},
|
||||
{
|
||||
title: "Comparisons",
|
||||
description:
|
||||
"Cycle-to-cycle comparisons normalized by time, price, drawdown, or supply behavior.",
|
||||
chart: "Cycle comparison",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Cohorts",
|
||||
description:
|
||||
"Groups of market participants organized by age, balance, cost basis, and observed behavior.",
|
||||
chart: "Cohort overview",
|
||||
children: [
|
||||
{
|
||||
title: "Short Term",
|
||||
description:
|
||||
"Recently moved coins and holders more sensitive to price, volatility, and liquidity.",
|
||||
chart: "Short-term holder supply",
|
||||
},
|
||||
{
|
||||
title: "Long Term",
|
||||
description:
|
||||
"Older coins and holders with stronger dormancy, conviction, or lower spend frequency.",
|
||||
chart: "Long-term holder supply",
|
||||
},
|
||||
{
|
||||
title: "Cost Basis",
|
||||
description:
|
||||
"Estimated acquisition prices across cohorts and how they frame profit and loss.",
|
||||
chart: "Cohort cost basis",
|
||||
"Realized cap values each coin at the price when it last moved on-chain. It is often used as a rough view of the market's aggregate cost basis.",
|
||||
chart: {
|
||||
title: "Realized cap",
|
||||
series: realizedCapSeries,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
title: "Term",
|
||||
description:
|
||||
"Splits realized cap between coins that moved recently and coins that have stayed still longer. This shows where the market's cost basis sits across active and long-term holder supply.",
|
||||
chart: {
|
||||
title: "Realized cap by term",
|
||||
series: realizedCapTermSeries,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Age",
|
||||
description:
|
||||
"Groups realized cap by how long coins have stayed still since their last on-chain movement. This shows which coin ages carry the largest share of the market's cost basis.",
|
||||
chart: {
|
||||
title: "Realized cap by age",
|
||||
series: realizedCapAgeSeries,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "UTXO Balance",
|
||||
description:
|
||||
"Groups realized cap by the size of each unspent output. This shows how cost basis is distributed across small and large spendable coin fragments.",
|
||||
chart: {
|
||||
title: "Realized cap by UTXO balance",
|
||||
series: realizedCapUtxoBalanceSeries,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Address Balance",
|
||||
description:
|
||||
"Groups realized cap by the total BTC held at each address. Addresses are not people or entities, but this still helps show how cost basis is distributed across address balances.",
|
||||
chart: {
|
||||
title: "Realized cap by address balance",
|
||||
series: realizedCapAddressBalanceSeries,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Type",
|
||||
description:
|
||||
"Groups realized cap by Bitcoin output type. This shows how much cost basis is held in each script format.",
|
||||
chart: {
|
||||
title: "Realized cap by type",
|
||||
series: realizedCapTypeSeries,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Epoch",
|
||||
description:
|
||||
"Groups realized cap by the halving epoch when coins were mined. This shows the cost basis of coins created during each issuance period.",
|
||||
chart: {
|
||||
title: "Realized cap by epoch",
|
||||
series: realizedCapEpochSeries,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Class",
|
||||
description:
|
||||
"Groups realized cap by the calendar year when coins were mined. This shows the cost basis of supply created in each year.",
|
||||
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"],
|
||||
]);
|
||||
@@ -18,10 +18,18 @@ function scrollToTarget(target, behavior) {
|
||||
target.scrollIntoView({ behavior, block: "start" });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} main
|
||||
* @param {ScrollBehavior} behavior
|
||||
*/
|
||||
function scrollToCurrentHash(main, behavior) {
|
||||
const target = getHashTarget(main, window.location.hash);
|
||||
|
||||
if (target) scrollToTarget(target, behavior);
|
||||
}
|
||||
|
||||
/** @param {HTMLElement} main */
|
||||
export function initHashLinks(main) {
|
||||
const initialHash = window.location.hash;
|
||||
|
||||
main.addEventListener("click", (event) => {
|
||||
if (!isPlainLeftClick(event)) return;
|
||||
|
||||
@@ -45,12 +53,8 @@ export function initHashLinks(main) {
|
||||
|
||||
window.addEventListener("popstate", () => {
|
||||
if (main.hidden) return;
|
||||
const target = getHashTarget(main, window.location.hash);
|
||||
if (target) scrollToTarget(target, "auto");
|
||||
scrollToCurrentHash(main, "auto");
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const target = getHashTarget(main, initialHash);
|
||||
if (target) scrollToTarget(target, "auto");
|
||||
});
|
||||
main.addEventListener("pageactive", () => scrollToCurrentHash(main, "auto"));
|
||||
}
|
||||
|
||||
+13
-23
@@ -3,44 +3,33 @@ import { sections } from "./data.js";
|
||||
import { createChart as createDataChart } from "./charts/index.js";
|
||||
import { initHashLinks } from "./hash-links.js";
|
||||
import { initScrollSpy } from "./scroll-spy.js";
|
||||
import { createId } from "../utils/id.js";
|
||||
|
||||
/** @param {Section["chart"]} chart */
|
||||
function createFigure(chart) {
|
||||
if (typeof chart !== "string") return createDataChart(chart);
|
||||
|
||||
const figure = document.createElement("figure");
|
||||
const placeholder = document.createElement("div");
|
||||
const caption = document.createElement("figcaption");
|
||||
|
||||
placeholder.append(chart);
|
||||
caption.append(chart);
|
||||
figure.append(placeholder, caption);
|
||||
|
||||
return figure;
|
||||
}
|
||||
import { createPathId } from "./path.js";
|
||||
|
||||
/**
|
||||
* @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;
|
||||
if (section.numbered === false) element.dataset.numbered = "false";
|
||||
anchor.href = `#${id}`;
|
||||
anchor.append(section.title);
|
||||
heading.append(anchor);
|
||||
description.append(section.description);
|
||||
element.append(heading, description, createFigure(section.chart));
|
||||
element.append(heading, description);
|
||||
if (section.chart) element.append(createDataChart(section.chart));
|
||||
|
||||
for (const child of children) {
|
||||
element.append(createSection(child, level + 1));
|
||||
element.append(createSection(child, sectionPath));
|
||||
}
|
||||
|
||||
return element;
|
||||
@@ -65,6 +54,7 @@ export function createLearnPage() {
|
||||
* @typedef {Object} Section
|
||||
* @property {string} title
|
||||
* @property {string} description
|
||||
* @property {string | import("./charts/index.js").Chart} chart
|
||||
* @property {import("./charts/index.js").Chart} [chart]
|
||||
* @property {boolean} [numbered]
|
||||
* @property {Section[]} [children]
|
||||
*/
|
||||
|
||||
@@ -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));
|
||||
@@ -26,34 +36,61 @@ main.learn {
|
||||
}
|
||||
|
||||
> section {
|
||||
counter-increment: theme;
|
||||
counter-reset: topic;
|
||||
width: min(100%, var(--content-width));
|
||||
margin-inline: auto;
|
||||
scroll-margin-top: var(--offset);
|
||||
}
|
||||
|
||||
> section:first-of-type {
|
||||
margin-top: calc(-1 * var(--offset));
|
||||
padding-top: var(--offset);
|
||||
> section:not([data-numbered="false"]) {
|
||||
counter-increment: theme;
|
||||
}
|
||||
|
||||
> section[data-numbered="false"] {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
min-height: calc(100dvh - var(--offset));
|
||||
|
||||
> h1 {
|
||||
position: static;
|
||||
max-width: 10ch;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 4rem;
|
||||
|
||||
a::before {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
> p {
|
||||
max-width: 42rem;
|
||||
margin-top: 1.5rem;
|
||||
color: var(--white);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
}
|
||||
|
||||
> section + section {
|
||||
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 +125,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 +145,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);
|
||||
@@ -120,21 +179,5 @@ main.learn {
|
||||
margin-top: 2rem;
|
||||
color: var(--gray);
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
&:not([data-chart]) {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
> div {
|
||||
height: 18rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--dark-gray);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-26
@@ -1,5 +1,5 @@
|
||||
import { createHeader } from "./header/index.js";
|
||||
import { createRoutePage, isRoute, normalizePath } from "./routes.js";
|
||||
import { createRoutePage, normalizePath, resolvePath } from "./routes.js";
|
||||
import { getEventAnchor, isPlainLeftClick } from "./utils/event.js";
|
||||
import { revealPage, transitionPage } from "./utils/transition.js";
|
||||
|
||||
@@ -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);
|
||||
@@ -55,18 +37,18 @@ function activatePage(page) {
|
||||
page.hidden = false;
|
||||
page.inert = false;
|
||||
currentPage = page;
|
||||
page.dispatchEvent(new Event("pageactive"));
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const pathname = normalizePath(window.location.pathname);
|
||||
activatePage(getPage(pathname));
|
||||
updateCurrentLink(pathname);
|
||||
}
|
||||
|
||||
/** @param {string} pathname */
|
||||
function navigate(pathname) {
|
||||
if (pathname === window.location.pathname) return;
|
||||
history.pushState(null, "", pathname);
|
||||
/** @param {string} path */
|
||||
function navigate(path) {
|
||||
if (path === `${window.location.pathname}${window.location.hash}`) return;
|
||||
history.pushState(null, "", path);
|
||||
void transitionPage(renderPage);
|
||||
}
|
||||
|
||||
@@ -80,10 +62,11 @@ document.addEventListener("click", (event) => {
|
||||
if (url.origin !== window.location.origin) return;
|
||||
if (url.pathname === window.location.pathname && url.hash) return;
|
||||
|
||||
if (!isRoute(url.pathname)) return;
|
||||
const pathname = resolvePath(url.pathname);
|
||||
if (!pathname) return;
|
||||
|
||||
event.preventDefault();
|
||||
navigate(url.pathname);
|
||||
navigate(`${pathname}${url.hash}`);
|
||||
});
|
||||
|
||||
window.addEventListener("popstate", renderPage);
|
||||
|
||||
+18
-17
@@ -3,30 +3,31 @@ import { createExplorePage } from "./explore/index.js";
|
||||
import { createHomePage } from "./home/index.js";
|
||||
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 },
|
||||
];
|
||||
|
||||
/** @type {Record<string, () => HTMLElement>} */
|
||||
const routes = Object.fromEntries(
|
||||
pages.map(({ pathname, createPage }) => [pathname, createPage]),
|
||||
);
|
||||
|
||||
export const primaryRoutes = pages.flatMap(({ pathname, label }) =>
|
||||
label ? [{ pathname, label }] : [],
|
||||
);
|
||||
const routes = {
|
||||
"/": createHomePage,
|
||||
"/explore": createExplorePage,
|
||||
"/learn": createLearnPage,
|
||||
"/build": createBuildPage,
|
||||
};
|
||||
|
||||
/** @param {string} pathname */
|
||||
export function isRoute(pathname) {
|
||||
return pathname in routes;
|
||||
function canonicalPath(pathname) {
|
||||
return pathname !== "/" && pathname.endsWith("/")
|
||||
? pathname.slice(0, -1)
|
||||
: pathname;
|
||||
}
|
||||
|
||||
/** @param {string} pathname */
|
||||
export function resolvePath(pathname) {
|
||||
const path = canonicalPath(pathname);
|
||||
|
||||
return path in routes ? path : undefined;
|
||||
}
|
||||
|
||||
/** @param {string} pathname */
|
||||
export function normalizePath(pathname) {
|
||||
return isRoute(pathname) ? pathname : "/";
|
||||
return resolvePath(pathname) ?? "/";
|
||||
}
|
||||
|
||||
/** @param {string} pathname */
|
||||
|
||||
@@ -26,12 +26,8 @@
|
||||
--fuchsia: oklch(0.629 0.294 322.523);
|
||||
--pink: oklch(0.624 0.245 357.444);
|
||||
--rose: oklch(0.6155 0.2495 17.012);
|
||||
--background-color: light-dark(var(--white), var(--black));
|
||||
--color: light-dark(var(--black), var(--white));
|
||||
--off-color: var(--gray);
|
||||
--border-color: light-dark(var(--light-gray), var(--dark-gray));
|
||||
--inv-border-color: light-dark(var(--dark-gray), var(--light-gray));
|
||||
--off-border-color: light-dark(var(--dark-white), var(--light-black));
|
||||
|
||||
--font-size-xs: 0.75rem;
|
||||
--line-height-xs: calc(1 / 0.75);
|
||||
@@ -39,15 +35,7 @@
|
||||
--line-height-sm: calc(1.25 / 0.875);
|
||||
--font-size-base: 1rem;
|
||||
--line-height-base: calc(1.5 / 1);
|
||||
--font-size-lg: 1.125rem;
|
||||
--line-height-lg: calc(1.75 / 1.125);
|
||||
--font-size-xl: 1.25rem;
|
||||
--line-height-xl: calc(1.75 / 1.25);
|
||||
|
||||
--page-x: 2rem;
|
||||
--main-padding: 2rem;
|
||||
--negative-main-padding: calc(-1 * var(--main-padding));
|
||||
--font-weight-base: 400;
|
||||
--max-main-width: 70dvw;
|
||||
--layer-header: 10;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export function getEventAnchor(event) {
|
||||
export function isPlainLeftClick(event) {
|
||||
return (
|
||||
event.button === 0 &&
|
||||
!event.altKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.shiftKey
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "bitviewnext",
|
||||
"compatibility_date": "2026-06-07",
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"assets": {
|
||||
"directory": "."
|
||||
},
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user