From 250b8cfbd42ef1574c5e0d0b6cb1b7d1e5bbc617 Mon Sep 17 00:00:00 2001 From: 37ng Date: Tue, 8 Sep 2026 11:38:01 -0700 Subject: [PATCH 1/9] feat: add accelerator fees by pool to the bitcoin post One stacked column per month of mempool.space accelerator fees, split by the pool that took them, from mempool-space-by-month.json. Figures are BTC to three significant digits; the exact sats stay in the readout and in every hover title. The axis is linear and stays linear. A band's height is the amount that pool took and the top of a column is the month's total. A log axis reads better -- months run from 0.004 BTC to 2.9 -- but stacking on a log scale plots the running total, which leaves band heights meaning nothing. The reader zooms instead: clicking a pool refits the axis to that pool's own months, which is the only way a pool worth a tenth of a percent gets a readable plot. Bands get no minimum height either, so they stay exactly proportional; a pool too small to see is still reachable from the legend. Hovering a pool drops the other thirteen, clicking keeps it alone, and moving across the plot sets a month cursor that rewrites the readout and every legend figure to that month. Pools rank by lifetime total and take palette slots in that order, so the biggest is always the floor of the stack and a pool keeps one color everywhere. That needed fourteen slots, so --pool-1..4 are now aliases onto a merged --series-1..14 rather than a second palette; --pool-5 stays its own neutral because it is the residual bucket, not a series. Existing figures render unchanged. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 4 + .../bitcoin/diagrams/AcceleratorPools.astro | 5 + .../bitcoin/diagrams/AcceleratorPools.tsx | 356 ++++++++++++++++++ src/posts/bitcoin/index.mdx | 3 + .../bitcoin/lib/accelerator-pools.test.ts | 187 +++++++++ src/posts/bitcoin/lib/accelerator-pools.ts | 157 ++++++++ src/styles/global.css | 61 ++- 7 files changed, 758 insertions(+), 15 deletions(-) create mode 100644 src/posts/bitcoin/diagrams/AcceleratorPools.astro create mode 100644 src/posts/bitcoin/diagrams/AcceleratorPools.tsx create mode 100644 src/posts/bitcoin/lib/accelerator-pools.test.ts create mode 100644 src/posts/bitcoin/lib/accelerator-pools.ts diff --git a/CLAUDE.md b/CLAUDE.md index f1f81c7..1ea7aa2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,8 @@ Each blog is an MDX file that combines article content with interactive illustra **Light theme — dark is blueprint, light is paper.** `ThemeToggle.tsx` sits in the post header, beside the date, and pins ``, persisted to `localStorage` (a reading preference outlives a visit, unlike the homepage stage's `sessionStorage`). No component knows about it: `global.css` re-points the whole `--color-ink-*` ramp at warm paper tones, and since that ramp is a _value_ ramp (950 furthest back, 50 furthest forward) every existing `bg-ink-900`/`text-ink-300` flips at once with its contrast relationship intact. **It is scoped `html[data-theme="light"]:has(#post)`** — the homepage is full-bleed art whose legibility comes from each post's `stageTone`, and inverting the ramp under it would put near-black type on a dark photograph. `--color-ink-400` is pinned by measured contrast (4.6:1), not by eye, because it carries the date and every mono label. The `` bootstrap in `BaseLayout.astro` resolves `localStorage` then `prefers-color-scheme` before the body is parsed, and the toggle ships both glyphs with CSS hiding one — picking in JSX would flip the icon at hydration, which is the flash the bootstrap exists to prevent. Same `var`-only IIFE + `astro:after-swap` rules as the stage bootstrap. +**Chart series palette:** `--series-1` … `--series-14` in `global.css`, stepped separately for dark ink and light paper. `--pool-1` … `--pool-4` are **aliases onto the same slots**, not a second palette — a pool must keep one color across every figure. `--pool-5` stays its own neutral because it is the residual bucket (`.pool-rest` hatches it), not a series. Slots 1-4 were run through the palette validator; slots 5-14 exist only because the accelerator chart has fourteen pools and are separated by hover and click as much as by hue. + **Design tokens:** All colors, fonts, and spacing live in `src/styles/global.css` under the `@theme` block (Tailwind v4 CSS-native config). Tailwind generates utility classes directly from these CSS variables — e.g. `bg-ink-900`, `text-signal-500`. Never hardcode colors in components; always use token-derived utilities. **Design language:** "Engineering plan set / spec sheet." Cold blue-slate `ink` neutrals + one oxide-orange `signal` accent (`#c2410c`-family) — signal appears only on rules, indices, and hover states, never on body text. Mono (`font-mono`, JetBrains Mono) carries all UI chrome — nav, labels, dates — uppercase with wide tracking (enforced globally by a `.font-mono` rule in `global.css`, not per-component). Fraunces drives its variable axes (`opsz`/`SOFT`/`WONK`) on every heading, sitewide, not just in prose. Zero radius everywhere (`--radius-*: 0`); panels use hairline rules (`shadow-panel` token), not blurred drop shadows. Interactions are mechanical — no opacity fades or translate-Y lifts; see `.spec-card` (hard offset border, print-misregistration style) and `.nav-index` (mono index number that shutters open, not fades in) in `global.css`. Apply `prose-tech` class to article content for the blog prose styles — includes auto-numbered `§01`/`§02` section counters on `h2`. Every `h2`/`h3` also gets a hover-revealed `#` permalink (`.heading-anchor`, injected at build time by `rehype-autolink-headings` in `astro.config.mjs`). @@ -39,6 +41,8 @@ Each blog is an MDX file that combines article content with interactive illustra **Implied off-chain revenue figure** (`src/posts/bitcoin/diagrams/OffchainRevenue.tsx`, in the post): one mark per block that mined a transaction paying under its own floor feerate, `(floor_B − actual) × vsize` on a log scale, with a per-day coverage rail underneath for the blocks that carried nothing. The numbers in `src/posts/bitcoin/data/offchain-revenue-jan2023.json` are **pseudo data** — a deterministic stand-in written by `npm run offchain-pseudo`, there so the figure could be built before the real measurements exist. Real data replaces that one file and nothing else; the generator script then goes away. Two things the generator has to keep true, because the tooltip prints all three numbers side by side: `impliedSats` is derived from the _rounded_ feerates, so a reader who multiplies them out lands on the printed figure; and only flagged blocks are listed, with `totalBlocks`/`blocksPerDay` carrying the rest (the rail's denominator). Marks are painted through `.chart-mark` in `global.css` rather than a token utility — a post's `color` is chosen for identity, not contrast, so on the light theme's paper the figure swaps to `--accent-dark`. +**Accelerator fees by pool** (`src/posts/bitcoin/diagrams/AcceleratorPools.tsx`, at the top of the post): one **stacked column per month**, split by the pool that took the fee, from `src/posts/bitcoin/data/mempool-space-by-month.json`. Figures are **BTC**, three significant digits, never an exponent (`formatBtc`) — the file stores sats and the exact sats stay in the readout's second line and in every hover title, since a rounded BTC figure is for reading and the sats are for checking. The axis is **linear**, and stays linear: a band's height is the amount that pool took and the top of a column is the month's total. A log axis was tried and dropped — stacking on a log scale plots the running total, which leaves band heights meaning nothing, and no caption repairs a figure the eye reads wrong. Nothing is given a minimum height either, so the bands are exactly proportional; a pool too small to see in the stack is still reachable from the legend. The cost is real and is accepted: months run from 0.004 BTC to 2.9, so most columns are short beside jan 2024 and jun 2026. The answer is to let the reader zoom rather than bend the scale — `axisMax` fits the ceiling to what is on screen and rounds it to an even tick step, so clicking a pool refits the axis to that pool's own months and gives a pool worth a tenth of a percent a readable plot. Pools are ranked by lifetime total and take palette slots in that order, so the biggest pool is always the floor of the stack, the legend reads top-down, and a pool keeps its color everywhere. A pool that took nothing that month gets **no band at all** rather than a zero-height one, so `segments()` returns only the pools actually paid and the legend prints `—` for the rest. Reading is by three moves that never fight: hovering a pool (legend row or band) drops the other thirteen to 0.12, clicking keeps that pool alone and rebases its bands to zero, and moving across the plot sets a month cursor that rewrites the readout and every legend figure to that month. Nothing animates. + **Interactive diagrams:** a post's own figures live in `src/posts//diagrams/`, beside the prose and the data they read, so the MDX imports them relatively (`./diagrams/BitcoinTimeline.astro`) and `src/components/` keeps only what the whole site uses. They are heavier React components (e.g. `BitcoinCoin3D.tsx`, `three` + `@react-three/fiber`) and must be hydrated explicitly at the call site — `client:load`/`client:visible` in MDX posts, `client:idle` for the stage furniture in `HeroStage.astro`. A diagram with two homes gets a thin `.astro` mount point beside it (`BitcoinTimeline.astro`) so the hydration directive stays one decision; that is also the only reason `HeroStage.astro` reaches across into a post folder (`@/posts/bitcoin/diagrams/…`) — the coin and the timeline belong to the bitcoin post, the stage only borrows them. Their binary payloads live in that post's own `assets/` folder and are pulled in with a relative `?url` import (`import modelUrl from "../assets/coin.glb?url"`), not from `public/`: the build then fingerprints the file for long-term caching and fails at compile time if it is ever moved or renamed, where a `public/` path only 404s at runtime. **Path alias:** `@/` maps to `src/` (used for site-wide modules like `import { backdropFor } from '@/lib/stage'`; a post's own files are imported relatively instead). diff --git a/src/posts/bitcoin/diagrams/AcceleratorPools.astro b/src/posts/bitcoin/diagrams/AcceleratorPools.astro new file mode 100644 index 0000000..fd93c30 --- /dev/null +++ b/src/posts/bitcoin/diagrams/AcceleratorPools.astro @@ -0,0 +1,5 @@ +--- +import { AcceleratorPools } from "./AcceleratorPools"; +--- + + diff --git a/src/posts/bitcoin/diagrams/AcceleratorPools.tsx b/src/posts/bitcoin/diagrams/AcceleratorPools.tsx new file mode 100644 index 0000000..6e732b5 --- /dev/null +++ b/src/posts/bitcoin/diagrams/AcceleratorPools.tsx @@ -0,0 +1,356 @@ +import { useMemo, useState } from "react"; +import { + axisMax, + axisTicks, + formatBtc, + formatMonth, + formatShare, + GRAND_TOTAL, + lifetimeShare, + MONTHS, + POOLS, + position, + segments, + shareOfMonth, + yearTicks, + type MonthRow, + type PoolSeries, + type Segment, +} from "../lib/accelerator-pools"; + +const PLOT_HEIGHT = 240; +const YEARS = yearTicks(); +const STACKS = MONTHS.map(segments); + +export function AcceleratorPools() { + const [selected, setSelected] = useState(null); + const [hovered, setHovered] = useState(null); + const [cursor, setCursor] = useState(null); + + const focus = selected ?? hovered; + const row = cursor === null ? null : MONTHS[cursor]; + const ceiling = useMemo(() => axisMax(selected), [selected]); + const ticks = useMemo(() => axisTicks(ceiling), [ceiling]); + const toggle = (id: string) => + setSelected((current) => (current === id ? null : id)); + + return ( +
+
+ + accelerator fees paid, by pool + + + btc · monthly · {formatMonth(MONTHS[0].month)} —{" "} + {formatMonth(MONTHS[MONTHS.length - 1].month)} + +
+ + + +
{ + setCursor(null); + setHovered(null); + }} + > + + +
+ {ticks.map((tick) => ( + +
+ +
+ + + setSelected(null)} + /> + +
+ one column per month, in BTC, split by the pool that took the fee. the + axis is linear, so a band's height is the amount and the top of a column + is the month's total. hover a pool to pick it out; click to keep it + alone, which refits the axis to that pool's own months — the only way a + pool worth a tenth of a percent gets a readable plot. +
+
+ ); +} + +function Column({ + stack, + ceiling, + index, + cursor, + selected, + focus, + onCursor, + onHover, + onToggle, +}: { + stack: Segment[]; + ceiling: number; + index: number; + cursor: number | null; + selected: string | null; + focus: string | null; + onCursor: (index: number) => void; + onHover: (id: string | null) => void; + onToggle: (id: string) => void; +}) { + const shown = selected + ? stack + .filter((segment) => segment.pool.id === selected) + .map((segment) => ({ ...segment, base: 0 })) + : stack; + + return ( +
onCursor(index)} + > + {cursor === index && ( +