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