diff --git a/scripts/cycle-dca-sim.mjs b/scripts/cycle-dca-sim.mjs new file mode 100755 index 000000000..0fa6ae4cf --- /dev/null +++ b/scripts/cycle-dca-sim.mjs @@ -0,0 +1,702 @@ +#!/usr/bin/env node + +const DEFAULT_BUY_LEVELS = new Map([ + [50, 1.0], + [45, 1.5], + [40, 2.0], +]); + +const DAYS_PER_MONTH = 365.2425 / 12; + +function parseArgs(argv) { + const opts = { + baseUrl: "https://bitview.space", + start: "2014-01-01", + end: null, + starts: null, + startSet: "cycle-extremes", + initialCash: 10_000, + monthlyTopup: 1_000, + dailyBuy: null, + initialDeployDays: 365, + buyTriggerPct: 50, + buyLevels: DEFAULT_BUY_LEVELS, + sellRule: "percentile-band", + sellArmPct: 100, + sellBandLowerPct: 95, + sellBandUpperPct: 100, + sellBandMultiple: 5, + sellAthMultiple: 3, + sellMap: null, + maxDailySellFraction: 0.005, + mode: "both", + output: "table", + }; + + 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 === "--help" || arg === "-h") { + opts.help = true; + } else if (arg === "--base-url") { + opts.baseUrl = next(); + } else if (arg === "--start") { + opts.start = next(); + } else if (arg === "--end") { + opts.end = next(); + } else if (arg === "--starts") { + opts.starts = next() + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } else if (arg === "--start-set") { + opts.startSet = next(); + } else if (arg === "--initial-cash") { + opts.initialCash = parseNumber(next(), arg); + } else if (arg === "--monthly-topup") { + opts.monthlyTopup = parseNumber(next(), arg); + } else if (arg === "--daily-buy") { + opts.dailyBuy = parseNumber(next(), arg); + } else if (arg === "--initial-deploy-days") { + opts.initialDeployDays = parseInteger(next(), arg); + } else if (arg === "--buy-trigger-pct") { + opts.buyTriggerPct = parseInteger(next(), arg); + } else if (arg === "--buy-levels") { + opts.buyLevels = parsePctMap(next(), arg); + } else if (arg === "--sell-map") { + opts.sellMap = parsePctMap(next(), arg); + opts.sellRule = "percentile-map"; + } else if (arg === "--sell-band") { + const [lower, upper] = next().split(":"); + if (!lower || !upper) { + throw new Error("--sell-band must look like lowerPct:upperPct"); + } + opts.sellBandLowerPct = parseInteger(lower, arg); + opts.sellBandUpperPct = parseInteger(upper, arg); + opts.sellRule = "percentile-band"; + } else if (arg === "--sell-arm-pct") { + opts.sellArmPct = parseInteger(next(), arg); + } else if (arg === "--sell-band-multiple") { + opts.sellBandMultiple = parseNumber(next(), arg); + opts.sellRule = "percentile-band"; + } else if (arg === "--sell-ath-multiple") { + opts.sellAthMultiple = parseNumber(next(), arg); + opts.sellRule = "ath"; + } else if (arg === "--max-daily-sell-fraction") { + opts.maxDailySellFraction = parseNumber(next(), arg); + } else if (arg === "--mode") { + opts.mode = next(); + } else if (arg === "--csv") { + opts.output = "csv"; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!["both", "hold", "sell"].includes(opts.mode)) { + throw new Error("--mode must be one of: both, hold, sell"); + } + + if (!["cycle-extremes", "single", "custom"].includes(opts.startSet)) { + throw new Error("--start-set must be one of: cycle-extremes, single, custom"); + } + + if (opts.starts?.length) { + opts.startSet = "custom"; + } + + return opts; +} + +function parseNumber(value, label) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) throw new Error(`${label} must be a number`); + return parsed; +} + +function parseInteger(value, label) { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed)) throw new Error(`${label} must be an integer`); + return parsed; +} + +function parsePctMap(value, label) { + const map = new Map(); + for (const part of value.split(",")) { + const [pct, weight] = part.split(":"); + if (!pct || !weight) { + throw new Error(`${label} entries must look like pct:value,pct:value`); + } + map.set(parseInteger(pct, label), parseNumber(weight, label)); + } + return map; +} + +function printHelp() { + console.log(`Usage: node scripts/cycle-dca-sim.mjs [options] + +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 + - start with cash, then add a monthly top-up + +Defaults: + --start 2014-01-01 + --initial-cash 10000 + --monthly-topup 1000 + --buy-levels 50:1,45:1.5,40:2 + --sell-band 95:100 + --sell-band-multiple 5 + --max-daily-sell-fraction 0.005 + --mode both + --start-set cycle-extremes + +Options: + --start YYYY-MM-DD + --end YYYY-MM-DD + --starts YYYY-MM-DD,YYYY-MM-DD Explicit start dates + --start-set cycle-extremes|single|custom + --mode both|hold|sell + --initial-cash USD + --monthly-topup USD + --daily-buy USD Default: monthly top-up / average month + --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-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 + --max-daily-sell-fraction N Max BTC fraction sold per day when all sell thresholds fire + --csv +`); +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + printHelp(); + return; + } + + const data = await loadData(opts); + const starts = resolveStartPoints(data.rows, opts); + const results = []; + + for (const startPoint of starts) { + const startIndex = startPoint.index; + const benchmarkLump = simulateLumpAndTopup(data.rows, startIndex, opts); + const benchmarkDca = simulateSimpleDailyDca(data.rows, startIndex, opts); + + const modes = + opts.mode === "both" ? [false, true] : opts.mode === "sell" ? [true] : [false]; + + for (const sellEnabled of modes) { + const signal = simulateSignal(data.rows, startIndex, opts, sellEnabled); + results.push({ + start_label: startPoint.label, + start_date: data.rows[startIndex].date, + start_kind: startPoint.kind, + start_epoch: startPoint.epoch, + mode: sellEnabled ? "sell" : "hold", + final_date: signal.finalDate, + contributed: signal.contributed, + final_value: signal.finalValue, + return_pct: pct(signal.finalValue / signal.contributed - 1), + cash: signal.cash, + btc: signal.btc, + buys: signal.buys, + sells: signal.sells, + bought_usd: signal.boughtUsd, + sold_usd: signal.soldUsd, + max_drawdown_pct: pct(signal.maxDrawdown), + lump_value: benchmarkLump.finalValue, + lump_delta_pct: pct(signal.finalValue / benchmarkLump.finalValue - 1), + daily_dca_value: benchmarkDca.finalValue, + daily_dca_delta_pct: pct(signal.finalValue / benchmarkDca.finalValue - 1), + }); + } + } + + if (opts.output === "csv") { + printCsv(results); + } else { + printTable(results, opts); + } +} + +async function loadData(opts) { + const pctSet = new Set([ + opts.buyTriggerPct, + ...opts.buyLevels.keys(), + opts.sellArmPct, + ...(opts.sellRule === "percentile-band" + ? [opts.sellBandLowerPct, opts.sellBandUpperPct] + : []), + ...(opts.sellMap?.keys() ?? []), + ]); + + const seriesNames = [ + "date", + "price", + "price_ath", + "halving_epoch", + ...[...pctSet] + .sort((a, b) => a - b) + .map((pct) => costBasisSeriesName(pct)), + ]; + + const loaded = new Map( + await Promise.all( + seriesNames.map(async (name) => [name, await fetchSeries(opts, name)]), + ), + ); + + const dates = loaded.get("date").data; + const price = loaded.get("price").data; + const ath = loaded.get("price_ath").data; + const epoch = loaded.get("halving_epoch").data; + const len = Math.min(dates.length, price.length, ath.length, epoch.length); + const rows = []; + + for (let i = 0; i < len; i += 1) { + const percentiles = new Map(); + for (const pct of pctSet) { + const series = loaded.get(costBasisSeriesName(pct)).data; + percentiles.set(pct, series[i]); + } + + rows.push({ + date: dates[i], + price: price[i], + ath: ath[i], + previousAth: i > 0 ? ath[i - 1] : ath[i], + epoch: epoch[i], + percentiles, + }); + } + + return { rows }; +} + +async function fetchSeries(opts, series) { + const url = new URL(`/api/series/${series}/day1`, normalizeBaseUrl(opts.baseUrl)); + url.searchParams.set("start", opts.start); + if (opts.end) url.searchParams.set("end", opts.end); + + const response = await fetch(url); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to fetch ${series}: ${response.status} ${text}`); + } + + const json = await response.json(); + if (!Array.isArray(json.data)) { + throw new Error(`Series ${series} did not return a data array`); + } + return json; +} + +function normalizeBaseUrl(baseUrl) { + return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; +} + +function costBasisSeriesName(pct) { + if (pct === 100) return "cost_basis_max"; + return `cost_basis_per_coin_pct${String(pct).padStart(2, "0")}`; +} + +function resolveStartPoints(rows, opts) { + if (opts.startSet === "custom") { + return opts.starts.map((date) => ({ + label: date, + date, + kind: "custom", + epoch: null, + index: findDateIndex(rows, date), + })); + } + + if (opts.startSet === "single") { + return [ + { + label: opts.start, + date: opts.start, + kind: "single", + epoch: null, + index: 0, + }, + ]; + } + + const byEpoch = new Map(); + rows.forEach((row, index) => { + 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 }); + }); + + const points = []; + for (const [epoch, items] of [...byEpoch.entries()].sort(([a], [b]) => a - b)) { + if (!items.length) continue; + + const top = items.reduce((best, item) => + item.row.price > best.row.price ? item : best, + ); + const bottom = items.reduce((best, item) => + item.row.price < best.row.price ? item : best, + ); + + points.push({ + label: `epoch-${epoch}-bottom`, + date: bottom.row.date, + kind: "bottom", + epoch, + index: bottom.index, + }); + points.push({ + label: `epoch-${epoch}-top`, + date: top.row.date, + kind: "top", + epoch, + index: top.index, + }); + } + + return uniqueByIndex(points).sort((a, b) => a.index - b.index); +} + +function uniqueByIndex(points) { + const seen = new Set(); + return points.filter((point) => { + if (seen.has(point.index)) return false; + seen.add(point.index); + return true; + }); +} + +function findDateIndex(rows, date) { + const index = rows.findIndex((row) => row.date >= date); + if (index === -1) throw new Error(`Start date ${date} is outside loaded data`); + return index; +} + +function simulateSignal(rows, startIndex, opts, sellEnabled) { + let cash = opts.initialCash; + let btc = 0; + let buyActive = false; + let sellArmed = false; + let initialDeployActiveDays = 0; + let contributed = opts.initialCash; + let buys = 0; + let sells = 0; + let boughtUsd = 0; + let soldUsd = 0; + let peakValue = cash; + let maxDrawdown = 0; + const baseDailyBuy = opts.dailyBuy ?? 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; + contributed += opts.monthlyTopup; + } + + 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; + } + + if (sellEnabled && sellArmed) { + const sellFraction = dailySellFraction(row, opts); + if (sellFraction > 0 && btc > 0) { + const btcToSell = btc * sellFraction; + const usd = btcToSell * row.price; + btc -= btcToSell; + cash += usd; + soldUsd += usd; + sells += 1; + } + } + + if (buyActive && cash > 0) { + const initialBudget = + initialDeployActiveDays < opts.initialDeployDays && opts.initialDeployDays > 0 + ? opts.initialCash / opts.initialDeployDays + : 0; + const buyBudget = (baseDailyBuy + initialBudget) * buyWeight(row, opts); + const usd = Math.min(cash, buyBudget); + if (usd > 0) { + btc += usd / row.price; + cash -= usd; + boughtUsd += usd; + buys += 1; + initialDeployActiveDays += 1; + } + } + + const value = cash + btc * row.price; + peakValue = Math.max(peakValue, value); + maxDrawdown = Math.max(maxDrawdown, peakValue === 0 ? 0 : 1 - value / peakValue); + } + + const finalRow = rows.at(-1); + return { + finalDate: finalRow.date, + finalValue: cash + btc * finalRow.price, + cash, + btc, + buys, + sells, + boughtUsd, + soldUsd, + contributed, + maxDrawdown, + }; +} + +function simulateLumpAndTopup(rows, startIndex, opts) { + let cash = opts.initialCash; + let btc = cash / rows[startIndex].price; + let contributed = cash; + cash = 0; + + for (let i = startIndex + 1; i < rows.length; i += 1) { + const row = rows[i]; + if (isMonthStart(row.date)) { + contributed += opts.monthlyTopup; + btc += opts.monthlyTopup / row.price; + } + } + + const finalRow = rows.at(-1); + return { + finalValue: btc * finalRow.price, + contributed, + }; +} + +function simulateSimpleDailyDca(rows, startIndex, opts) { + let cash = opts.initialCash; + let btc = 0; + let contributed = cash; + const baseDailyBuy = opts.dailyBuy ?? 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; + contributed += opts.monthlyTopup; + } + + const elapsedDays = i - startIndex; + const initialBudget = + elapsedDays < opts.initialDeployDays && opts.initialDeployDays > 0 + ? opts.initialCash / opts.initialDeployDays + : 0; + const usd = Math.min(cash, baseDailyBuy + initialBudget); + if (usd > 0) { + btc += usd / row.price; + cash -= usd; + } + } + + const finalRow = rows.at(-1); + return { + finalValue: cash + btc * finalRow.price, + contributed, + }; +} + +function buyWeight(row, opts) { + let weight = 1.0; + for (const [pct, pctWeight] of [...opts.buyLevels.entries()].sort( + ([a], [b]) => b - a, + )) { + const level = row.percentiles.get(pct); + if (Number.isFinite(level) && level > 0 && row.price <= level) { + weight = Math.max(weight, pctWeight); + } + } + return weight; +} + +function isSellArmTouch(row, previousRow, opts) { + const level = row.percentiles.get(opts.sellArmPct); + if (!Number.isFinite(level) || level <= 0) return false; + + if (opts.sellArmPct === 100) { + const previousLevel = previousRow?.percentiles.get(opts.sellArmPct); + return Number.isFinite(previousLevel) && level > previousLevel; + } + + return row.price >= level; +} + +function dailySellFraction(row, opts) { + if (opts.sellRule === "percentile-band") { + const lowerLevel = row.percentiles.get(opts.sellBandLowerPct); + const upperLevel = row.percentiles.get(opts.sellBandUpperPct); + if ( + !Number.isFinite(lowerLevel) || + !Number.isFinite(upperLevel) || + lowerLevel <= 0 || + upperLevel <= 0 + ) { + return 0; + } + + const lower = Math.min(lowerLevel, upperLevel); + const upper = Math.max(lowerLevel, upperLevel); + return row.price >= lower && row.price <= upper + ? Math.min(1, opts.maxDailySellFraction * opts.sellBandMultiple) + : 0; + } + + if (opts.sellRule === "ath") { + const threshold = row.previousAth * opts.sellAthMultiple; + return Number.isFinite(threshold) && threshold > 0 && row.price >= threshold + ? opts.maxDailySellFraction + : 0; + } + + const totalWeight = [...opts.sellMap.values()].reduce((sum, weight) => sum + weight, 0); + if (totalWeight <= 0) return 0; + + let triggeredWeight = 0; + for (const [pct, multiplier] of opts.sellMap) { + const level = row.percentiles.get(pct); + if (Number.isFinite(level) && level > 0 && row.price >= level * multiplier) { + triggeredWeight += multiplier; + } + } + + if (triggeredWeight <= 0) return 0; + return opts.maxDailySellFraction * (triggeredWeight / totalWeight); +} + +function isMonthStart(date) { + return date.endsWith("-01"); +} + +function pct(value) { + return value * 100; +} + +function printTable(results, opts) { + console.log( + [ + `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`, + `Sell: ${ + opts.mode === "hold" + ? "disabled" + : opts.sellRule === "ath" + ? `optional, previous ATH x${formatNumber(opts.sellAthMultiple, 2)}, max ${formatNumber(opts.maxDailySellFraction * 100, 3)}% BTC/day` + : opts.sellRule === "percentile-band" + ? `optional, armed by p${opts.sellArmPct} touch, p${opts.sellBandLowerPct}-p${opts.sellBandUpperPct}, sell size x${formatNumber(opts.sellBandMultiple, 2)}, base ${formatNumber(opts.maxDailySellFraction * 100, 3)}% BTC/day` + : `optional, percentile map, max ${formatNumber(opts.maxDailySellFraction * 100, 3)}% BTC/day` + }`, + "", + ].join("\n"), + ); + + const rows = results.map((result) => ({ + start: `${result.start_label} ${result.start_date}`, + mode: result.mode, + contributed: usd(result.contributed), + final: usd(result.final_value), + ret: `${formatNumber(result.return_pct, 2)}%`, + cash: usd(result.cash), + btc: formatNumber(result.btc, 6), + buys: String(result.buys), + sells: String(result.sells), + dd: `${formatNumber(result.max_drawdown_pct, 2)}%`, + vs_lump: `${formatNumber(result.lump_delta_pct, 2)}%`, + vs_dca: `${formatNumber(result.daily_dca_delta_pct, 2)}%`, + })); + + printFixedWidthTable(rows, [ + ["start", "start"], + ["mode", "mode"], + ["contributed", "contributed"], + ["final", "final"], + ["ret", "return"], + ["cash", "cash"], + ["btc", "btc"], + ["buys", "buys"], + ["sells", "sells"], + ["dd", "max dd"], + ["vs_lump", "vs lump"], + ["vs_dca", "vs dca"], + ]); +} + +function printCsv(results) { + const keys = Object.keys(results[0] ?? {}); + console.log(keys.join(",")); + for (const result of results) { + console.log( + keys + .map((key) => { + const value = result[key]; + if (typeof value === "string") return `"${value.replaceAll('"', '""')}"`; + return value; + }) + .join(","), + ); + } +} + +function printFixedWidthTable(rows, columns) { + const widths = new Map(); + for (const [key, label] of columns) { + widths.set( + key, + Math.max(label.length, ...rows.map((row) => String(row[key]).length)), + ); + } + + const formatRow = (row) => + columns + .map(([key]) => String(row[key]).padEnd(widths.get(key))) + .join(" "); + + console.log(formatRow(Object.fromEntries(columns.map(([key, label]) => [key, label])))); + console.log( + columns.map(([key]) => "-".repeat(widths.get(key))).join(" "), + ); + for (const row of rows) console.log(formatRow(row)); +} + +function usd(value) { + return `$${formatNumber(value, 2)}`; +} + +function formatNumber(value, digits) { + return new Intl.NumberFormat("en-US", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(value); +} + +main().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/website_next/learn/contents/style.css b/website_next/learn/contents/style.css index cc8a17127..3b68d14b3 100644 --- a/website_next/learn/contents/style.css +++ b/website_next/learn/contents/style.css @@ -3,14 +3,15 @@ main.learn { counter-reset: content-theme; position: sticky; top: 0; - padding-top: var(--top-offset); - padding-bottom: calc(var(--top-offset) / 2); + padding-block: var(--offset); max-height: 100dvh; overflow: auto; scrollbar-width: thin; font-size: var(--font-size-xs); line-height: var(--line-height-xs); text-transform: uppercase; + padding-left: 0.5rem; + margin-left: -0.5rem; ol { list-style: none; @@ -38,16 +39,37 @@ main.learn { } a { + display: block; + scroll-margin-block: var(--offset); color: inherit; text-decoration: none; + margin-right: 1rem; &::before { opacity: 0.5; } - &:is(:hover, [aria-current="location"]) { + &:is(:hover, :active) { + margin-block: -0.25rem; + margin-left: -0.5rem; + padding: 0.25rem; + padding-left: 0.5rem; + border-radius: 0.25rem; + } + + &:hover { + color: var(--white); + background-color: var(--dark-gray); + } + + &[aria-current="location"] { color: var(--orange); } + + &:active { + color: var(--black); + background-color: var(--orange); + } } > ol > li > a::before { diff --git a/website_next/learn/scroll-spy.js b/website_next/learn/scroll-spy.js index 1b36bad8a..0f9c00e9e 100644 --- a/website_next/learn/scroll-spy.js +++ b/website_next/learn/scroll-spy.js @@ -1,7 +1,14 @@ /** @param {HTMLElement} main */ export function initScrollSpy(main) { - const headings = [...main.querySelectorAll("article h1, article h2")]; - const visibleHeadings = new Set(); + const sections = [...main.querySelectorAll("section[id]")]; + const sectionStates = sections.map((section) => ({ + section, + children: [...section.querySelectorAll(":scope > section")], + intersecting: false, + })); + const stateBySection = new Map( + sectionStates.map((state) => [state.section, state]), + ); const links = new Map( [...main.querySelectorAll('nav a[href^="#"]')].map((link) => [ link.getAttribute("href"), @@ -12,12 +19,24 @@ export function initScrollSpy(main) { /** @type {string | null} */ let current = null; - /** @param {Element} heading */ - function getHash(heading) { - const section = /** @type {HTMLElement} */ ( - heading.closest("section[id]") + /** @param {Element} section */ + function getVisibleHeight(section) { + const rect = section.getBoundingClientRect(); + return Math.max( + 0, + Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0), ); - return `#${section.id}`; + } + + /** @param {{ section: Element, children: Element[] }} state */ + function getOwnVisibleHeight(state) { + let height = getVisibleHeight(state.section); + + for (const child of state.children) { + height -= getVisibleHeight(child); + } + + return Math.max(0, height); } /** @param {string} hash */ @@ -26,38 +45,62 @@ export function initScrollSpy(main) { } /** @param {string} hash */ - function setCurrent(hash) { + function setCurrentHash(hash) { if (hash === current) return; if (current) getLink(current).removeAttribute("aria-current"); - getLink(hash).setAttribute("aria-current", "location"); + + const link = getLink(hash); + link.setAttribute("aria-current", "location"); + link.scrollIntoView({ block: "nearest", inline: "nearest" }); + history.replaceState(null, "", hash); current = hash; } + function getCurrentSection() { + /** @type {{ section: Element, children: Element[] } | undefined} */ + let currentState; + let currentHeight = 0; + + for (const state of sectionStates) { + if (!state.intersecting) continue; + + const height = getOwnVisibleHeight(state); + + if (height > currentHeight) { + currentState = state; + currentHeight = height; + } + } + + return currentState?.section; + } + function update() { if (main.hidden) return; - const heading = headings.findLast((heading) => - visibleHeadings.has(heading), - ); - if (heading) setCurrent(getHash(heading)); + const section = getCurrentSection(); + if (section) setCurrentHash(`#${section.id}`); } const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { - if (entry.isIntersecting) { - visibleHeadings.add(entry.target); - } else { - visibleHeadings.delete(entry.target); - } + const state = /** @type {{ intersecting: boolean }} */ ( + stateBySection.get(entry.target) + ); + state.intersecting = entry.isIntersecting; } update(); }, - { rootMargin: "0px 0px -80% 0px" }, + { + threshold: [ + 0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, + ], + }, ); - for (const heading of headings) observer.observe(heading); + for (const section of sections) observer.observe(section); } diff --git a/website_next/learn/style.css b/website_next/learn/style.css index 1410a5d54..675a4703d 100644 --- a/website_next/learn/style.css +++ b/website_next/learn/style.css @@ -1,5 +1,5 @@ main.learn { - --top-offset: 6rem; + --offset: 6rem; --content-width: 52rem; display: grid; @@ -9,8 +9,7 @@ main.learn { article { counter-reset: theme; - padding-top: var(--top-offset); - padding-bottom: calc(var(--top-offset) / 2); + padding-block: var(--offset); &::before { content: ""; @@ -18,10 +17,10 @@ main.learn { top: 0; z-index: 2; display: block; - height: var(--top-offset); - margin-top: calc(-1 * var(--top-offset)); + height: var(--offset); + margin-top: calc(-1 * var(--offset)); margin-inline: auto; - margin-bottom: calc(-1 * var(--top-offset)); + margin-bottom: calc(-1 * var(--offset)); background: var(--black); pointer-events: none; } @@ -31,12 +30,12 @@ main.learn { counter-reset: topic; width: min(100%, var(--content-width)); margin-inline: auto; - scroll-margin-top: var(--top-offset); + scroll-margin-top: var(--offset); } > section:first-of-type { - margin-top: calc(-1 * var(--top-offset)); - padding-top: var(--top-offset); + margin-top: calc(-1 * var(--offset)); + padding-top: var(--offset); } > section + section { @@ -45,14 +44,14 @@ main.learn { section section { counter-increment: topic; - scroll-margin-top: var(--top-offset); + scroll-margin-top: var(--offset); } } h1, h2 { position: sticky; - top: var(--top-offset); + top: var(--offset); padding-bottom: 0.5rem; background: var(--black); line-height: 1;