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