website: redesign part 11

This commit is contained in:
nym21
2026-06-06 22:29:33 +02:00
parent 66dc7cd8f5
commit 041c542046
49 changed files with 17275 additions and 193 deletions
+1
View File
@@ -18,6 +18,7 @@ _*
/*.py
/*.json
/*.html
!/btc-cycle-sim.html
/research
/filter_*
/heatmaps*
+2109
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8953,7 +8953,7 @@ pub struct BrkClient {
impl BrkClient {
/// Client version.
pub const VERSION: &'static str = "v0.3.1";
pub const VERSION: &'static str = "v0.3.2";
/// Create a new client with the given base URL.
pub fn new(base_url: impl Into<String>) -> Self {
+107 -16
View File
@@ -1,9 +1,19 @@
#!/usr/bin/env node
const DEFAULT_BUY_LEVELS = new Map([
[50, 1.0],
[45, 1.5],
[40, 2.0],
[100, 10.875],
[95, 11.7395833],
[90, 12.6041667],
[85, 13.46875],
[80, 14.3333333],
[75, 15.1979167],
[70, 16.0625],
[65, 16.9270833],
[60, 17.7916667],
[55, 18.65625],
[50, 19.5208333],
[45, 20.3854167],
[40, 21.25],
]);
const DAYS_PER_MONTH = 365.2425 / 12;
@@ -11,6 +21,7 @@ const DAYS_PER_MONTH = 365.2425 / 12;
function parseArgs(argv) {
const opts = {
baseUrl: "https://bitview.space",
dataStart: "2014-01-01",
start: "2014-01-01",
end: null,
starts: null,
@@ -18,6 +29,8 @@ function parseArgs(argv) {
initialCash: 10_000,
monthlyTopup: 1_000,
dailyBuy: null,
buyRunwayMonths: 0,
minCashReserveMonths: 0,
initialDeployDays: 365,
buyTriggerPct: 50,
buyLevels: DEFAULT_BUY_LEVELS,
@@ -25,7 +38,7 @@ function parseArgs(argv) {
sellArmPct: 100,
sellBandLowerPct: 95,
sellBandUpperPct: 100,
sellBandMultiple: 5,
sellBandMultiple: 2.75,
sellAthMultiple: 3,
sellMap: null,
maxDailySellFraction: 0.005,
@@ -45,6 +58,8 @@ function parseArgs(argv) {
opts.help = true;
} else if (arg === "--base-url") {
opts.baseUrl = next();
} else if (arg === "--data-start") {
opts.dataStart = next();
} else if (arg === "--start") {
opts.start = next();
} else if (arg === "--end") {
@@ -62,6 +77,10 @@ function parseArgs(argv) {
opts.monthlyTopup = parseNumber(next(), arg);
} else if (arg === "--daily-buy") {
opts.dailyBuy = parseNumber(next(), arg);
} else if (arg === "--buy-runway-months") {
opts.buyRunwayMonths = parseNumber(next(), arg);
} else if (arg === "--min-cash-reserve-months") {
opts.minCashReserveMonths = parseNumber(next(), arg);
} else if (arg === "--initial-deploy-days") {
opts.initialDeployDays = parseInteger(next(), arg);
} else if (arg === "--buy-trigger-pct") {
@@ -143,23 +162,25 @@ function printHelp() {
Fetches Bitview daily price + BTC-weighted cost-basis percentile data and
simulates the image rule:
- p50 touch starts daily DCA-in
- ATH touch stops DCA-in
- optional DCA-out uses percentile * multiplier thresholds
- p100 increase stops DCA-in and arms DCA-out
- optional DCA-out sells inside the selected percentile band after arming
- start with cash, then add a monthly top-up
Defaults:
--start 2014-01-01
--data-start 2014-01-01
--initial-cash 10000
--monthly-topup 1000
--buy-levels 50:1,45:1.5,40:2
--buy-levels 100:10.875,95:11.7395833,90:12.6041667,85:13.46875,80:14.3333333,75:15.1979167,70:16.0625,65:16.9270833,60:17.7916667,55:18.65625,50:19.5208333,45:20.3854167,40:21.25
--sell-band 95:100
--sell-band-multiple 5
--sell-band-multiple 2.75
--max-daily-sell-fraction 0.005
--mode both
--start-set cycle-extremes
Options:
--start YYYY-MM-DD
--data-start YYYY-MM-DD Warmup data start for cycle phase inference
--end YYYY-MM-DD
--starts YYYY-MM-DD,YYYY-MM-DD Explicit start dates
--start-set cycle-extremes|single|custom
@@ -167,11 +188,13 @@ Options:
--initial-cash USD
--monthly-topup USD
--daily-buy USD Default: monthly top-up / average month
--buy-runway-months N Cap daily buys to preserve N months of spend runway
--min-cash-reserve-months N Never spend below N months of top-up cash
--initial-deploy-days N Adds initial cash / N to buy budget on active buy days
--buy-trigger-pct N
--buy-levels pct:weight,...
--sell-arm-pct N Arms sell phase once p100 increases, or price touches other pct
--sell-band lowerPct:upperPct Sell only inside percentile band after multiplier
--sell-band lowerPct:upperPct Sell only inside percentile band after arming
--sell-band-multiple N Multiplies daily sell size while inside the band
--sell-ath-multiple N Sell when price >= previous ATH * N
--sell-map pct:multiplier,... Alternative: sell on cost-basis percentile thresholds
@@ -212,8 +235,10 @@ async function main() {
final_value: signal.finalValue,
return_pct: pct(signal.finalValue / signal.contributed - 1),
cash: signal.cash,
min_cash: signal.minCash,
btc: signal.btc,
buys: signal.buys,
capped_buy_days: signal.cappedBuyDays,
sells: signal.sells,
bought_usd: signal.boughtUsd,
sold_usd: signal.soldUsd,
@@ -289,7 +314,7 @@ async function loadData(opts) {
async function fetchSeries(opts, series) {
const url = new URL(`/api/series/${series}/day1`, normalizeBaseUrl(opts.baseUrl));
url.searchParams.set("start", opts.start);
url.searchParams.set("start", opts.dataStart);
if (opts.end) url.searchParams.set("end", opts.end);
const response = await fetch(url);
@@ -332,13 +357,15 @@ function resolveStartPoints(rows, opts) {
date: opts.start,
kind: "single",
epoch: null,
index: 0,
index: findDateIndex(rows, opts.start),
},
];
}
const firstIndex = findDateIndex(rows, opts.start);
const byEpoch = new Map();
rows.forEach((row, index) => {
if (index < firstIndex) return;
if (!Number.isFinite(row.price) || row.price <= 0) return;
if (!byEpoch.has(row.epoch)) byEpoch.set(row.epoch, []);
byEpoch.get(row.epoch).push({ row, index });
@@ -392,14 +419,15 @@ function findDateIndex(rows, date) {
function simulateSignal(rows, startIndex, opts, sellEnabled) {
let cash = opts.initialCash;
let btc = 0;
let buyActive = false;
let sellArmed = false;
let { buyActive, sellArmed } = inferPhase(rows, startIndex, opts);
let initialDeployActiveDays = 0;
let contributed = opts.initialCash;
let buys = 0;
let sells = 0;
let boughtUsd = 0;
let soldUsd = 0;
let minCash = cash;
let cappedBuyDays = 0;
let peakValue = cash;
let maxDrawdown = 0;
const baseDailyBuy = opts.dailyBuy ?? opts.monthlyTopup / DAYS_PER_MONTH;
@@ -440,7 +468,10 @@ function simulateSignal(rows, startIndex, opts, sellEnabled) {
? opts.initialCash / opts.initialDeployDays
: 0;
const buyBudget = (baseDailyBuy + initialBudget) * buyWeight(row, opts);
const usd = Math.min(cash, buyBudget);
const usd = cappedBuyUsd(cash, buyBudget, opts);
if (usd + 1e-9 < Math.min(cash, buyBudget)) {
cappedBuyDays += 1;
}
if (usd > 0) {
btc += usd / row.price;
cash -= usd;
@@ -451,6 +482,7 @@ function simulateSignal(rows, startIndex, opts, sellEnabled) {
}
const value = cash + btc * row.price;
minCash = Math.min(minCash, cash);
peakValue = Math.max(peakValue, value);
maxDrawdown = Math.max(maxDrawdown, peakValue === 0 ? 0 : 1 - value / peakValue);
}
@@ -460,8 +492,10 @@ function simulateSignal(rows, startIndex, opts, sellEnabled) {
finalDate: finalRow.date,
finalValue: cash + btc * finalRow.price,
cash,
minCash,
btc,
buys,
cappedBuyDays,
sells,
boughtUsd,
soldUsd,
@@ -470,6 +504,43 @@ function simulateSignal(rows, startIndex, opts, sellEnabled) {
};
}
function cappedBuyUsd(cash, buyBudget, opts) {
let cap = buyBudget;
if (opts.buyRunwayMonths > 0) {
cap = Math.min(cap, cash / (DAYS_PER_MONTH * opts.buyRunwayMonths));
}
if (opts.minCashReserveMonths > 0) {
const reserve = opts.monthlyTopup * opts.minCashReserveMonths;
cap = Math.min(cap, Math.max(0, cash - reserve));
}
return Math.min(cash, cap);
}
function inferPhase(rows, startIndex, opts) {
let buyActive = false;
let sellArmed = false;
for (let i = 0; i < startIndex; i += 1) {
const row = rows[i];
const p50 = row.percentiles.get(opts.buyTriggerPct);
if (Number.isFinite(p50) && p50 > 0 && row.price <= p50) {
buyActive = true;
sellArmed = false;
}
if (buyActive && isSellArmTouch(row, rows[i - 1], opts)) {
buyActive = false;
sellArmed = true;
}
}
return { buyActive, sellArmed };
}
function simulateLumpAndTopup(rows, startIndex, opts) {
let cash = opts.initialCash;
let btc = cash / rows[startIndex].price;
@@ -524,7 +595,7 @@ function simulateSimpleDailyDca(rows, startIndex, opts) {
}
function buyWeight(row, opts) {
let weight = 1.0;
let weight = 0;
for (const [pct, pctWeight] of [...opts.buyLevels.entries()].sort(
([a], [b]) => b - a,
)) {
@@ -603,7 +674,8 @@ function printTable(results, opts) {
[
`Data: ${opts.start}${opts.end ? ` to ${opts.end}` : " to latest"}`,
`Cash: ${usd(opts.initialCash)} initial + ${usd(opts.monthlyTopup)} monthly`,
`Buy trigger: p${opts.buyTriggerPct} touch starts DCA-in; ATH touch stops it`,
`Buy runway: ${formatBuyRunway(opts)}`,
`Buy trigger: p${opts.buyTriggerPct} touch starts DCA-in; p${opts.sellArmPct} increase stops it`,
`Sell: ${
opts.mode === "hold"
? "disabled"
@@ -624,8 +696,10 @@ function printTable(results, opts) {
final: usd(result.final_value),
ret: `${formatNumber(result.return_pct, 2)}%`,
cash: usd(result.cash),
min_cash: usd(result.min_cash),
btc: formatNumber(result.btc, 6),
buys: String(result.buys),
capped_buys: String(result.capped_buy_days),
sells: String(result.sells),
dd: `${formatNumber(result.max_drawdown_pct, 2)}%`,
vs_lump: `${formatNumber(result.lump_delta_pct, 2)}%`,
@@ -639,8 +713,10 @@ function printTable(results, opts) {
["final", "final"],
["ret", "return"],
["cash", "cash"],
["min_cash", "min cash"],
["btc", "btc"],
["buys", "buys"],
["capped_buys", "capped buys"],
["sells", "sells"],
["dd", "max dd"],
["vs_lump", "vs lump"],
@@ -648,6 +724,21 @@ function printTable(results, opts) {
]);
}
function formatBuyRunway(opts) {
const parts = [];
if (opts.buyRunwayMonths > 0) {
parts.push(
`daily cap preserves ${formatNumber(opts.buyRunwayMonths, 2)} month(s)`,
);
}
if (opts.minCashReserveMonths > 0) {
parts.push(
`cash floor ${formatNumber(opts.minCashReserveMonths, 2)} month(s) top-up`,
);
}
return parts.length ? parts.join("; ") : "none";
}
function printCsv(results) {
const keys = Object.keys(results[0] ?? {});
console.log(keys.join(","));
+402
View File
@@ -0,0 +1,402 @@
#!/usr/bin/env node
const PCTS = [100, 95, 90, 85, 80, 75, 70, 65, 60, 55, 50, 45, 40];
const DAYS_PER_MONTH = 365.2425 / 12;
function parseArgs(argv) {
const opts = {
baseUrl: "https://bitview.space",
dataStart: "2014-01-01",
end: "2026-06-06",
startFrom: "2017-12-01",
startTo: "2025-08-01",
starts: null,
initialCash: 10_000,
monthlyTopup: 1_000,
initialDeployDays: 365,
topMin: 0.25,
topMax: 8,
bottomMin: 0.25,
bottomMax: 16,
step: 0.25,
sellMin: 0,
sellMax: 10,
sellStep: 1,
topN: 12,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = () => {
i += 1;
if (i >= argv.length) throw new Error(`Missing value for ${arg}`);
return argv[i];
};
if (arg === "--base-url") opts.baseUrl = next();
else if (arg === "--data-start") opts.dataStart = next();
else if (arg === "--end") opts.end = next();
else if (arg === "--start-from") opts.startFrom = next();
else if (arg === "--start-to") opts.startTo = next();
else if (arg === "--starts") opts.starts = next().split(",").filter(Boolean);
else if (arg === "--top-min") opts.topMin = Number(next());
else if (arg === "--top-max") opts.topMax = Number(next());
else if (arg === "--bottom-min") opts.bottomMin = Number(next());
else if (arg === "--bottom-max") opts.bottomMax = Number(next());
else if (arg === "--step") opts.step = Number(next());
else if (arg === "--sell-min") opts.sellMin = Number(next());
else if (arg === "--sell-max") opts.sellMax = Number(next());
else if (arg === "--sell-step") opts.sellStep = Number(next());
else if (arg === "--top-n") opts.topN = Number(next());
else throw new Error(`Unknown argument: ${arg}`);
}
return opts;
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
const rows = await loadRows(opts);
const starts = resolveStarts(rows, opts);
const startStates = starts.map((start) => ({
...start,
phase: inferPhase(rows, start.index),
dca: simulateSimpleDailyDca(rows, start.index, opts),
lump: simulateLumpAndTopup(rows, start.index, opts),
}));
const baselineWeights = linearWeights(1, 7);
const baseline = scoreVariant(rows, startStates, baselineWeights, 1, opts);
const candidates = [];
for (const top of range(opts.topMin, opts.topMax, opts.step)) {
const bottomStart = Math.max(opts.bottomMin, top);
for (const bottom of range(bottomStart, opts.bottomMax, opts.step)) {
const weights = linearWeights(top, bottom);
for (const sellMultiple of range(opts.sellMin, opts.sellMax, opts.sellStep)) {
const score = scoreVariant(rows, startStates, weights, sellMultiple, opts);
candidates.push({
top,
bottom,
sellMultiple,
...score,
});
}
}
}
candidates.sort((a, b) => b.avgVsDca - a.avgVsDca);
console.log(
[
`Starts: ${starts[0].date} to ${starts.at(-1).date} (${starts.length})`,
`Data end: ${rows.at(-1).date}`,
`Baseline p100=1 -> p40=7, sell x1: avg vs DCA ${fmtPct(baseline.avgVsDca)}, median vs DCA ${fmtPct(baseline.medianVsDca)}, worst vs DCA ${fmtPct(baseline.worstVsDca)}, avg final ${usd(baseline.avgFinal)}`,
"",
"rank,p100,p40,sell_x,avg_vs_dca,median_vs_dca,worst_vs_dca,avg_vs_lump,avg_final,wins_vs_baseline",
].join("\n"),
);
for (const [index, candidate] of candidates.slice(0, opts.topN).entries()) {
console.log(
[
index + 1,
fmt(candidate.top),
fmt(candidate.bottom),
fmt(candidate.sellMultiple),
fmtPct(candidate.avgVsDca),
fmtPct(candidate.medianVsDca),
fmtPct(candidate.worstVsDca),
fmtPct(candidate.avgVsLump),
usd(candidate.avgFinal),
candidate.finals.filter((value, i) => value > baseline.finals[i]).length,
].join(","),
);
}
}
async function loadRows(opts) {
const names = [
"date",
"price",
"price_ath",
...PCTS.map(costBasisSeriesName),
];
const loaded = new Map(
await Promise.all(names.map(async (name) => [name, await fetchSeries(opts, name)])),
);
const len = Math.min(...names.map((name) => loaded.get(name).data.length));
const rows = [];
for (let i = 0; i < len; i += 1) {
const row = {
date: loaded.get("date").data[i],
price: loaded.get("price").data[i],
ath: loaded.get("price_ath").data[i],
previousAth: i > 0 ? loaded.get("price_ath").data[i - 1] : loaded.get("price_ath").data[i],
levels: {},
};
for (const pct of PCTS) {
row.levels[pct] = loaded.get(costBasisSeriesName(pct)).data[i];
}
rows.push(row);
}
return rows;
}
async function fetchSeries(opts, series) {
const url = new URL(`/api/series/${series}/day1`, normalizeBaseUrl(opts.baseUrl));
url.searchParams.set("start", opts.dataStart);
if (opts.end) url.searchParams.set("end", opts.end);
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch ${series}: ${response.status}`);
const json = await response.json();
if (!Array.isArray(json.data)) throw new Error(`${series} returned no data array`);
return json;
}
function resolveStarts(rows, opts) {
const dates = opts.starts ?? monthStarts(opts.startFrom, opts.startTo);
return dates.map((date) => ({
date,
index: findDateIndex(rows, date),
}));
}
function monthStarts(from, to) {
const dates = [];
for (
const cursor = new Date(`${from}T00:00:00Z`), end = new Date(`${to}T00:00:00Z`);
cursor <= end;
cursor.setUTCMonth(cursor.getUTCMonth() + 1)
) {
dates.push(cursor.toISOString().slice(0, 10));
}
return dates;
}
function findDateIndex(rows, date) {
const index = rows.findIndex((row) => row.date >= date);
if (index === -1) throw new Error(`Date ${date} is outside loaded data`);
return index;
}
function inferPhase(rows, startIndex) {
let buyActive = false;
let sellArmed = false;
for (let i = 0; i < startIndex; i += 1) {
const row = rows[i];
const p50 = row.levels[50];
if (Number.isFinite(p50) && p50 > 0 && row.price <= p50) {
buyActive = true;
sellArmed = false;
}
if (buyActive && isP100Increase(row, rows[i - 1])) {
buyActive = false;
sellArmed = true;
}
}
return { buyActive, sellArmed };
}
function scoreVariant(rows, starts, weights, sellMultiple, opts) {
const finals = [];
const vsDca = [];
const vsLump = [];
const drawdowns = [];
for (const start of starts) {
const result = simulateSignal(rows, start.index, start.phase, weights, sellMultiple, opts);
finals.push(result.finalValue);
vsDca.push(result.finalValue / start.dca.finalValue - 1);
vsLump.push(result.finalValue / start.lump.finalValue - 1);
drawdowns.push(result.maxDrawdown);
}
return {
finals,
avgFinal: avg(finals),
avgVsDca: avg(vsDca),
medianVsDca: median(vsDca),
worstVsDca: Math.min(...vsDca),
avgVsLump: avg(vsLump),
avgDrawdown: avg(drawdowns),
};
}
function simulateSignal(rows, startIndex, phase, weights, sellMultiple, opts) {
let cash = opts.initialCash;
let btc = 0;
let buyActive = phase.buyActive;
let sellArmed = phase.sellArmed;
let initialDeployActiveDays = 0;
let peakValue = cash;
let maxDrawdown = 0;
const baseDailyBuy = opts.monthlyTopup / DAYS_PER_MONTH;
for (let i = startIndex; i < rows.length; i += 1) {
const row = rows[i];
if (i > startIndex && isMonthStart(row.date)) cash += opts.monthlyTopup;
const p50 = row.levels[50];
if (Number.isFinite(p50) && p50 > 0 && row.price <= p50) {
buyActive = true;
sellArmed = false;
}
if (buyActive && isP100Increase(row, rows[i - 1])) {
buyActive = false;
sellArmed = true;
}
if (sellMultiple > 0 && sellArmed && isInsideSellBand(row) && btc > 0) {
const btcToSell = btc * Math.min(1, 0.005 * sellMultiple);
btc -= btcToSell;
cash += btcToSell * row.price;
}
if (buyActive && cash > 0) {
const initialBudget =
initialDeployActiveDays < opts.initialDeployDays
? opts.initialCash / opts.initialDeployDays
: 0;
const usd = Math.min(
cash,
(baseDailyBuy + initialBudget) * buyWeight(row, weights),
);
if (usd > 0) {
btc += usd / row.price;
cash -= usd;
initialDeployActiveDays += 1;
}
}
const value = cash + btc * row.price;
peakValue = Math.max(peakValue, value);
maxDrawdown = Math.max(maxDrawdown, peakValue === 0 ? 0 : 1 - value / peakValue);
}
return {
finalValue: cash + btc * rows.at(-1).price,
maxDrawdown,
};
}
function simulateLumpAndTopup(rows, startIndex, opts) {
let btc = opts.initialCash / rows[startIndex].price;
for (let i = startIndex + 1; i < rows.length; i += 1) {
if (isMonthStart(rows[i].date)) btc += opts.monthlyTopup / rows[i].price;
}
return { finalValue: btc * rows.at(-1).price };
}
function simulateSimpleDailyDca(rows, startIndex, opts) {
let cash = opts.initialCash;
let btc = 0;
const baseDailyBuy = opts.monthlyTopup / DAYS_PER_MONTH;
for (let i = startIndex; i < rows.length; i += 1) {
const row = rows[i];
if (i > startIndex && isMonthStart(row.date)) cash += opts.monthlyTopup;
const elapsedDays = i - startIndex;
const initialBudget =
elapsedDays < opts.initialDeployDays ? opts.initialCash / opts.initialDeployDays : 0;
const usd = Math.min(cash, baseDailyBuy + initialBudget);
if (usd > 0) {
btc += usd / row.price;
cash -= usd;
}
}
return { finalValue: cash + btc * rows.at(-1).price };
}
function linearWeights(top, bottom) {
const weights = {};
for (let i = 0; i < PCTS.length; i += 1) {
weights[PCTS[i]] = top + ((bottom - top) * i) / (PCTS.length - 1);
}
return weights;
}
function buyWeight(row, weights) {
let weight = 0;
for (const pct of PCTS) {
const level = row.levels[pct];
if (Number.isFinite(level) && level > 0 && row.price <= level) {
weight = Math.max(weight, weights[pct]);
}
}
return weight;
}
function isP100Increase(row, previousRow) {
const level = row.levels[100];
const previousLevel = previousRow?.levels[100];
return Number.isFinite(level) && Number.isFinite(previousLevel) && level > previousLevel;
}
function isInsideSellBand(row) {
const p95 = row.levels[95];
const p100 = row.levels[100];
if (!Number.isFinite(p95) || !Number.isFinite(p100) || p95 <= 0 || p100 <= 0) {
return false;
}
const lower = Math.min(p95, p100);
const upper = Math.max(p95, p100);
return row.price >= lower && row.price <= upper;
}
function costBasisSeriesName(pct) {
if (pct === 100) return "cost_basis_max";
return `cost_basis_per_coin_pct${String(pct).padStart(2, "0")}`;
}
function normalizeBaseUrl(baseUrl) {
return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
}
function isMonthStart(date) {
return date.endsWith("-01");
}
function range(min, max, step) {
const values = [];
const scale = 1 / step;
for (let value = min; value <= max + step / 10; value += step) {
values.push(Math.round(value * scale) / scale);
}
return values;
}
function avg(values) {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function median(values) {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}
function fmt(value) {
return String(Number(value.toFixed(2)));
}
function fmtPct(value) {
return `${(value * 100).toFixed(2)}%`;
}
function usd(value) {
return `$${(value / 1000).toFixed(1)}k`;
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
+370 -91
View File
@@ -5,111 +5,281 @@ import { colors } from "../colors.js";
export const canCapture = !ios || canShare;
/**
* @param {Object} args
* @param {HTMLCanvasElement} args.screenshot
* @param {number} args.chartWidth
* @param {HTMLElement} args.parent
* @param {{ element: HTMLElement }[]} args.legends
* @typedef {Object} LegendItem
* @property {string} text
* @property {string[]} colors
* @property {boolean} muted
*
* @typedef {Object} LegendCapture
* @property {number} x
* @property {number} y
* @property {number} width
* @property {LegendItem[]} items
*
* @typedef {Object} LegendMetrics
* @property {number} dot
* @property {number} fontSize
* @property {number} itemGap
* @property {number} lineHeight
* @property {number} rowGap
* @property {number} textGap
*/
export function capture({ screenshot, chartWidth, parent, legends }) {
const dpr = screenshot.width / chartWidth;
const pad = Math.round(16 * dpr);
const fontSize = Math.round(14 * dpr);
const titleFontSize = Math.round(20 * dpr);
const circleRadius = Math.round(5 * dpr);
const legendHeight = Math.round(28 * dpr);
const titleHeight = Math.round(36 * dpr);
const title = (parent.querySelector("h1")?.textContent ?? "").toUpperCase();
const hasTitle = title.length > 0;
const hasTopLegend = legends[0].element.children.length > 0;
const hasBottomLegend = legends[1].element.children.length > 0;
const titleOffset = hasTitle ? titleHeight : 0;
const topLegendOffset = hasTopLegend ? legendHeight : 0;
const bottomOffset = hasBottomLegend ? legendHeight : 0;
/**
* @param {string} value
* @param {number} [fallback]
*/
function cssPx(value, fallback = 0) {
return Number.parseFloat(value) || fallback;
}
const canvas = document.createElement("canvas");
canvas.width = screenshot.width + pad * 2;
canvas.height =
screenshot.height + pad * 2 + titleOffset + topLegendOffset + bottomOffset;
const ctx = canvas.getContext("2d");
if (!ctx) return;
/**
* @param {CSSStyleDeclaration} computedStyle
* @param {number} size
*/
function canvasFont(computedStyle, size) {
return [
computedStyle.fontStyle,
computedStyle.fontWeight,
`${size}px`,
computedStyle.fontFamily || style.fontFamily,
].join(" ");
}
// Background
const bodyBg = getComputedStyle(document.body).backgroundColor;
const htmlBg = getComputedStyle(document.documentElement).backgroundColor;
ctx.fillStyle = bodyBg === "rgba(0, 0, 0, 0)" ? htmlBg : bodyBg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
/**
* @param {CanvasRenderingContext2D} ctx
* @param {CSSStyleDeclaration} computedStyle
* @param {number} size
* @param {number} [letterSpacing]
*/
function setFont(ctx, computedStyle, size, letterSpacing = 0) {
ctx.font = canvasFont(computedStyle, size);
if ("letterSpacing" in ctx) ctx.letterSpacing = `${letterSpacing}px`;
}
/** @param {HTMLElement} legendEl @param {number} y */
const drawLegend = (legendEl, y) => {
ctx.font = `${fontSize}px ${style.fontFamily}`;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
let x = pad;
for (const div of legendEl.children) {
const label = div.querySelector("label");
if (!label) continue;
const input = label.querySelector("input");
if (input && !input.checked) continue;
// Draw color circles
const colorSpans = label.querySelectorAll(".colors span");
for (const span of colorSpans) {
ctx.fillStyle = /** @type {HTMLElement} */ (span).style.backgroundColor;
ctx.beginPath();
ctx.arc(x + circleRadius, y, circleRadius, 0, Math.PI * 2);
ctx.fill();
x += circleRadius * 2 + Math.round(2 * dpr);
}
// Draw name
const name = label.querySelector(".name")?.textContent ?? "";
ctx.fillStyle = colors.default();
ctx.fillText(name, x + Math.round(4 * dpr), y);
x += ctx.measureText(name).width + Math.round(20 * dpr);
/**
* @param {string} text
* @param {HTMLElement} element
*/
function transformText(text, element) {
switch (getComputedStyle(element).textTransform) {
case "lowercase":
return text.toLowerCase();
case "uppercase":
return text.toUpperCase();
case "capitalize":
return text.replace(/\b\w/g, (char) => char.toUpperCase());
default:
return text;
}
}
/** @param {HTMLElement} element */
function visibleText(element) {
const select = element.querySelector("select");
if (!(select instanceof HTMLSelectElement)) {
return transformText(element.textContent?.trim() ?? "", element);
}
const selected =
select.selectedOptions[0]?.textContent?.trim() || select.value.trim();
return transformText(selected, element);
}
/**
* @param {string} text
* @param {Partial<LegendItem>} [item]
* @returns {LegendItem[]}
*/
function legendText(text, item = {}) {
return text
? [
{
text,
colors: [],
muted: false,
...item,
},
]
: [];
}
/**
* @param {HTMLElement} legend
* @returns {LegendItem[]}
*/
function legendItems(legend) {
const scroller = legend.firstElementChild;
if (!(scroller instanceof HTMLElement)) return [];
const children = Array.from(scroller.children);
const seriesRoot =
children.find((child) =>
child.querySelector('label input[type="checkbox"]'),
) ?? children.at(-1);
const prefix = children[0] !== seriesRoot ? children[0] : null;
const separator = prefix ? children[1] : null;
/** @type {LegendItem[]} */
const seriesItems = [];
const root = seriesRoot instanceof HTMLElement ? seriesRoot : scroller;
for (const label of root.querySelectorAll("label")) {
const input = label.querySelector('input[type="checkbox"]');
const name = label.querySelector(".name");
if (
!(input instanceof HTMLInputElement) ||
!(name instanceof HTMLElement) ||
!input.checked
) {
continue;
}
const text = transformText(name.textContent?.trim() ?? "", name);
if (!text) continue;
seriesItems.push({
text,
muted: false,
colors: Array.from(label.querySelectorAll(".colors span"))
.map((span) =>
span instanceof HTMLElement ? span.style.backgroundColor : "",
)
.filter(Boolean),
});
}
if (!seriesItems.length) return [];
const prefixText = prefix instanceof HTMLElement ? visibleText(prefix) : "";
return [
...legendText(prefixText),
...(prefixText && separator instanceof HTMLElement
? legendText(visibleText(separator), { muted: true })
: []),
...seriesItems,
];
}
/**
* @param {HTMLElement} legend
* @param {DOMRect} parentRect
* @param {(value: number) => number} scale
* @returns {LegendCapture}
*/
function captureLegend(legend, parentRect, scale) {
const scroller = legend.firstElementChild;
if (!(scroller instanceof HTMLElement)) {
return { x: 0, y: 0, width: 0, items: [] };
}
const rect = scroller.getBoundingClientRect();
const computedStyle = getComputedStyle(scroller);
const left = cssPx(computedStyle.paddingLeft);
const right = cssPx(computedStyle.paddingRight);
return {
x: scale(rect.left - parentRect.left + left),
y: scale(rect.top - parentRect.top + cssPx(computedStyle.paddingTop, 6)),
width: Math.max(0, scale(rect.width - left - right)),
items: legendItems(legend),
};
}
// Title
if (hasTitle) {
ctx.font = `${titleFontSize}px ${style.fontFamily}`;
ctx.fillStyle = colors.default();
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(title, pad, pad + titleHeight / 2);
/**
* @param {CanvasRenderingContext2D} ctx
* @param {LegendItem} item
* @param {LegendMetrics} metrics
*/
function measureItem(ctx, item, metrics) {
const swatch = item.colors.length ? metrics.dot * 2 + metrics.textGap : 0;
return swatch + ctx.measureText(item.text).width + metrics.itemGap;
}
/**
* @param {CanvasRenderingContext2D} ctx
* @param {LegendItem[]} items
* @param {number} width
* @param {LegendMetrics} metrics
*/
function layoutLegend(ctx, items, width, metrics) {
/** @type {LegendItem[][]} */
const rows = [];
/** @type {LegendItem[]} */
let row = [];
let rowWidth = 0;
for (const item of items) {
const itemWidth = measureItem(ctx, item, metrics);
if (row.length && rowWidth + itemWidth > width) {
rows.push(row);
row = [];
rowWidth = 0;
}
row.push(item);
rowWidth += itemWidth;
}
// Top legend
if (hasTopLegend) {
drawLegend(legends[0].element, pad + titleOffset + topLegendOffset / 2);
}
if (row.length) rows.push(row);
return rows;
}
// Chart
ctx.drawImage(screenshot, pad, pad + titleOffset + topLegendOffset);
/**
* @param {CanvasRenderingContext2D} ctx
* @param {string[]} itemColors
* @param {number} x
* @param {number} y
* @param {number} radius
*/
function drawSwatch(ctx, itemColors, x, y, radius) {
ctx.save();
ctx.beginPath();
ctx.arc(x + radius, y, radius, 0, Math.PI * 2);
ctx.clip();
// Bottom legend
if (hasBottomLegend) {
drawLegend(
legends[1].element,
pad +
titleOffset +
topLegendOffset +
screenshot.height +
legendHeight / 2,
);
}
itemColors.forEach((color, index) => {
const h = (radius * 2) / itemColors.length;
ctx.fillStyle = color;
ctx.fillRect(x, y - radius + index * h, radius * 2, h);
});
// Watermark
ctx.fillStyle = colors.gray();
ctx.font = `${fontSize}px ${style.fontFamily}`;
ctx.textAlign = "right";
ctx.textBaseline = "bottom";
ctx.fillText(
window.location.host,
canvas.width - pad,
canvas.height - pad / 2,
);
ctx.restore();
}
// Open in new tab
/**
* @param {CanvasRenderingContext2D} ctx
* @param {LegendItem[][]} rows
* @param {number} x
* @param {number} y
* @param {LegendMetrics} metrics
*/
function drawLegend(ctx, rows, x, y, metrics) {
ctx.textAlign = "left";
ctx.textBaseline = "middle";
rows.forEach((row, rowIndex) => {
let itemX = x;
const itemY =
y + metrics.lineHeight / 2 + rowIndex * (metrics.lineHeight + metrics.rowGap);
for (const item of row) {
if (item.colors.length) {
drawSwatch(ctx, item.colors, itemX, itemY, metrics.dot);
itemX += metrics.dot * 2 + metrics.textGap;
}
const textWidth = ctx.measureText(item.text).width;
ctx.fillStyle = item.muted ? colors.gray() : colors.default();
ctx.fillText(item.text, itemX, itemY);
itemX += textWidth + metrics.itemGap;
}
});
}
/** @param {HTMLCanvasElement} canvas */
function openCanvas(canvas) {
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
@@ -117,3 +287,112 @@ export function capture({ screenshot, chartWidth, parent, legends }) {
setTimeout(() => URL.revokeObjectURL(url), 100);
}, "image/png");
}
/**
* @param {Object} args
* @param {HTMLCanvasElement} args.screenshot
* @param {number} args.chartWidth
* @param {HTMLElement} args.chartElement
* @param {HTMLElement} args.parent
* @param {{ element: HTMLElement }[]} args.legends
*/
export function capture({
screenshot,
chartWidth,
chartElement,
parent,
legends,
}) {
const dpr =
chartWidth > 0 ? screenshot.width / chartWidth : window.devicePixelRatio;
const scale = (/** @type {number} */ value) => Math.round(value * dpr);
const parentRect = parent.getBoundingClientRect();
const chartRect = chartElement.getBoundingClientRect();
const parentStyle = getComputedStyle(parent);
const rootStyle = getComputedStyle(document.documentElement);
const pad = scale(
cssPx(
parentStyle.paddingLeft,
cssPx(rootStyle.getPropertyValue("--main-padding"), 32),
),
);
const chartX = scale(chartRect.left - parentRect.left);
const chartY = scale(chartRect.top - parentRect.top);
const title = parent.querySelector("h1");
const titleText = title?.textContent?.trim() ?? "";
const titleStyle = title
? getComputedStyle(title)
: getComputedStyle(document.documentElement);
const titleSize = scale(cssPx(titleStyle.fontSize, 32));
const legendStyle = getComputedStyle(legends[0].element);
const metrics = {
dot: scale(5),
fontSize: scale(cssPx(legendStyle.fontSize, 12)),
itemGap: scale(16),
lineHeight: scale(
cssPx(legendStyle.lineHeight, cssPx(legendStyle.fontSize, 12) * 1.333),
),
rowGap: scale(3),
textGap: scale(4),
};
const top = captureLegend(legends[0].element, parentRect, scale);
const bottom = captureLegend(legends[1].element, parentRect, scale);
const measureCtx = document.createElement("canvas").getContext("2d");
if (!measureCtx) return;
setFont(measureCtx, legendStyle, metrics.fontSize);
const topRows = layoutLegend(measureCtx, top.items, top.width, metrics);
const bottomRows = layoutLegend(measureCtx, bottom.items, bottom.width, metrics);
const canvas = document.createElement("canvas");
canvas.width = Math.max(screenshot.width + chartX, scale(parentRect.width));
canvas.height = Math.max(
chartY + screenshot.height + pad,
scale(parentRect.height),
);
const ctx = canvas.getContext("2d");
if (!ctx) return;
const bodyBg = getComputedStyle(document.body).backgroundColor;
const htmlBg = getComputedStyle(document.documentElement).backgroundColor;
ctx.fillStyle = bodyBg === "rgba(0, 0, 0, 0)" ? htmlBg : bodyBg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (titleText && title instanceof HTMLElement) {
const rect = title.getBoundingClientRect();
setFont(
ctx,
titleStyle,
titleSize,
scale(cssPx(titleStyle.letterSpacing)),
);
ctx.fillStyle = colors.default();
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(
titleText,
scale(rect.left - parentRect.left),
scale(rect.top - parentRect.top + rect.height / 2),
);
}
ctx.drawImage(screenshot, chartX, chartY);
setFont(ctx, legendStyle, metrics.fontSize);
drawLegend(ctx, topRows, top.x, top.y, metrics);
drawLegend(ctx, bottomRows, bottom.x, bottom.y, metrics);
ctx.fillStyle = colors.gray();
setFont(ctx, legendStyle, metrics.fontSize);
ctx.textAlign = "right";
ctx.textBaseline = "bottom";
ctx.fillText(window.location.host, canvas.width - pad, canvas.height - pad / 2);
openCanvas(canvas);
}
+3
View File
@@ -65,6 +65,7 @@ import { Unit } from "../units.js";
* @typedef {Object} Legend
* @property {HTMLLegendElement} element
* @property {function(HTMLElement): void} setPrefix
* @property {function(): void} clearPrefix
* @property {function({ series: AnySeries, name: string, order: number, colors: Color[] }): void} addOrReplace
* @property {function(number): void} removeFrom
*/
@@ -1620,6 +1621,7 @@ export function createChart({ parent, brk, fitContent }) {
const units = Array.from(map.keys());
if (!units.length) {
blueprints.panes[paneIndex].unit = null;
legends[paneIndex].clearPrefix();
return;
}
@@ -1678,6 +1680,7 @@ export function createChart({ parent, brk, fitContent }) {
capture({
screenshot: ichart.takeScreenshot(),
chartWidth: chartEl.clientWidth,
chartElement: chartEl,
parent,
legends,
});
+10 -6
View File
@@ -29,20 +29,23 @@ export function createLegend() {
const separator = createSpan("|");
captureScroll(separator);
/** @type {HTMLElement | null} */
let prefix = null;
return {
element,
scroller,
/** @param {HTMLElement} el */
setPrefix(el) {
const prev = separator.previousSibling;
if (prev) {
prev.replaceWith(el);
} else {
scroller.prepend(el, separator);
}
prefix ? prefix.replaceWith(el) : scroller.prepend(el, separator);
prefix = el;
captureScroll(el);
},
clearPrefix() {
prefix?.remove();
prefix = null;
separator.remove();
},
};
}
@@ -76,6 +79,7 @@ export function createSeriesLegend() {
return {
element: legend.element,
setPrefix: legend.setPrefix,
clearPrefix: legend.clearPrefix,
/**
* @param {Object} args
* @param {AnySeries} args.series
+5
View File
@@ -99,6 +99,11 @@
<link rel="stylesheet" href="/home/style.css" />
<link rel="stylesheet" href="/explore/style.css" />
<link rel="stylesheet" href="/learn/style.css" />
<link rel="stylesheet" href="/learn/charts/style.css" />
<link rel="stylesheet" href="/learn/charts/bar/style.css" />
<link rel="stylesheet" href="/learn/charts/line/style.css" />
<link rel="stylesheet" href="/learn/charts/dots/style.css" />
<link rel="stylesheet" href="/learn/charts/stacked/style.css" />
<link rel="stylesheet" href="/learn/contents/style.css" />
<link rel="stylesheet" href="/build/style.css" />
<!-- /IMPORTMAP -->
+83
View File
@@ -0,0 +1,83 @@
import { createLinePathData, formatCoordinate } from "../path.js";
import { createSvgElement } from "../svg.js";
import { VIEWBOX_WIDTH } from "../viewbox.js";
import { createStackedSeries } from "../stacked/series.js";
/**
* @param {number} value
* @param {number} min
* @param {number} max
*/
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
/** @param {{ x: number, y0: number, y1: number }[]} points */
function getBarWidth(points) {
return points.length > 1 ? (VIEWBOX_WIDTH / (points.length - 1)) * 0.8 : 1;
}
/**
* @param {{ x: number, y0: number, y1: number }[]} points
* @param {number} width
*/
function createBarPathData(points, width) {
return points
.map(({ x, y0, y1 }) => {
const left = clamp(x - width / 2, 0, VIEWBOX_WIDTH - width);
const right = left + width;
const top = Math.min(y0, y1);
const bottom = Math.max(y0, y1);
return (
`M${formatCoordinate(left)} ${formatCoordinate(top)}` +
`H${formatCoordinate(right)}V${formatCoordinate(bottom)}` +
`H${formatCoordinate(left)}Z`
);
})
.join(" ");
}
/**
* @param {SVGGElement} group
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {SeriesHighlight} highlight
* @param {{ reversed: boolean }} options
*/
export function renderBarPlot(group, loadedSeries, height, highlight, options) {
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
loadedSeries,
height,
options.reversed,
);
for (const index of stackIndexes) {
const { color, points } = plottedSeries[index];
const path = createSvgElement("path");
path.dataset.chart = "bar";
path.dataset.series = index.toString();
path.style.setProperty("--color", color);
path.setAttribute("d", createBarPathData(points, getBarWidth(points)));
highlight.add(path, index);
group.append(path);
}
for (const index of lineIndexes) {
const { color, points } = plottedSeries[index];
const path = createSvgElement("path");
path.dataset.chart = "line";
path.dataset.series = index.toString();
path.style.setProperty("--color", color);
path.setAttribute("d", createLinePathData(points));
highlight.add(path, index);
group.append(path);
}
return plottedSeries;
}
/** @typedef {import("../highlight.js").SeriesHighlight} SeriesHighlight */
/** @typedef {import("../index.js").LoadedSeries} LoadedSeries */
+8
View File
@@ -0,0 +1,8 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="bar"] {
fill: var(--color, var(--orange));
stroke: none;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* @param {ChartSeries[]} series
* @returns {ChartSeries[]}
*/
export function createSeries(series) {
return series;
}
/**
* @param {ChartSeries} series
* @returns {ChartSeries}
*/
export function referenceLine(series) {
return { ...series, role: "line" };
}
/** @typedef {import("./index.js").ChartSeries} ChartSeries */
+43
View File
@@ -0,0 +1,43 @@
import { formatCoordinate } from "../path.js";
import { createSvgElement } from "../svg.js";
import { createLineSeries } from "../line/series.js";
const radius = 1;
/** @param {{ x: number, y: number }[]} points */
function createDotsPathData(points) {
return points
.map(
({ x, y }) =>
`M${formatCoordinate(x - radius)} ${formatCoordinate(y)}` +
`a${radius} ${radius} 0 1 0 ${radius * 2} 0` +
`a${radius} ${radius} 0 1 0 ${radius * -2} 0`,
)
.join(" ");
}
/**
* @param {SVGGElement} group
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {SeriesHighlight} highlight
*/
export function renderDotsPlot(group, loadedSeries, height, highlight) {
const plottedSeries = createLineSeries(loadedSeries, height);
plottedSeries.forEach(({ color, points }, index) => {
const path = createSvgElement("path");
path.dataset.chart = "dots";
path.dataset.series = index.toString();
path.style.setProperty("--color", color);
path.setAttribute("d", createDotsPathData(points));
highlight.add(path, index);
group.append(path);
});
return plottedSeries;
}
/** @typedef {import("../highlight.js").SeriesHighlight} SeriesHighlight */
/** @typedef {import("../index.js").LoadedSeries} LoadedSeries */
+8
View File
@@ -0,0 +1,8 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="dots"] {
fill: var(--color, var(--orange));
stroke: none;
}
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* @param {number} value
* @param {number} digits
*/
function formatNumber(value, digits) {
return value.toLocaleString("en-us", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
}
/** @param {number} value */
export function formatValue(value) {
const absolute = Math.abs(value);
if (absolute < 10) return formatNumber(value, 3);
if (absolute < 1_000) return formatNumber(value, 2);
if (absolute < 10_000) return formatNumber(value, 1);
if (absolute < 1_000_000) return formatNumber(value, 0);
if (absolute >= 1e27) return "Inf.";
const log = Math.floor(Math.log10(absolute) - 6);
const suffixes = ["M", "B", "T", "P", "E", "Z", "Y"];
const suffixIndex = Math.floor(log / 3);
const digits = 3 - (log % 3);
const scaled = value / (1_000_000 * 1_000 ** suffixIndex);
return `${formatNumber(scaled, digits)}${suffixes[suffixIndex]}`;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* @param {HTMLElement} target
* @param {() => void} onChange
*/
function listen(target, onChange) {
document.addEventListener("fullscreenchange", () => {
if (document.fullscreenElement === target || !document.fullscreenElement) {
onChange();
}
});
}
/** @param {HTMLElement} target */
export function createFullscreenButton(target) {
const button = document.createElement("button");
function update() {
const active = document.fullscreenElement === target;
button.textContent = active ? "Exit" : "Full";
button.setAttribute("aria-pressed", active.toString());
}
button.type = "button";
button.dataset.chart = "fullscreen";
button.addEventListener("click", () => {
if (document.fullscreenElement === target) {
void document.exitFullscreen();
} else {
void target.requestFullscreen();
}
});
listen(target, update);
update();
return button;
}
+102
View File
@@ -0,0 +1,102 @@
/** @returns {SeriesNode} */
function createSeriesNode() {
return [];
}
/**
* @param {HTMLElement[]} items
*/
export function createSeriesHighlight(items) {
const seriesNodes = items.map(createSeriesNode);
/** @param {number} index */
function scrollToItem(index) {
items[index].scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
}
/** @param {number} index */
function activate(index) {
for (const [itemIndex, item] of items.entries()) {
setActive(item, itemIndex === index);
}
seriesNodes.forEach((nodes, nodeIndex) => {
for (const node of nodes) {
setActive(node, nodeIndex === index);
}
});
}
function clear() {
for (const item of items) clearState(item);
for (const nodes of seriesNodes) {
for (const node of nodes) clearState(node);
}
}
items.forEach((item, index) => {
item.addEventListener("pointerenter", () => activate(index));
item.addEventListener("pointerleave", clear);
item.addEventListener("focus", () => activate(index));
item.addEventListener("blur", clear);
});
/**
* @param {SVGPathElement | SVGCircleElement} node
* @param {number} index
*/
function add(node, index) {
seriesNodes[index].push(node);
node.addEventListener("pointerenter", () => {
scrollToItem(index);
activate(index);
});
node.addEventListener("pointerleave", clear);
}
function clearNodes() {
clear();
for (const nodes of seriesNodes) {
nodes.length = 0;
}
}
return {
add,
clearNodes,
};
}
/**
* @param {HTMLElement | SVGElement} element
* @param {boolean} active
*/
function setActive(element, active) {
if (active) {
element.dataset.active = "";
delete element.dataset.muted;
} else {
delete element.dataset.active;
element.dataset.muted = "";
}
}
/** @param {HTMLElement | SVGElement} element */
function clearState(element) {
delete element.dataset.active;
delete element.dataset.muted;
}
/** @typedef {(SVGPathElement | SVGCircleElement)[]} SeriesNode */
/**
* @typedef {Object} SeriesHighlight
* @property {(node: SVGPathElement | SVGCircleElement, index: number) => void} add
* @property {() => void} clearNodes
*/
+268
View File
@@ -0,0 +1,268 @@
import { brk } from "../../utils/client.js";
import { renderBarPlot } from "./bar/index.js";
import { createFullscreenButton } from "./fullscreen.js";
import { createSeriesHighlight } from "./highlight.js";
import { onFirstIntersection } from "./intersection.js";
import { createLegend } from "./legend.js";
import { renderLinePlot } from "./line/index.js";
import { createScrubber } from "./scrubber.js";
import { renderDotsPlot } from "./dots/index.js";
import { createSvgElement } from "./svg.js";
import { renderStackedPlot } from "./stacked/index.js";
import {
createTimeframeControl,
fetchTimeframe,
getDefaultTimeframe,
saveTimeframe,
} from "./timeframes.js";
import {
createViewControl,
getDefaultView,
saveView,
} from "./views.js";
import {
FALLBACK_VIEWBOX_HEIGHT,
getViewBoxHeight,
VIEWBOX_WIDTH,
} from "./viewbox.js";
/** @typedef {import("./legend.js").Readout} Readout */
/** @typedef {import("./timeframes.js").TimeframeValue} TimeframeValue */
/** @typedef {import("./views.js").ChartView} ChartView */
/**
* @param {ChartResult} result
* @returns {{ date: Date, value: number }[]}
*/
function createEntries(result) {
/** @type {{ date: Date, value: number }[]} */
const entries = [];
/** @type {number | undefined} */
let lastValue;
for (const [date, value] of result.dateEntries()) {
if (typeof value === "number" && Number.isFinite(value)) lastValue = value;
if (lastValue !== undefined) entries.push({ date, value: lastValue });
}
return entries;
}
/**
* @param {Chart} chart
* @param {TimeframeValue} timeframe
* @returns {Promise<LoadedSeries[]>}
*/
async function loadSeries(chart, timeframe) {
return Promise.all(
chart.series.map(async (item) => ({
series: item,
color: item.color(),
entries: createEntries(await fetchTimeframe(item.metric(brk), timeframe)),
})),
);
}
/** @param {Chart} chart */
function createLoadedSeriesCache(chart) {
/** @type {Map<TimeframeValue, Promise<LoadedSeries[]>>} */
const cache = new Map();
/** @param {TimeframeValue} timeframe */
return function getLoadedSeries(timeframe) {
let promise = cache.get(timeframe);
if (!promise) {
promise = loadSeries(chart, timeframe).catch((error) => {
cache.delete(timeframe);
throw error;
});
cache.set(timeframe, promise);
}
return promise;
};
}
/**
* @param {ChartView} view
* @param {SVGGElement} group
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {SeriesHighlight} highlight
*/
function renderPlot(view, group, loadedSeries, height, highlight) {
switch (view) {
case "line":
return renderLinePlot(group, loadedSeries, height, highlight);
case "bar":
case "bar-reversed":
return renderBarPlot(group, loadedSeries, height, highlight, {
reversed: view === "bar-reversed",
});
case "dots":
return renderDotsPlot(group, loadedSeries, height, highlight);
default:
return renderStackedPlot(group, loadedSeries, height, highlight, {
reversed: view === "stacked-reversed",
});
}
}
/**
* @param {SVGSVGElement} svg
* @param {Readout} readout
* @param {HTMLElement[]} items
* @param {HTMLElement} status
* @param {Chart} chart
* @param {() => ChartView} getView
* @param {() => TimeframeValue} getTimeframe
*/
function createChartRenderer(
svg,
readout,
items,
status,
chart,
getView,
getTimeframe,
) {
const group = createSvgElement("g");
const highlight = createSeriesHighlight(items);
const getLoadedSeries = createLoadedSeriesCache(chart);
/** @type {LoadedSeries[]} */
let loadedSeries = [];
/** @type {ReturnType<typeof createScrubber> | undefined} */
let scrubber;
let loadId = 0;
svg.append(group);
function renderCurrent() {
if (!loadedSeries.length) return;
const height = getViewBoxHeight(svg);
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${height}`);
group.replaceChildren();
highlight.clearNodes();
scrubber ??= createScrubber(svg, readout, highlight);
scrubber.setSeries(
renderPlot(getView(), group, loadedSeries, height, highlight),
height,
);
}
async function loadCurrent() {
const id = (loadId += 1);
svg.setAttribute("aria-busy", "true");
try {
const nextSeries = await getLoadedSeries(getTimeframe());
if (id !== loadId) return;
loadedSeries = nextSeries;
renderCurrent();
status.textContent = "";
} catch (error) {
if (id !== loadId) return;
console.error(error);
status.textContent = "Chart unavailable";
} finally {
if (id === loadId) svg.removeAttribute("aria-busy");
}
}
new ResizeObserver(renderCurrent).observe(svg);
return {
loadCurrent,
renderCurrent,
};
}
/** @param {Chart} chart */
export function createChart(chart) {
const figure = document.createElement("figure");
const svg = createSvgElement("svg");
const controls = document.createElement("footer");
const timeControls = document.createElement("div");
const status = document.createElement("p");
const chartKey = chart.title;
let currentTimeframe = getDefaultTimeframe(chartKey);
let currentView = getDefaultView(chartKey);
const { legend, items, readout } = createLegend(chart);
figure.dataset.chart = "series";
figure.dataset.timeframe = currentTimeframe;
figure.dataset.view = currentView;
svg.setAttribute(
"viewBox",
`0 0 ${VIEWBOX_WIDTH} ${FALLBACK_VIEWBOX_HEIGHT}`,
);
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", chart.title);
svg.setAttribute("tabindex", "0");
status.setAttribute("aria-live", "polite");
status.setAttribute("role", "status");
const renderer = createChartRenderer(
svg,
readout,
items,
status,
chart,
() => currentView,
() => currentTimeframe,
);
const viewControl = createViewControl(currentView, (view) => {
currentView = view;
saveView(chartKey, view);
figure.dataset.view = view;
renderer.renderCurrent();
});
const timeframeControl = createTimeframeControl(
currentTimeframe,
(timeframe) => {
currentTimeframe = timeframe;
saveTimeframe(chartKey, timeframe);
figure.dataset.timeframe = timeframe;
void renderer.loadCurrent();
},
);
timeControls.append(timeframeControl, createFullscreenButton(figure));
controls.append(viewControl, timeControls);
figure.append(legend, svg, controls, status);
onFirstIntersection(figure, () => void renderer.loadCurrent());
return figure;
}
/**
* @typedef {Object} Chart
* @property {string} title
* @property {ChartSeries[]} series
*/
/**
* @typedef {Object} ChartSeries
* @property {string} label
* @property {() => string} color
* @property {"line"} [role]
* @property {(client: typeof brk) => import("./timeframes.js").TimeframeMetric} metric
*/
/**
* @typedef {Object} ChartResult
* @property {() => Iterable<[Date, number | null | undefined]>} dateEntries
*/
/**
* @typedef {Object} LoadedSeries
* @property {ChartSeries} series
* @property {string} color
* @property {{ date: Date, value: number }[]} entries
*/
/** @typedef {import("./highlight.js").SeriesHighlight} SeriesHighlight */
+14
View File
@@ -0,0 +1,14 @@
/**
* @param {Element} element
* @param {() => void} callback
*/
export function onFirstIntersection(element, callback) {
const observer = new IntersectionObserver((entries) => {
if (!entries[0].isIntersecting) return;
observer.disconnect();
callback();
});
observer.observe(element);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* @param {Chart} chart
* @returns {{ legend: HTMLElement, items: HTMLElement[], readout: Readout }}
*/
export function createLegend(chart) {
const legend = document.createElement("figcaption");
const header = document.createElement("header");
const title = document.createElement("span");
const time = document.createElement("time");
const menu = document.createElement("menu");
const rows = chart.series.map((series) => {
const item = document.createElement("li");
const button = document.createElement("button");
const label = document.createElement("span");
const value = document.createElement("output");
button.type = "button";
button.style.setProperty("--color", series.color());
label.append(series.label);
button.append(label, value);
item.append(button);
menu.append(item);
return { button, value };
});
const items = rows.map(({ button }) => button);
title.append(chart.title);
header.append(title);
header.append(time);
legend.append(header, menu);
return { legend, items, readout: { time, rows } };
}
/**
* @typedef {Object} Readout
* @property {HTMLTimeElement} time
* @property {{ value: HTMLOutputElement }[]} rows
*/
/** @typedef {import("./index.js").Chart} Chart */
+29
View File
@@ -0,0 +1,29 @@
import { createLinePathData } from "../path.js";
import { createSvgElement } from "../svg.js";
import { createLineSeries } from "./series.js";
/**
* @param {SVGGElement} group
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {SeriesHighlight} highlight
*/
export function renderLinePlot(group, loadedSeries, height, highlight) {
const plottedSeries = createLineSeries(loadedSeries, height);
plottedSeries.forEach(({ color, points }, index) => {
const path = createSvgElement("path");
path.dataset.chart = "line";
path.dataset.series = index.toString();
path.style.setProperty("--color", color);
path.setAttribute("d", createLinePathData(points));
highlight.add(path, index);
group.append(path);
});
return plottedSeries;
}
/** @typedef {import("../highlight.js").SeriesHighlight} SeriesHighlight */
/** @typedef {import("../index.js").LoadedSeries} LoadedSeries */
+53
View File
@@ -0,0 +1,53 @@
import { VIEWBOX_WIDTH } from "../viewbox.js";
/** @param {LoadedSeries[]} series */
function createValueBounds(series) {
let min = Infinity;
let max = -Infinity;
for (const { entries } of series) {
for (const { value } of entries) {
min = Math.min(min, value);
max = Math.max(max, value);
}
}
return { min, max };
}
/**
* @param {{ date: Date, value: number }[]} entries
* @param {{ min: number, max: number }} bounds
* @param {number} height
*/
function createPoints(entries, bounds, height) {
const xScale = VIEWBOX_WIDTH / (entries.length - 1);
const yScale =
bounds.max === bounds.min ? 0 : height / (bounds.max - bounds.min);
return entries.map(({ date, value }, index) => ({
date,
value,
x: index * xScale,
y:
bounds.max === bounds.min
? height / 2
: height - (value - bounds.min) * yScale,
}));
}
/**
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
*/
export function createLineSeries(loadedSeries, height) {
const bounds = createValueBounds(loadedSeries);
return loadedSeries.map(({ series, color, entries }) => ({
series,
color,
points: createPoints(entries, bounds, height),
}));
}
/** @typedef {import("../index.js").LoadedSeries} LoadedSeries */
+12
View File
@@ -0,0 +1,12 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="line"] {
fill: none;
stroke: var(--color, var(--orange));
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
/** @param {number} value */
export function formatCoordinate(value) {
return value.toFixed(2);
}
/**
* @param {string} command
* @param {number} x
* @param {number} y
*/
function createPathCommand(command, x, y) {
return `${command}${formatCoordinate(x)} ${formatCoordinate(y)}`;
}
/** @param {{ x: number, y: number }[]} points */
export function createLinePathData(points) {
return points
.map(({ x, y }, index) => createPathCommand(index ? "L" : "M", x, y))
.join(" ");
}
/** @param {{ x: number, y0: number, y1: number }[]} points */
export function createAreaPathData(points) {
const commands = points.map(({ x, y1 }, index) =>
createPathCommand(index ? "L" : "M", x, y1),
);
for (let index = points.length - 1; index >= 0; index -= 1) {
const { x, y0 } = points[index];
commands.push(createPathCommand("L", x, y0));
}
return `${commands.join(" ")} Z`;
}
+38
View File
@@ -0,0 +1,38 @@
let groupId = 0;
/**
* @template {string} T
* @param {Object} args
* @param {string} args.legend
* @param {{ value: T, label: string }[]} args.options
* @param {T} args.currentValue
* @param {(value: T) => void} args.onChange
*/
export function createRadioGroup(args) {
const fieldset = document.createElement("fieldset");
const legend = document.createElement("legend");
const name = `chart-control-${(groupId += 1)}`;
legend.append(args.legend);
fieldset.append(legend);
for (const option of args.options) {
const label = document.createElement("label");
const input = document.createElement("input");
const text = document.createElement("span");
input.type = "radio";
input.name = name;
input.value = option.value;
input.checked = option.value === args.currentValue;
input.addEventListener("change", () => {
if (input.checked) args.onChange(option.value);
});
text.append(option.label);
label.append(input, text);
fieldset.append(label);
}
return fieldset;
}
+163
View File
@@ -0,0 +1,163 @@
import { formatValue } from "./format.js";
import { createSvgElement } from "./svg.js";
import { VIEWBOX_WIDTH } from "./viewbox.js";
/** @typedef {import("./highlight.js").SeriesHighlight} SeriesHighlight */
/** @typedef {import("./legend.js").Readout} Readout */
const dateFormat = new Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
/**
* @param {number} value
* @param {number} min
* @param {number} max
*/
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
/**
* @param {ScrubberSeries} series
* @param {number} ratio
*/
function getPointAtRatio(series, ratio) {
return series.points[Math.round(ratio * (series.points.length - 1))];
}
/**
* @param {HTMLTimeElement} time
* @param {Date} date
*/
function updateTime(time, date) {
time.textContent = dateFormat.format(date);
time.dateTime = date.toISOString().slice(0, 10);
}
/**
* @param {Readout} readout
* @param {ReturnType<typeof getPointAtRatio>[]} points
*/
function updateReadout(readout, points) {
updateTime(readout.time, points[0].date);
readout.rows.forEach(({ value }, index) => {
value.textContent = formatValue(points[index].value);
});
}
/**
* @param {SVGSVGElement} svg
* @param {Readout} readout
* @param {SeriesHighlight} highlight
*/
export function createScrubber(svg, readout, highlight) {
const group = createSvgElement("g");
const guide = createSvgElement("line");
/** @type {ScrubberSeries[]} */
let series = [];
/** @type {SVGCircleElement[]} */
let markers = [];
let height = 0;
let stepCount = 0;
group.dataset.scrubber = "root";
guide.dataset.scrubber = "guide";
group.append(guide);
svg.append(group);
/**
* @param {number} ratio
* @param {boolean} [scrubbing]
*/
function update(ratio, scrubbing = true) {
const nextRatio = clamp(ratio, 0, 1);
const points = series.map((item) => getPointAtRatio(item, nextRatio));
const x = points[0].x.toFixed(2);
svg.dataset.index = Math.round(nextRatio * stepCount).toString();
guide.setAttribute("x1", x);
guide.setAttribute("x2", x);
guide.setAttribute("y1", "0");
guide.setAttribute("y2", height.toString());
updateReadout(readout, points);
markers.forEach((marker, index) => {
const point = points[index];
marker.setAttribute("cx", point.x.toFixed(2));
marker.setAttribute("cy", point.y.toFixed(2));
});
if (scrubbing) {
svg.dataset.scrubbing = "true";
} else {
delete svg.dataset.scrubbing;
}
}
function hide() {
update(1, false);
}
/**
* @param {ScrubberSeries[]} nextSeries
* @param {number} nextHeight
*/
function setSeries(nextSeries, nextHeight) {
series = nextSeries;
height = nextHeight;
stepCount = Math.max(...series.map(({ points }) => points.length - 1));
markers = series.map(({ color }, index) => {
const marker = createSvgElement("circle");
marker.dataset.series = index.toString();
marker.dataset.scrubber = "marker";
marker.style.setProperty("--color", color);
marker.setAttribute("r", "3");
highlight.add(marker, index);
return marker;
});
group.replaceChildren(guide, ...markers);
update(1, false);
}
/** @param {PointerEvent} event */
function updateFromPointer(event) {
const { left, width } = svg.getBoundingClientRect();
const x = ((event.clientX - left) / width) * VIEWBOX_WIDTH;
update(x / VIEWBOX_WIDTH);
}
svg.addEventListener("pointermove", updateFromPointer);
svg.addEventListener("pointerleave", hide);
svg.addEventListener("focus", () => update(1));
svg.addEventListener("blur", hide);
svg.addEventListener("keydown", (event) => {
const current = Number(svg.dataset.index || stepCount);
if (event.key === "ArrowLeft") {
event.preventDefault();
update((current - 1) / stepCount);
}
if (event.key === "ArrowRight") {
event.preventDefault();
update((current + 1) / stepCount);
}
});
return { setSeries };
}
/**
* @typedef {Object} ScrubberSeries
* @property {string} color
* @property {{ date: Date, value: number, x: number, y: number }[]} points
*/
@@ -0,0 +1,53 @@
import { createAreaPathData, createLinePathData } from "../path.js";
import { createSvgElement } from "../svg.js";
import { createStackedSeries } from "./series.js";
/**
* @param {SVGGElement} group
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {SeriesHighlight} highlight
* @param {{ reversed: boolean }} options
*/
export function renderStackedPlot(
group,
loadedSeries,
height,
highlight,
options,
) {
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
loadedSeries,
height,
options.reversed,
);
for (const index of stackIndexes) {
const { color, points } = plottedSeries[index];
const path = createSvgElement("path");
path.dataset.chart = "stacked";
path.dataset.series = index.toString();
path.style.setProperty("--color", color);
path.setAttribute("d", createAreaPathData(points));
highlight.add(path, index);
group.append(path);
}
for (const index of lineIndexes) {
const { color, points } = plottedSeries[index];
const path = createSvgElement("path");
path.dataset.chart = "line";
path.dataset.series = index.toString();
path.style.setProperty("--color", color);
path.setAttribute("d", createLinePathData(points));
highlight.add(path, index);
group.append(path);
}
return plottedSeries;
}
/** @typedef {import("../highlight.js").SeriesHighlight} SeriesHighlight */
/** @typedef {import("../index.js").LoadedSeries} LoadedSeries */
+135
View File
@@ -0,0 +1,135 @@
import { VIEWBOX_WIDTH } from "../viewbox.js";
/**
* @param {LoadedSeries[]} series
* @param {number[]} stackIndexes
* @param {number[]} lineIndexes
*/
function createStackBounds(series, stackIndexes, lineIndexes) {
const length = series[0].entries.length;
let min = 0;
let max = 0;
for (let index = 0; index < length; index += 1) {
let negative = 0;
let positive = 0;
for (const seriesIndex of stackIndexes) {
const value = series[seriesIndex].entries[index].value;
if (value < 0) negative += value;
else positive += value;
}
min = Math.min(min, negative);
max = Math.max(max, positive);
for (const seriesIndex of lineIndexes) {
const value = series[seriesIndex].entries[index].value;
min = Math.min(min, value);
max = Math.max(max, value);
}
}
return { min, max };
}
/**
* @param {number} value
* @param {{ min: number, max: number }} bounds
* @param {number} height
*/
function scaleY(value, bounds, height) {
return bounds.max === bounds.min
? height / 2
: height - ((value - bounds.min) / (bounds.max - bounds.min)) * height;
}
/** @returns {StackedPoint[]} */
function createStackedPoints() {
return [];
}
/**
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {boolean} reversed
*/
export function createStackedSeries(loadedSeries, height, reversed) {
const indexes = loadedSeries.map((_, index) => index);
const lineIndexes = indexes.filter(
(index) => loadedSeries[index].series.role === "line",
);
const stackIndexes = indexes.filter(
(index) => loadedSeries[index].series.role !== "line",
);
const bounds = createStackBounds(loadedSeries, stackIndexes, lineIndexes);
const length = loadedSeries[0].entries.length;
const xScale = VIEWBOX_WIDTH / (length - 1);
const order = [...stackIndexes];
const plottedSeries = loadedSeries.map(({ series, color }) => ({
series,
color,
points: createStackedPoints(),
}));
if (reversed) order.reverse();
for (let index = 0; index < length; index += 1) {
let negative = 0;
let positive = 0;
const x = index * xScale;
for (const seriesIndex of order) {
const { date, value } = loadedSeries[seriesIndex].entries[index];
const start = value < 0 ? negative : positive;
const end = start + value;
if (value < 0) negative = end;
else positive = end;
plottedSeries[seriesIndex].points.push({
date,
value,
x,
y: scaleY(end, bounds, height),
y0: scaleY(start, bounds, height),
y1: scaleY(end, bounds, height),
});
}
for (const seriesIndex of lineIndexes) {
const { date, value } = loadedSeries[seriesIndex].entries[index];
const y = scaleY(value, bounds, height);
plottedSeries[seriesIndex].points.push({
date,
value,
x,
y,
y0: y,
y1: y,
});
}
}
return {
lineIndexes,
plottedSeries,
stackIndexes,
};
}
/** @typedef {import("../index.js").LoadedSeries} LoadedSeries */
/**
* @typedef {Object} StackedPoint
* @property {Date} date
* @property {number} value
* @property {number} x
* @property {number} y
* @property {number} y0
* @property {number} y1
*/
@@ -0,0 +1,11 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="stacked"] {
fill: var(--color, var(--orange));
stroke: var(--black);
stroke-linejoin: round;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
}
}
+19
View File
@@ -0,0 +1,19 @@
/** @param {string} name */
export function createChartStorage(name) {
const prefix = `bitview:chart-${name}`;
return {
/** @param {string} chartKey */
get(chartKey) {
return localStorage.getItem(`${prefix}:${chartKey}`);
},
/**
* @param {string} chartKey
* @param {string} value
*/
set(chartKey, value) {
localStorage.setItem(`${prefix}:${chartKey}`, value);
},
};
}
+258
View File
@@ -0,0 +1,258 @@
main.learn {
figure[data-chart="series"] {
line-height: 1;
svg {
display: block;
width: 100%;
height: 20rem;
cursor: crosshair;
overflow: visible;
touch-action: pan-y;
}
svg:focus-visible {
outline: 1px solid var(--orange);
outline-offset: 0.5rem;
}
p[role="status"]:empty {
display: none;
}
> footer {
display: flex;
flex-wrap: wrap;
align-items: start;
justify-content: space-between;
gap: 0.5rem 1rem;
margin: 0.5rem 0 0;
> div {
display: flex;
flex-wrap: wrap;
gap: 0.125rem 0.5rem;
}
fieldset {
display: flex;
gap: 0.25rem;
margin: 0;
padding: 0;
border: 0;
text-transform: uppercase;
legend {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
label {
position: relative;
display: block;
cursor: pointer;
}
input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
span {
display: block;
padding: 0.25rem;
border-radius: 0.25rem;
color: var(--gray);
}
label:hover span {
color: var(--white);
background: var(--dark-gray);
}
label:has(:checked) span {
color: var(--black);
background: var(--white);
}
label:active span {
color: var(--black);
background: var(--orange);
}
label:has(:focus-visible) span {
outline: 1px solid var(--orange);
outline-offset: 0.125rem;
}
}
button[data-chart="fullscreen"] {
padding: 0.25rem;
border: 0;
border-radius: 0.25rem;
color: var(--gray);
background: none;
font: inherit;
line-height: inherit;
text-transform: uppercase;
cursor: pointer;
&:hover {
color: var(--white);
background: var(--dark-gray);
}
&[aria-pressed="true"] {
color: var(--black);
background: var(--green);
}
&:active {
color: var(--black);
background: var(--orange);
}
&:focus-visible {
outline: 1px solid var(--orange);
outline-offset: 0.125rem;
}
}
}
&:fullscreen {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: var(--black);
svg {
flex: 1;
height: auto;
min-height: 0;
}
figcaption menu {
padding-bottom: 0.5rem;
}
}
figcaption {
text-transform: uppercase;
header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
}
time {
color: var(--off-color);
}
menu {
display: flex;
gap: 0.5rem;
padding: 0;
padding-bottom: 1rem;
overflow-x: auto;
scrollbar-width: thin;
list-style: none;
}
li {
flex: 0 0 auto;
}
button {
padding: 0.25rem;
border: 0;
border-radius: 0.25rem;
color: inherit;
background: none;
font: inherit;
text-align: inherit;
text-transform: inherit;
cursor: pointer;
&:is(:hover, :focus-visible, [data-active]) {
color: var(--black);
background: var(--color);
span,
output {
color: inherit;
}
}
&:focus-visible {
outline: 1px solid var(--orange);
outline-offset: 0.125rem;
}
&[data-muted] {
opacity: 0.35;
}
> span {
display: block;
color: var(--color);
text-align: left;
&::before {
content: "";
display: inline-block;
width: 0.5em;
height: 0.5em;
margin-right: 0.35em;
margin-bottom: 0.1rem;
border-radius: 50%;
background: currentColor;
}
}
> output {
display: block;
margin-top: 0.25rem;
color: var(--white);
font-variant-numeric: tabular-nums;
text-align: right;
}
}
}
svg [data-series][data-muted] {
opacity: 0.2;
}
[data-scrubber] {
opacity: 0;
pointer-events: none;
}
svg[data-scrubbing="true"] [data-scrubber] {
opacity: 1;
}
[data-scrubber="guide"] {
stroke: var(--light-gray);
stroke-dasharray: 2 4;
vector-effect: non-scaling-stroke;
}
[data-scrubber="marker"] {
fill: var(--black);
stroke: var(--color, var(--orange));
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
const SVG_NS = "http://www.w3.org/2000/svg";
/**
* @template {keyof SVGElementTagNameMap} Name
* @param {Name} name
* @returns {SVGElementTagNameMap[Name]}
*/
export function createSvgElement(name) {
return document.createElementNS(SVG_NS, name);
}
+88
View File
@@ -0,0 +1,88 @@
import { createRadioGroup } from "./radio.js";
import { createChartStorage } from "./storage.js";
const storage = createChartStorage("timeframe");
/** @type {TimeframeValue} */
const defaultTimeframe = "all";
/** @type {Record<TimeframeValue, TimeframeConfig>} */
const timeframes = {
"1d": { index: "minute10", count: 144 },
"1w": { index: "hour1", count: 168 },
"1m": { index: "hour4", count: 186 },
"1y": { index: "day1", count: 366 },
"4y": { index: "day3", count: 488 },
"8y": { index: "week1", count: 418 },
all: { index: "week1" },
};
/** @type {{ value: TimeframeValue, label: string }[]} */
const options = [
{ value: "1d", label: "1d" },
{ value: "1w", label: "1w" },
{ value: "1m", label: "1m" },
{ value: "1y", label: "1y" },
{ value: "4y", label: "4y" },
{ value: "8y", label: "8y" },
{ value: "all", label: "all" },
];
/** @param {string} chartKey */
export function getDefaultTimeframe(chartKey) {
const value = storage.get(chartKey);
return (
options.find((timeframe) => timeframe.value === value)?.value ??
defaultTimeframe
);
}
/**
* @param {string} chartKey
* @param {TimeframeValue} timeframe
*/
export function saveTimeframe(chartKey, timeframe) {
storage.set(chartKey, timeframe);
}
/**
* @param {TimeframeValue} currentTimeframe
* @param {(timeframe: TimeframeValue) => void} onChange
*/
export function createTimeframeControl(currentTimeframe, onChange) {
return createRadioGroup({
legend: "Time",
options,
currentValue: currentTimeframe,
onChange,
});
}
/**
* @param {TimeframeMetric} metric
* @param {TimeframeValue} timeframe
*/
export function fetchTimeframe(metric, timeframe) {
const { count, index } = timeframes[timeframe];
const endpoint = metric.by[index];
return count ? endpoint.last(count).fetch() : endpoint.fetch();
}
/** @typedef {"1d" | "1w" | "1m" | "1y" | "4y" | "8y" | "all"} TimeframeValue */
/** @typedef {"minute10" | "hour1" | "hour4" | "day1" | "day3" | "week1"} TimeframeIndex */
/**
* @typedef {Object} TimeframeConfig
* @property {TimeframeIndex} index
* @property {number} [count]
*/
/**
* @typedef {Object} TimeframeEndpoint
* @property {() => Promise<import("./index.js").ChartResult>} fetch
* @property {(count: number) => { fetch: () => Promise<import("./index.js").ChartResult> }} last
*/
/**
* @typedef {Object} TimeframeMetric
* @property {Record<TimeframeIndex, TimeframeEndpoint>} by
*/
+11
View File
@@ -0,0 +1,11 @@
export const VIEWBOX_WIDTH = 640;
export const FALLBACK_VIEWBOX_HEIGHT = 220;
/** @param {SVGSVGElement} svg */
export function getViewBoxHeight(svg) {
const { width, height } = svg.getBoundingClientRect();
return width && height
? (VIEWBOX_WIDTH * height) / width
: FALLBACK_VIEWBOX_HEIGHT;
}
+45
View File
@@ -0,0 +1,45 @@
import { createRadioGroup } from "./radio.js";
import { createChartStorage } from "./storage.js";
const storage = createChartStorage("view");
/** @type {ChartView} */
const defaultView = "stacked";
/** @type {{ value: ChartView, label: string }[]} */
const views = [
{ value: "line", label: "Line" },
{ value: "stacked", label: "Stack↑" },
{ value: "stacked-reversed", label: "Stack↓" },
{ value: "bar", label: "Bars↑" },
{ value: "bar-reversed", label: "Bars↓" },
{ value: "dots", label: "Dots" },
];
/** @param {string} chartKey */
export function getDefaultView(chartKey) {
const value = storage.get(chartKey);
return views.find((view) => view.value === value)?.value ?? defaultView;
}
/**
* @param {string} chartKey
* @param {ChartView} view
*/
export function saveView(chartKey, view) {
storage.set(chartKey, view);
}
/**
* @param {ChartView} currentView
* @param {(view: ChartView) => void} onChange
*/
export function createViewControl(currentView, onChange) {
return createRadioGroup({
legend: "View",
options: views,
currentValue: currentView,
onChange,
});
}
/** @typedef {"line" | "stacked" | "stacked-reversed" | "bar" | "bar-reversed" | "dots"} ChartView */
+194
View File
@@ -0,0 +1,194 @@
import { createSeries } from "./charts/config.js";
import { colors } from "../utils/colors.js";
/** @typedef {import("./charts/index.js").ChartSeries["color"]} ChartColor */
/** @typedef {import("./charts/index.js").ChartSeries["metric"]} Metric */
/** @type {ChartColor[]} */
const palette = [
colors.red,
colors.orange,
colors.amber,
colors.yellow,
colors.avocado,
colors.lime,
colors.green,
colors.emerald,
colors.teal,
colors.cyan,
colors.sky,
colors.blue,
colors.indigo,
colors.violet,
colors.purple,
colors.fuchsia,
colors.pink,
colors.rose,
];
/** @param {number} index */
function colorAt(index) {
return palette[index % palette.length];
}
/**
* @param {readonly { label: string, color?: ChartColor, metric: Metric }[]} items
*/
function createCohortSeries(items) {
return createSeries(
items.map(({ label, color, metric }, index) => ({
label,
color: color ?? colorAt(index),
metric,
})),
);
}
/**
* @template {string} Key
* @param {readonly (readonly [string, Key])[]} items
* @param {(key: Key) => Metric} createMetric
*/
function createCohortSeriesFromKeys(items, createMetric) {
return createCohortSeries(
items.map(([label, key]) => ({
label,
metric: createMetric(key),
})),
);
}
const ageRanges = /** @type {const} */ ([
["0-1h", "under1h"],
["1h to 1d", "_1hTo1d"],
["1d to 1w", "_1dTo1w"],
["1w to 1m", "_1wTo1m"],
["1m to 2m", "_1mTo2m"],
["2m to 3m", "_2mTo3m"],
["3m to 4m", "_3mTo4m"],
["4m to 5m", "_4mTo5m"],
["5m to 6m", "_5mTo6m"],
["6m to 1y", "_6mTo1y"],
["1y to 2y", "_1yTo2y"],
["2y to 3y", "_2yTo3y"],
["3y to 4y", "_3yTo4y"],
["4y to 5y", "_4yTo5y"],
["5y to 6y", "_5yTo6y"],
["6y to 7y", "_6yTo7y"],
["7y to 8y", "_7yTo8y"],
["8y to 10y", "_8yTo10y"],
["10y to 12y", "_10yTo12y"],
["12y to 15y", "_12yTo15y"],
["15y+", "over15y"],
]);
const amountRanges = /** @type {const} */ ([
["0 sats", "_0sats"],
["1-10 sats", "_1satTo10sats"],
["10-100 sats", "_10satsTo100sats"],
["100-1k sats", "_100satsTo1kSats"],
["1k-10k sats", "_1kSatsTo10kSats"],
["10k-100k sats", "_10kSatsTo100kSats"],
["100k-1M sats", "_100kSatsTo1mSats"],
["1M-10M sats", "_1mSatsTo10mSats"],
["10M sats-1 BTC", "_10mSatsTo1btc"],
["1-10 BTC", "_1btcTo10btc"],
["10-100 BTC", "_10btcTo100btc"],
["100-1k BTC", "_100btcTo1kBtc"],
["1k-10k BTC", "_1kBtcTo10kBtc"],
["10k-100k BTC", "_10kBtcTo100kBtc"],
["100k+ BTC", "over100kBtc"],
]);
const types = /** @type {const} */ ([
["P2PK65", "p2pk65"],
["P2PK33", "p2pk33"],
["P2PKH", "p2pkh"],
["OP_RETURN", "opReturn"],
["P2MS", "p2ms"],
["P2SH", "p2sh"],
["P2WPKH", "p2wpkh"],
["P2WSH", "p2wsh"],
["P2TR", "p2tr"],
["P2A", "p2a"],
["Unknown", "unknown"],
["Empty", "empty"],
]);
const epochs = /** @type {const} */ ([
["Epoch 0", "_0"],
["Epoch 1", "_1"],
["Epoch 2", "_2"],
["Epoch 3", "_3"],
["Epoch 4", "_4"],
]);
const classes = /** @type {const} */ ([
["2009", "_2009"],
["2010", "_2010"],
["2011", "_2011"],
["2012", "_2012"],
["2013", "_2013"],
["2014", "_2014"],
["2015", "_2015"],
["2016", "_2016"],
["2017", "_2017"],
["2018", "_2018"],
["2019", "_2019"],
["2020", "_2020"],
["2021", "_2021"],
["2022", "_2022"],
["2023", "_2023"],
["2024", "_2024"],
["2025", "_2025"],
["2026", "_2026"],
]);
export const termSeries = createCohortSeries([
{
label: "STH",
color: colors.sky,
metric: (client) => client.series.cohorts.utxo.sth.supply.total.btc,
},
{
label: "LTH",
color: colors.orange,
metric: (client) => client.series.cohorts.utxo.lth.supply.total.btc,
},
]);
export const ageSeries = createCohortSeriesFromKeys(
ageRanges,
(key) => (client) =>
client.series.cohorts.utxo.ageRange[key].supply.total.btc,
);
export const utxoBalanceSeries = createCohortSeriesFromKeys(
amountRanges,
(key) => (client) =>
client.series.cohorts.utxo.amountRange[key].supply.total.btc,
);
export const addressBalanceSeries = createCohortSeriesFromKeys(
amountRanges,
(key) => (client) =>
client.series.cohorts.addr.amountRange[key].supply.total.btc,
);
export const typeSeries = createCohortSeriesFromKeys(
types,
(key) => (client) =>
key === "opReturn"
? client.series.outputs.value.opReturn.cumulative.btc
: client.series.cohorts.utxo.type[key].supply.total.btc,
);
export const epochSeries = createCohortSeriesFromKeys(
epochs,
(key) => (client) => client.series.cohorts.utxo.epoch[key].supply.total.btc,
);
export const classSeries = createCohortSeriesFromKeys(
classes,
(key) => (client) => client.series.cohorts.utxo.class[key].supply.total.btc,
);
+6 -6
View File
@@ -1,18 +1,18 @@
import { createId } from "../../utils/id.js";
/**
* @param {{ title: string, children: Section[] }} section
*/
/** @param {Section} section */
function createContentsItem(section) {
const item = document.createElement("li");
const anchor = document.createElement("a");
const children = section.children ?? [];
anchor.href = `#${createId(section.title)}`;
anchor.append(section.title);
if (section.children.length) {
if (children.length) {
const list = document.createElement("ol");
for (const child of section.children) {
for (const child of children) {
list.append(createContentsItem(child));
}
item.append(list);
@@ -40,5 +40,5 @@ export function createContents(sections) {
/**
* @typedef {Object} Section
* @property {string} title
* @property {Section[]} children
* @property {Section[]} [children]
*/
+6 -6
View File
@@ -25,7 +25,7 @@ main.learn {
}
ol ol {
margin-top: 0.5rem;
margin-top: 0.25rem;
margin-left: 1rem;
color: var(--gray);
}
@@ -35,7 +35,7 @@ main.learn {
}
li + li {
margin-top: 0.5rem;
margin-top: 0.25rem;
}
a {
@@ -44,16 +44,16 @@ main.learn {
color: inherit;
text-decoration: none;
margin-right: 1rem;
margin-block: -0.25rem;
margin-left: -0.5rem;
padding: 0.25rem;
padding-left: 0.5rem;
&::before {
opacity: 0.5;
}
&:is(:hover, :active) {
margin-block: -0.25rem;
margin-left: -0.5rem;
padding: 0.25rem;
padding-left: 0.5rem;
border-radius: 0.25rem;
}
+105 -37
View File
@@ -1,30 +1,124 @@
import {
addressBalanceSeries,
ageSeries,
classSeries,
epochSeries,
termSeries,
typeSeries,
utxoBalanceSeries,
} from "./cohorts.js";
import { colors } from "../utils/colors.js";
/** @param {typeof import("../utils/client.js").brk} client */
function metricCirculatingSupply(client) {
return client.series.supply.circulating.btc;
}
/** @param {typeof import("../utils/client.js").brk} client */
function metricSupplyInProfit(client) {
return client.series.cohorts.utxo.profitability.profit.all.supply.all.btc;
}
/** @param {typeof import("../utils/client.js").brk} client */
function metricSupplyInLoss(client) {
return client.series.cohorts.utxo.profitability.loss.all.supply.all.btc;
}
export const sections = [
{
title: "Supply",
description:
"How bitcoin moves from issuance into long-term ownership, profit, loss, and distribution.",
chart: "Circulating supply",
chart: {
title: "Circulating supply",
series: [
{
label: "Circulating",
color: colors.orange,
metric: metricCirculatingSupply,
},
],
},
children: [
{
title: "Profitability",
description:
"Which coins sit in profit or loss, and how that balance changes through cycles.",
chart: "Supply in profit",
children: [],
chart: {
title: "Profitability",
series: [
{
label: "In profit",
color: colors.green,
metric: metricSupplyInProfit,
},
{
label: "In loss",
color: colors.red,
metric: metricSupplyInLoss,
},
],
},
},
{
title: "Term",
description:
"Supply split between recently moved coins and long-term holder coins.",
chart: {
title: "Supply by term",
series: termSeries,
},
},
{
title: "Age",
description:
"How long coins have remained still, from fresh movement to deep dormancy.",
chart: "Supply by age",
children: [],
chart: {
title: "Supply by age",
series: ageSeries,
},
},
{
title: "Distribution",
title: "UTXO Balance",
description: "Supply grouped by the amount held in each unspent output.",
chart: {
title: "Supply by UTXO balance",
series: utxoBalanceSeries,
},
},
{
title: "Address Balance",
description: "Supply grouped by the balance held at each address.",
chart: {
title: "Supply by address balance",
series: addressBalanceSeries,
},
},
{
title: "Type",
description: "Supply grouped by output script type.",
chart: {
title: "Supply by type",
series: typeSeries,
},
},
{
title: "Epoch",
description:
"How supply is spread across addresses, scripts, cohorts, and balance ranges.",
chart: "Supply distribution",
children: [],
"Supply grouped by the halving epoch in which coins were created.",
chart: {
title: "Supply by epoch",
series: epochSeries,
},
},
{
title: "Class",
description:
"Supply grouped by the calendar year in which coins were created.",
chart: {
title: "Supply by class",
series: classSeries,
},
},
],
},
@@ -39,28 +133,26 @@ export const sections = [
description:
"The current market value of circulating bitcoin at spot price.",
chart: "Market capitalization",
children: [],
},
{
title: "Realized Cap",
description:
"The aggregate value of coins priced where they last moved on-chain.",
chart: "Realized capitalization",
children: [],
},
{
title: "Value Bands",
description:
"How market value compares with cost basis and historical valuation ranges.",
chart: "Valuation bands",
children: [],
},
],
},
{
title: "Activity",
description:
"How often the chain is used, how value moves, and how demand appears in fees and transactions.",
"How often the chain is used, how value moves, and how demand appears " +
"in fees and transactions.",
chart: "Network activity",
children: [
{
@@ -68,21 +160,18 @@ export const sections = [
description:
"Confirmed transaction count, throughput, and block-level settlement patterns.",
chart: "Transaction count",
children: [],
},
{
title: "Fees",
description:
"The cost users pay for block space and what that reveals about demand.",
chart: "Fee rate",
children: [],
},
{
title: "Addresses",
description:
"Address creation, reuse, activity, and balance changes across the network.",
chart: "Active addresses",
children: [],
},
],
},
@@ -97,21 +186,18 @@ export const sections = [
description:
"Estimated computational power securing the network over time.",
chart: "Hashrate",
children: [],
},
{
title: "Difficulty",
description:
"How Bitcoin adjusts mining difficulty to keep block production steady.",
chart: "Difficulty",
children: [],
},
{
title: "Rewards",
description:
"Subsidy, fees, and the changing economics of block production.",
chart: "Miner rewards",
children: [],
},
],
},
@@ -126,21 +212,18 @@ export const sections = [
description:
"Bitcoin price across time, cycles, drawdowns, and all-time highs.",
chart: "Price",
children: [],
},
{
title: "Returns",
description:
"How returns vary by holding period, entry point, and cycle phase.",
chart: "Returns",
children: [],
},
{
title: "Volatility",
description:
"The scale and rhythm of price movement across different windows.",
chart: "Volatility",
children: [],
},
],
},
@@ -155,21 +238,18 @@ export const sections = [
description:
"Address and entity balances grouped by size, concentration, and historical change.",
chart: "Balance cohorts",
children: [],
},
{
title: "Entities",
description:
"Estimated ownership clusters and how their behavior changes through market regimes.",
chart: "Entity supply",
children: [],
},
{
title: "Custody",
description:
"Coins associated with exchanges, funds, miners, and other observable custody groups.",
chart: "Custody balances",
children: [],
},
],
},
@@ -184,21 +264,18 @@ export const sections = [
description:
"Coins held by entities that tend to spend, trade, or redistribute frequently.",
chart: "Liquid supply",
children: [],
},
{
title: "Illiquid Supply",
description:
"Coins held by entities with low spending history and stronger accumulation behavior.",
chart: "Illiquid supply",
children: [],
},
{
title: "Exchange Flow",
description:
"Deposits, withdrawals, and balance changes across known exchange clusters.",
chart: "Exchange netflow",
children: [],
},
],
},
@@ -213,21 +290,18 @@ export const sections = [
description:
"Distance from prior highs and the depth of cycle retracements over time.",
chart: "Drawdown",
children: [],
},
{
title: "Stress",
description:
"Periods where losses, volatility, and fee pressure concentrate together.",
chart: "Network stress",
children: [],
},
{
title: "Leverage",
description:
"Market conditions that indicate amplified exposure and forced positioning risk.",
chart: "Leverage proxy",
children: [],
},
],
},
@@ -242,21 +316,18 @@ export const sections = [
description:
"Supply issuance changes and their relationship to market and miner behavior.",
chart: "Halving cycles",
children: [],
},
{
title: "Phases",
description:
"Bull, bear, recovery, and transition periods described through on-chain behavior.",
chart: "Cycle phases",
children: [],
},
{
title: "Comparisons",
description:
"Cycle-to-cycle comparisons normalized by time, price, drawdown, or supply behavior.",
chart: "Cycle comparison",
children: [],
},
],
},
@@ -271,21 +342,18 @@ export const sections = [
description:
"Recently moved coins and holders more sensitive to price, volatility, and liquidity.",
chart: "Short-term holder supply",
children: [],
},
{
title: "Long Term",
description:
"Older coins and holders with stronger dormancy, conviction, or lower spend frequency.",
chart: "Long-term holder supply",
children: [],
},
{
title: "Cost Basis",
description:
"Estimated acquisition prices across cohorts and how they frame profit and loss.",
chart: "Cohort cost basis",
children: [],
},
],
},
+56
View File
@@ -0,0 +1,56 @@
import { getEventAnchor, isPlainLeftClick } from "../utils/event.js";
/**
* @param {HTMLElement} main
* @param {string} hash
*/
function getHashTarget(main, hash) {
const target = document.getElementById(hash.slice(1));
return target && main.contains(target) ? target : null;
}
/**
* @param {Element} target
* @param {ScrollBehavior} behavior
*/
function scrollToTarget(target, behavior) {
target.scrollIntoView({ behavior, block: "start" });
}
/** @param {HTMLElement} main */
export function initHashLinks(main) {
const initialHash = window.location.hash;
main.addEventListener("click", (event) => {
if (!isPlainLeftClick(event)) return;
const anchor = getEventAnchor(event);
if (!anchor) return;
const url = new URL(anchor.href);
if (url.origin !== window.location.origin) return;
if (url.pathname !== window.location.pathname || !url.hash) return;
const target = getHashTarget(main, url.hash);
if (!target) return;
event.preventDefault();
scrollToTarget(target, "smooth");
if (url.hash !== window.location.hash) {
history.pushState(null, "", url.hash);
}
});
window.addEventListener("popstate", () => {
if (main.hidden) return;
const target = getHashTarget(main, window.location.hash);
if (target) scrollToTarget(target, "auto");
});
requestAnimationFrame(() => {
const target = getHashTarget(main, initialHash);
if (target) scrollToTarget(target, "auto");
});
}
+18 -12
View File
@@ -1,17 +1,21 @@
import { createContents } from "./contents/index.js";
import { sections } from "./data.js";
import { createChart as createDataChart } from "./charts/index.js";
import { initHashLinks } from "./hash-links.js";
import { initScrollSpy } from "./scroll-spy.js";
import { createId } from "../utils/id.js";
/** @param {string} label */
function createChart(label) {
/** @param {Section["chart"]} chart */
function createFigure(chart) {
if (typeof chart !== "string") return createDataChart(chart);
const figure = document.createElement("figure");
const chart = document.createElement("div");
const placeholder = document.createElement("div");
const caption = document.createElement("figcaption");
chart.append(label);
caption.append(label);
figure.append(chart, caption);
placeholder.append(chart);
caption.append(chart);
figure.append(placeholder, caption);
return figure;
}
@@ -22,19 +26,20 @@ function createChart(label) {
*/
function createSection(section, level = 1) {
const element = document.createElement("section");
const title = document.createElement(level === 1 ? "h1" : "h2");
const heading = document.createElement(level === 1 ? "h1" : "h2");
const anchor = document.createElement("a");
const description = document.createElement("p");
const children = section.children ?? [];
const id = createId(section.title);
element.id = id;
anchor.href = `#${id}`;
anchor.append(section.title);
title.append(anchor);
heading.append(anchor);
description.append(section.description);
element.append(title, description, createChart(section.chart));
element.append(heading, description, createFigure(section.chart));
for (const child of section.children) {
for (const child of children) {
element.append(createSection(child, level + 1));
}
@@ -51,6 +56,7 @@ export function createLearnPage() {
}
main.append(createContents(sections), article);
initHashLinks(main);
initScrollSpy(main);
return main;
}
@@ -59,6 +65,6 @@ export function createLearnPage() {
* @typedef {Object} Section
* @property {string} title
* @property {string} description
* @property {string} chart
* @property {Section[]} children
* @property {string | import("./charts/index.js").Chart} chart
* @property {Section[]} [children]
*/
+3 -3
View File
@@ -1,3 +1,5 @@
const thresholds = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1];
/** @param {HTMLElement} main */
export function initScrollSpy(main) {
const sections = [...main.querySelectorAll("section[id]")];
@@ -96,9 +98,7 @@ export function initScrollSpy(main) {
update();
},
{
threshold: [
0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1,
],
threshold: thresholds,
},
);
+6 -1
View File
@@ -112,13 +112,18 @@ main.learn {
p {
margin-top: 1rem;
color: var(--dark-white);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
}
figure {
margin-top: 2rem;
color: var(--gray);
font-size: var(--font-size-xs);
text-transform: uppercase;
&:not([data-chart]) {
text-transform: uppercase;
}
> div {
height: 18rem;
+2 -4
View File
@@ -1,6 +1,6 @@
import { createHeader } from "./header/index.js";
import { createRoutePage, isRoute, normalizePath } from "./routes.js";
import { getEventAnchor } from "./utils/event.js";
import { getEventAnchor, isPlainLeftClick } from "./utils/event.js";
import { revealPage, transitionPage } from "./utils/transition.js";
/** @type {HTMLElement | undefined} */
@@ -71,9 +71,7 @@ function navigate(pathname) {
}
document.addEventListener("click", (event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) {
return;
}
if (!isPlainLeftClick(event)) return;
const anchor = getEventAnchor(event);
if (!anchor) return;
File diff suppressed because it is too large Load Diff
-10
View File
@@ -7,19 +7,9 @@ body {
background: var(--black);
}
html {
scroll-behavior: smooth;
}
body {
> main {
min-height: 100dvh;
color: var(--white);
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
+3
View File
@@ -0,0 +1,3 @@
import { BrkClient } from "../modules/brk-client/index.js";
export const brk = new BrkClient("https://bitview.space");
+21
View File
@@ -0,0 +1,21 @@
export const colors = {
orange: () => "var(--orange)",
white: () => "var(--white)",
sky: () => "var(--sky)",
cyan: () => "var(--cyan)",
teal: () => "var(--teal)",
yellow: () => "var(--yellow)",
avocado: () => "var(--avocado)",
amber: () => "var(--amber)",
green: () => "var(--green)",
emerald: () => "var(--emerald)",
lime: () => "var(--lime)",
rose: () => "var(--rose)",
pink: () => "var(--pink)",
fuchsia: () => "var(--fuchsia)",
purple: () => "var(--purple)",
violet: () => "var(--violet)",
indigo: () => "var(--indigo)",
blue: () => "var(--blue)",
red: () => "var(--red)",
};
+10
View File
@@ -14,3 +14,13 @@ export function getEventAnchor(event) {
getEventTarget(event, "a[href]")
);
}
/** @param {MouseEvent} event */
export function isPlainLeftClick(event) {
return (
event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey
);
}