A scroll-driven data essay, sober, zero infographic-cute
Site 02 of the fable-25 portfolio. The direction, verbatim in spirit: one <canvas> chart that morphs as the essay scrolls — warming stripes → annual scatter → smoothed line → zoomed 1970–2025 → a single glowing “you are here” point at 2025 (+1.19 °C). Each morph must be a tweened re-projection of the same data; points animate to their new coordinates, never a cut. Margin annotations (Krakatoa 1883, Pinatubo 1991, the 1998 El Niño, the 2023–24 records) fade in tied to data positions.
Palette: archive cream #f4efe6, ink #20242c, a heat ramp from #3d6bb3 to #b3372f with derived intermediate stops, rule gray #c9c2b4. Type: Newsreader (text, real italics) and IBM Plex Mono (figures, axis labels). The thing to prove: data storytelling can be typographically beautiful and honest — no truncated axes, baseline and units always visible, smoothing labeled.
The morph is a re-projection, not an animation
There is no per-state artwork. Each of the five figures is a function from the same 146 (year, anomaly) pairs to marks on one canvas. A mark is a rounded rect with position, size, alpha, and a mix-toward-gray. State 0 makes them viewport-tall stripes; state 1 shrinks them into dots and drops them to their measured values — which is the morph the whole page is built on:
function markAt(i, k) {
const yr = SERIES[i][0], v = SERIES[i][1];
const x = sx(k, yr);
switch (k) {
case 0: {
const w = plotW() / SERIES.length + 0.6;
return { x, y: (plotT() + plotB()) / 2, w, h: plotH(), a: 1, m: 0 };
}
case 1: return { x, y: sy(1, v), w: 6.6, h: 6.6, a: .92, m: 0 };
case 2: return { x, y: sy(2, v), w: 5.2, h: 5.2, a: .34, m: .72 };
case 3: return { x, y: sy(3, v), w: 7.2, h: 7.2, a: .95, m: 0 };
case 4: {
if (yr === 2025) return { x, y: sy(4, v), w: 10, h: 10, a: 1, m: 0 };
return { x, y: sy(4, v), w: 4.6, h: 4.6, a: .22, m: .32 };
}
}
}
index.html — per-point geometry for each named state
The draw loop takes the fractional scroll progress cur, asks every point for its geometry in state ⌊cur⌋ and state ⌊cur⌋+1, and lerps every channel. Scrolling backwards runs the same projection in reverse for free:
const k = clamp(Math.floor(cur), 0, 3), f = cur - k;
for (let i = 0; i < SERIES.length; i++) {
const A = markAt(i, k), B = markAt(i, k + 1);
const x = lerp(A.x, B.x, f), y = lerp(A.y, B.y, f);
const w = lerp(A.w, B.w, f), h = lerp(A.h, B.h, f);
const a = lerp(A.a, B.a, f), m = lerp(A.m, B.m, f);
if (a < .01) continue;
const c = m > .01 ? mix3(COLORS[i], DIM, m) : COLORS[i];
ctx.fillStyle = rgb(c, a);
ctx.beginPath();
ctx.roundRect(x - w/2, y - h/2, w, h, Math.min(w, h)/2);
ctx.fill();
}
index.html — drawMarks(), the tween that owns every transition
Axes travel the same way: each state declares an x/y domain, the current domain is the lerp of the bracketing two, and tick sets crossfade while tick positions ride the moving domain. That is why the zoom into 1970–2025 feels like a camera move and the zero line never leaves the frame.
One smoothed progress value, with a teleport escape hatch
Scroll position maps to a target state through plateaus — each prose step owns a flat span of scroll where its figure holds still, with short ramps between. The drawn value chases the target with a constant-factor lerp, so every morph has the same temper no matter how violently you scroll. One special case: if the page jumps more than 1.6 viewports in a frame (find-in-page, a dragged scrollbar, a restored session), tweening through the intermediate states would be a lie about where you came from, so it snaps:
function tick(now) {
raf = null;
const jump = Math.abs(scrollY - lastScrollY) > innerHeight * 1.6;
lastScrollY = scrollY;
target = targetFromScroll();
const kf = reduced.matches ? 1 : 0.095;
cur += (target - cur) * kf;
if (jump || Math.abs(target - cur) < 0.0006) cur = target;
draw(now);
const pulsing = cur > 3.5 && !reduced.matches;
if (Math.abs(target - cur) > 0.0005 || pulsing) wake();
}
index.html — the loop; note reduced-motion collapses the lerp to instant
The loop parks itself when settled (requestAnimationFrame is only re-armed while the value is still moving or the final halo is breathing) and pauses entirely when the tab is hidden.
The honesty rules, mechanized
The brief's must-prove is enforced in code, not just intentions:
- Baseline always visible. The zero line has its own alpha track across states and never reaches 0 once the axes exist; the zoomed domain (−0.34 to +1.44) is chosen so zero stays in frame. The figure's footer repeats units and baseline in every state.
- Smoothing labeled. The lowess curve carries lowess · 15-yr window on the canvas itself, and the annual points stay visible (dimmed, never deleted) wherever the smooth is shown.
- The trend states its window. least squares · 1970–2025, dashed, computed in-browser from the raw series — the label prints the fitted slope, it is not typed in as copy.
- The stripes carry their scale. Color-only charts hide magnitude, so Fig. I wears its full ramp with −0.5 / +1.3 °C endpoints and a zero notch.
The smoothing itself, since it gets named in-page, is worth showing — tricube weights over a centered fifteen-year window, one fitted value per year:
function lowess(series, half) {
return series.map(([yr]) => {
let num = 0, den = 0;
for (const [y2, v2] of series) {
const d = Math.abs(y2 - yr) / (half + 1e-4);
if (d >= 1) continue;
const w = Math.pow(1 - d*d*d, 3);
num += w * v2; den += w;
}
return num / den;
});
}
index.html — SMOOTH = lowess(SERIES, 7)
Every pixel computed, every number cited
- Data
- NASA GISS Surface Temperature Analysis (GISTEMP v4), Land-Ocean Temperature Index. data.giss.nasa.gov/gistemp/graphs_v4/, retrieved 2026-07-09. Global annual mean surface temperature anomaly, °C, relative to the 1951–1980 baseline; values are the No_Smoothing annual series, shipped verbatim in data/gistemp.json. No value was adjusted, omitted, or truncated. Every claim in the essay (−0.28 in 1884, +0.61 in 1998, the 0.16 °C jump of 2023, +1.28 in 2024, +1.19 in 2025, the +0.20 °C/decade trend) was checked against this file before shipping.
- Imagery
- None. No photography, no stock, no generated images. The figure, the hero ribbon, and the favicon stripes are drawn from the series at runtime; the paper grain is an inline SVG feTurbulence filter.
- Color
- The heat ramp interpolates from the brief's blue #3d6bb3 to its red #b3372f through a parchment neutral, with a deepened shade past each endpoint for the record extremes. The same ramp(v) colors every figure, the ribbon, and the favicon.
- Type
- Newsreader variable (opsz, real italics) and IBM Plex Mono 400/500, latin subsets, vendored locally as woff2 via fontsource. No CDN requests; the page makes exactly one fetch, for its own data file.
- Motion
- Custom cubic-beziers only; the chart runs on one smoothed progress value. Under prefers-reduced-motion the lerp collapses to instant states, the scroll cue stops, and the final halo holds a fixed radius — audited in CSS and in the reduced.matches branches of the draw code.
Three passes, kept honestly
Pass 1 — the timid ribbon
Screenshots: hero, scatter, lowess, final state, mobile hero.
Seen: the hero's right half was dead cream and the record ribbon sat at 584px — a timid gesture under a caption claiming “the whole record.” The Krakatoa annotation's leader line ran solid across nearly the full plot and read as a stray gridline. In Fig. V the “faded to context” points were nearly invisible (α = .09), and the +1.19 numeral's °C sat inside the halo's bleed. The mobile folio wrapped all three spans to two clumsy lines.
Fixed: ribbon runs the full measure; leader lines went dotted at lower alpha; context points raised; numeral moved clear of the halo (and the halo tightened); the folio's middle span is hidden on small screens.
Upgrade: the ribbon became an instrument — scrub it with a fine pointer and the caption reads out year · anomaly while the stripe under the cursor gets an ink notch.
Pass 2 — the baseline law, applied to myself
Screenshots: hero, stripes, scatter, final state, mobile hero.
Seen: the full-width ribbon and dotted leaders worked. But Fig. V still under-delivered “the whole record returns” — context points read as dust — and the state-V axis and zero-line alphas (.34/.45) were too faint for a page whose motto is that the baseline never leaves. My own honesty rule, failed by a tint.
Fixed: context points to α .22 with less gray mixed in; state-V axis and baseline alphas raised to .5/.6 so the reference frame stays legible under the glow.
Upgrade: a second annotation species — a bracket span over 1945–1975 in Fig. III, the flat decades, tying the essay's sulfate-haze sentence to the exact years it describes.
Pass 3 — collisions only a full sweep finds
Screenshots: all five settled states on desktop, page bottom, mobile stripes and mobile final, plus this guide.
Seen: the new era bracket's centered descriptor tail collided with the rising lowess curve; the trend label sat directly on the curve and points at the zoom's top-right; the mobile figure caption truncated mid-“1951–1980,” which is exactly the kind of clipped baseline this page exists to refuse; and a deliberately mis-timed mobile shot caught the IV→V transition piling two annotation cards onto the incoming numeral.
Fixed: era labels left-aligned to the bracket's start, clear of the curve; the trend label moved to the zoom frame's empty lower-right, naming its dashes; the mobile caption swaps to a shorter string that keeps units and baseline whole; the numeral's fade-in delayed past the cards' fade-out.
Upgrade: a hover inspector on the settled measurement states (II–IV): a fine pointer gets a hairline, a ring on the nearest year, and a year · value readout drawn on the canvas — the chart answers questions instead of just performing.