One dataset, five projections, zero cuts

Build notes for 1.19 Degrees: the brief, the morph engine, the honesty rules, and the three critique passes, kept as written.

The brief

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.

Technique 01

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.

Technique 02

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.

Technique 03

The honesty rules, mechanized

The brief's must-prove is enforced in code, not just intentions:

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)

Provenance

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.
The critique log

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.