Data Storytelling 数据叙事

A Hundred Summers·50s·Lemo-Opuscarby Lemomo

A chart that tells a story: real data, drawn mark by mark on cream paper by a red–blue pencil, with handwritten notes pinned to the numbers. The chart itself is the camera, the rhythm and the plot.

The demo: A hundred years of summer temperatures, where the chart itself tells the story.

Data journalismReportsClimate & science
Style prompt · STYLE.md

A chart that tells a story: real data, drawn mark by mark on cream paper by a red–blue pencil, with handwritten notes pinned to the numbers. The chart itself is the camera, the rhythm and the plot. References (grammar only): Hans Rosling's 200 Countries, 200 Years, 4 Minutes (one dot is a character), Ed Hawkins' warming stripes (a diverging ramp on a reference mean, no axes), The Pudding / NYT scrollytelling (one idea at a time, growing axes, pinned notes). Copy none of their charts, layouts or typefaces; never name them in the film.

1. Essence, and what it is not

The chart is the film. Every mark is a real number and every movement is a meaningful chart operation (a mark lands, an axis grows, the scale changes, a series morphs, a note is pinned). The data is real, openly licensed and traceable. You change the framing, never the numbers.

Pick the chart the data asks for:

The data is about Chart
comparing categories, rankings bars, a ranked bar race
a relationship between two measures scatter or bubble chart (one dot can be a character)
place map, choropleth, dots on a map
parts of a whole, counts of people unit / waffle chart, proportional squares
before → after slope or dumbbell chart
a distribution histogram, beeswarm
where things go flow / Sankey
change over time line or dot series, stripes, area

Three layers make it a story, not an infographic: a human scale (handwritten notes pinned to marks), a performer (a physical pencil that draws every mark), and sonification (each mark is a pitch; the trend is heard before it is read).

Not a dashboard (one idea at a time), not a keynote with charts (no slides), never decoration.

2. Materials & rendering

  • Paper: warm cream, fixed-seed fibre noise and a faint dot grid in world space. Ink: dark warm ink for axes and series, pale warm grey for grid and zero line, IBM Plex Mono ticks.
  • Marks: data dots carry a thin ink ring so pale values stay visible on cream. A mark lands with a short pop and a thin ripple; suppress ripples when marks arrive closer than ~0.1 s.
  • Handwriting (Caveat) in pencil, per-glyph jitter, graphite grain, written on as the tip dances over the x-height. Leaders are wobbly curves ending in an un-closed ring around the mark.
  • The red–blue pencil: hex body, blue half / red half, sharpened wood cone. Only the tip end shows, from the lower right; its shadow drifts farther and softer with lift; sharp tip, soft body.
  • Layout: a plot box fixed in world space, a memory zone for early notes, every note pinned in data space (value + pixel offset) so rescales keep the layout, a bottom band for the caption.

3. Colour logic

  • Colour encodes one thing: the value. Diverging data uses a ramp (ColorBrewer RdBu family) centred on the mean of a meaningful reference period, with a symmetric span; data with no natural centre uses one sequential ramp. Everything else is ink on cream.
  • Honesty beats drama: pale values stay pale; never stretch a ramp to make a change look bigger.
  • The two pencil leads have meanings: blue for memories and human notes, red for records and warnings. A change of lead marks a turn.
  • One accent colour may bypass the ramp for a mark that is not data (a logo, a highlight).

4. Type & subtitles

  • The subtitle is the figure caption: a text serif (Newsreader), ink colour, left-aligned with the chart, ≤ 2 lines, with a hairline rule above it and a small mono kicker for the range drawn so far. Words appear on their whisper timestamps over a paper-colour band. Hold ≥ max(1.8 s, speech + 0.6 s).
  • The title is the chart's own headline (Newsreader), set in world space above the plot; it fades when the camera is close and returns with the full view.
  • No captions over a signature chart operation or a silence.

5. Motion quality

  • The rhythm of the data is the rhythm of the film. Map the marks to a musical grid; to accelerate, one option is to divide the beat instead of changing tempo. Pick a tempo whose finest subdivision lands on whole frames (at 24 fps a minute has 1440 frames; the count of finest notes per minute must divide it), so picture and sound never drift.
  • Pencil acting: lift before each tap, land with the shadow meeting the tip, a tiny rebound; hurry as data densifies, tremble like a seismograph needle at a peak. It can flinch (recoil and freeze), hesitate (hover before a mark) and exit (hand the story to the viewer).
  • Rescale reads as ratchet clicks (stepped, each step eased); a calm rescale is smooth.
  • Morph: a staggered sweep, each mark easing into its new form; notes fade as it passes and return as tiny marks.
  • No fades to blank or dissolves. Transitions are chart operations: axis growth, pull-backs, rescales, morphs, a cell widening into the next frame.

6. Camera grammar

A 2D camera {x, y, zoom, roll} over one sheet of paper. A film is one take; a hard cut is rare and meaningful. The moves below are a vocabulary, not a route. Opening and ending come from the topic.

Move Expresses Can serve
Extreme close-up on one mark, following its leader One fact, one life a single case; a named person
Full view first, then dive to one mark The pattern before the case a familiar trend; a myth to correct
Pull back as axes grow out of the marks The frame arrives with the unit naming the measure; widening a claim
Track the pencil, newest mark at ~60 % of width Progress: the past behind, the future ahead a series in time; a race; a queue
Pedestal with rising data, a Dutch angle creeping in Escalation, unease runaway growth; a debt
Punch-in over 2 frames, decaying shake, jolt on rescale Surprise, a broken frame an outlier; a crash
Fast pull-out, roll, small constant tremor A rush a boom; a cascade; a panic
Locked-off The transformation is the movement a morph; a slow reading
Hold on one mark, then pull back The part inside the whole a verdict; context
Pull and pan to add more real data Scale switch a longer record; the rest of the list
Lateral pan across notes in the memory zone Recollection a biography; a timeline of events

Framing: subjects fill ≥ 1/3 of frame height at key moments. When the real axis scrolls off-screen, pin its tick labels to a paper strip at the frame edge (frozen pane).

7. Sound palette

  • Ambience follows the places the notes evoke (J-cuts in, L-cuts out). Foley is graphite, paper and wood: tick-and-paper taps, scratches for handwriting, a ruler hiss for axes, a paper puncture for a broken frame, a wooden click for the pencil flip, ratchet teeth for a rescale, a paper sweep for a morph.
  • Sonification is the music: pitch = value, rhythm = time density. Low values snap to a pentatonic scale, middle ones to semitones, extreme ones unquantized, with detune growing away from the reference. Timbre: a low-pass-gate "plonk" (sine + FM whose index rises with the value: wood → metal). Dense passages: quieter notes, decay ≤ 1.5 × the gap.
  • Bed: detuned-saw pad, soft kick, noise shaker, low-passed hats, sine sub, sample-and-hold blips, drones; harmony can darken or brighten with the data.
  • Silence is a tool: true digital silence (all layers) where the data should be heard alone, before a key mark, a morph or a verdict; the first sound after it matters most.
  • Mix: compressed calm, neutral narration, music ducked ~−11 dB, foley light except key taps; −14 LUFS, no grain. The voice never talks over a rush, a silence or a morph.

8. Native moves

A menu: use the ones your story needs.

  • One mark = one fact. Fits content like: the first store a chain opened; the first patient of a trial; the first satellite launched.
  • Axes grow with the story. The y axis arrives when the narration first names the unit. Fits content like: revenue; a city's population; vaccination rates.
  • Pinned annotations. Their content and colour carry the arc. Fits content like: an athlete's season; a family's migrations; releases on a usage curve.
  • Breaking the frame. The chart must make room; the torn edge stays as a scar. Fits content like: a viral spike; a price bubble; a record-breaking flood.
  • Encoding morph. Bars into a waffle, a map into a ranking, a line into a slope: the reveal of the whole. Fits content like: a budget as units of people; countries as a ranking; daily steps as a year strip.
  • Scale switch. One value → the series → a longer real record, never extrapolated. Fits content like: one day's energy use → a year; one species count → a region.
  • The empty next cell. A dashed slot for the value nobody knows yet. Fits content like: next year's election; the next launch; tomorrow's reading.

9. Pitfalls of the medium

  • Select data columns by header name, never by index; print every fact the narration claims.
  • Notes collide with later data → data-space pins, a memory zone.
  • A frame break rescaled too fast is invisible → hold the pierced state ≥ half a beat.
  • Right-aligned notes are still written left to right → the pencil hops to the first letter.
  • Helpers that set globalAlpha must multiply the incoming alpha, or fades skip them.
  • Tails leak across a hard silence → render before/after in separate buffers; verify on decoded samples.
  • In dense passages the pencil body lies across fresh marks → steepen its angle.
  • Synthesized human sounds (a laugh) are uncanny → use a soft non-human sound.

10. Engine

demo/engine.js (Canvas 2D, world units = chart pixels at zoom 1) is the style: paper, camera, value ramps, pencilStroke (any polyline in pencil), inkShape (any closed shape: bars, areas, map shapes), plotShape, handText, dataDot, morphMark, annotation geometry, drawPencil, caption. demo/music/score.py holds the sonification. The function table and a minimal example are in DEMO.md.

11. Variation space

You decide the dataset, the chart, the human scale, the notes, the opening, the ending, the camera path, the pacing and the silences. All far from our demo:

  • Structures: a ranked race (bars climb and overtake; one bar is the character; a newcomer breaks the frame from below); a map that becomes a count (dots gather on a map as events happen, then fall into a unit chart of people in one morph); before → after dialogue (two years face each other as a slope chart; one line left unfinished).
  • Openings: the finished chart (the whole pattern is already drawn; the film works backwards); the object before the axis (a pencil drawing of the thing measured, a ticket, a shoe, becomes the first mark); the question first (an empty plot with only the unit and a handwritten question; the pencil hovers).
  • Endings: one mark, close (end on a single value and its note, never the full view); back to the object (the chart folds into a drawing of the thing it measured); the headline revised (the pencil strikes a word of the title and writes the true one).

How our demo was made (story, shots, score, end card, build): DEMO.md. Read it after your treatment exists.

Director's notes for the demo film

One example among many. Don't reuse its story, arc, shots, props or timings.

Demo: A Hundred Summers (50.0 s) · dataviz.mp4 · source in demo/

A hundred and one summers of real global temperature (NASA GISTEMP v4, June–August), told as one woman's life from 1926 to 2026.

Story & structure

Our data is a time series, so it is drawn as dots and a line that morph into stripes. That was one choice for one dataset.

Shape: a cold open on one point. Tender, sparse early points. A steady middle. Acceleration: the notes get denser, and the pencil hurries. The frame breaks, and the pencil flinches and flips to its red end. A rush. Everything stops half a beat early; this is the only cut in the film, into silence. Then the last data point, alone, followed by the whole series in one view. The morph into stripes comes next, then the longer record, then an empty cell for the next value ("Next summer?"). The end card lives inside that empty cell.

How it was fitted to the data: 101 points (a series of 60–150 points suits this shape); 3 human notes pinned to the first marks drawn ("1926 — she is born.") in blue, 2 record notes pinned to the last in red; one frame-breaking moment found in the data; the last point given its own silence.

Shots

Beat Camera
Cold open Extreme close-up on the first dot (≈4×). Tilt up to follow the leader as the note is written.
Title Pull back to 1.55×. The drop line becomes the x axis; the headline typesets above.
Early life Push back in, then track the pencil, newest point at ~62 % of frame width. Slow pull-out as time speeds up (2.3 → 1.7×).
Climb Pedestal up with the rising curve while a Dutch angle creeps in (0 → −1.5°).
Frame break Punch-in over 2 frames (1.22 → 1.42×) plus decaying shake. A small jolt on the rescale.
Rush Fast pull-out to 1.0×, roll to −3°, constant 2 px tremor.
The cut Hard cut into a close-up of the empty slot: dashed column, 2026 label, level horizon. Rack focus from the defocused foreground pencil to its tip as it lands.
Reveal Hold on the last point while its note is written, then pull back to the full view (1.06×). The axis rescales to fit everything.
Morph Locked-off.
Scale Pull and pan (→ 0.72×) to add the earlier record.
Echo Push to the empty cell; it widens into the end card (frame within frame).

Key moments with subjects ≥ 1/3 of frame height: the first dot plus note, the century chart, the stripes band.

Why this route, for this data: the first fact got the extreme close-up because one woman's birth was the human scale; the axes grew as the unit was first named; tracking kept the past behind and the future ahead; escalation, surprise and rush came from the data itself; the morph was locked-off because the transformation was the movement; the ending pushed into the empty next cell because the next value was unknown. Each is one choice from STYLE.md §6, not the style's default.

Motion numbers: 90 BPM (chosen because a 1/32 note is exactly 2 frames at 24 fps); 7 points on quarter notes → 24 on eighths → 40 on sixteenths → 27 on thirty-seconds (a point every 2 frames). Rescale: four steps on 1/32 notes, each eased over 0.09 beat; the second rescale, in the clarity phase, is smooth. Flinch: freeze half a beat, then a 0.5 s end-over-end flip to the red lead. Morph: sweep over 0.85 beat; each mark falls to the band centre (first 45 %), then stretches to a full stripe (ease-out) and loses its ring; line segments fade as their left dot starts morphing; annotations return as tiny pencil marks above the band.

Score structure

  • Ambience: room tone → evening crickets → the sea (J-cut before "1933", L-cut after) → cicadas that grow louder and denser with the heat (J-cut before the climb) → open wind after the silence → room tone.
  • Sonification (music/score.py): midi = 62 + (v + 0.4) × 17; below-centre values snap to D major pentatonic. Detune only on the last ~15 points (5 → 45 cents). The note right before the silence is the most dissonant (max detune plus a minor-second shadow); the last point after the silence is the cleanest, highest note.
  • Bed: pad Dmaj9 → G/D → D–Bm–G–A → Bb/D–Dm → semitone cluster; hats low-passed at 9.5 kHz. After the silence: a D open fifth (no third, no answer), a glissando of all 101 points replayed during the morph, Gmaj7#11 over D for the scale shot, and the first point's note again under the end card.
  • Score arc: sparse, tender early points → a steady middle → accelerating subdivision as the notes densify → rush → silence → the last point alone → the whole → the morph → the first note again.
  • Silences: 1.0 s true digital silence before the last point (all layers); 0.67 s near-silence before the empty cell. The first sounds after them: the last tap plus its note, then the first stroke of the empty cell.
  • Mix: music −11 dB and cicadas −6 dB under the voice; narration SNR in the 300–4000 Hz band 8.6–16 dB; −14 LUFS, grain 0.
  • Voice: Kokoro af_alloy, speed 0.92, 5 lines, never over the rush, the silence or the morph. Whisper writes numbers as digits: asr set to "For 50 years…" and "…turned 100…".
  • The baby's laugh was replaced by one music-box note.

Palette & props

  • Paper #F6F3EC; dot grid 24 px #E4DDCF. Ink #2B2723: axes 1.4–1.6 px, series line 2.4 px, gridlines #DDD6C9 1 px, zero line #BDB4A5 1.3 px. Tick numbers IBM Plex Mono 17–20 px #6E665C.
  • Dots r = 6 px with a 1.5 px ink ring; landing pop 0.25 s (×1.55).
  • Ramp, u = (v − centre) / half: #2166AC → #4393C3 → #92C5DE → #D1E5F0 → #F2EEE8 (centre) → #FDDBC7 → #F4A582 → #D6604D → #B2182B → #7A0F1E (beyond +1). Centre = 1971–2000 mean 0.22 °C, half-span 0.85 °C.
  • Handwriting Caveat 30 px world size, blue pencil #2F5D8A / red pencil #A8283A, per-glyph ±1.7° rotation and ±2.5 % baseline, 16 % of pixels knocked out for grain. Doodles: a three-curl wave, a heart.
  • Pencil wood cone #E3C79C, entering at 35–58°.
  • Plot box 1500 × 520 in world space; the first note above its mark, late notes above-left of their dot; the bottom 150 px for the caption. Title Newsreader 66 px.
  • Caption: Newsreader 42 px, x = 180, baseline y ≈ 985, kicker 1926–1958, 3-frame rise and fade, flips up when it leaves; the hold rule is asserted in tools/subs.py.
  • The engine showcase uses a #D97757 accent spark.

End card

Inside the dashed "next cell": title, DATA STORYTELLING, a miniature of the stripes plus an empty dashed cell, Lemo-Opuscar · LemoLab × Claude Opus 5.5, data, font, voice and music credits (including "2026 value preliminary, retrieved 2026-09-26"), and a handwritten "Her story is fiction. Every number is real." The LemoLab sign-off belongs to this library's demo only; a user's film carries no LemoLab credit and no copy of this card.

Build notes

styles/dataviz/demo/
  data/        GLB.Ts+dSST.csv (as downloaded) + jja.json      tools/extract_data.py  column → json + story facts
  timeline.js  90 BPM grid, year → beat map, every key beat, chart geometry (single source of truth)
  engine.js    the style: paper, camera, pencil strokes, handwriting, dots, stripes, morph, pencil, caption, shapes
  film.js      the story: annotations, camera path, pencil activities, chart layers, end card, events for the mix
  music/score.py   sonification + modular bed (reads events.json)      mix.py   ambience + foley + narration + ducking
  tools/       words.py subs.py cuecheck.py final_asr.py
  1. Download the dataset and write the extractor. Print every fact the narration will claim.
  2. Write timeline.js (grid plus year → beat map) before any drawing.
  3. Build engine.js, then film.js, and render gate frames from the real engine (node core/render/still.mjs … --range).
  4. sh demo/build.sh rebuilds everything from scratch in about 2 minutes: extract → TTS → whisper → words → events → score → cuecheck (107 cues, max 0.03 ms) → mix → subtitles → render (1200 frames, ~30 s with 2 workers) → mux (−14 LUFS, grain 0) → styleframe and poster.
  5. Review: a 1 s contact sheet from the final mp4 twice (offset by 0.25 s), 0.1 s strips of the break, the cut and the morph, per-line whisper on the final mix, and a sample-accurate silence check.

Pitfalls tied to this demo: GISTEMP has J-D and D-N before the seasonal columns, so the column is selected by header name (JJA). A frame break rescaled after 0.17 s was invisible. Right-aligned notes: the pencil hop takes 16 % of the text phase. pencilStroke, dashedCell and dataDot set globalAlpha themselves. In tracking shots the world-space title was cut by the frame edge. At 32nd-note density the pencil body lay across fresh dots.

Engine reference

demo/engine.js (ES module, Canvas 2D, 1920×1080; imports /core/lib.js). World units are chart pixels at zoom 1. Call applyCam(g, cam) before any world-space drawing.

Function What it does Key parameters
drawPaper(g, cam, {dots}) cream paper, fibre noise and dot grid, fixed in world space dots 0–1 grid alpha
applyCam(g, cam) / w2s / s2w / viewRect 2D camera transform and conversions cam = {x, y, zoom, roll, sx, sy} (sx/sy = shake in px)
valueColor(v, {center, half, alpha}) / rampRGB(u) diverging stripes ramp reference-mean center, symmetric half
pencilStroke(g, pts, o) draw any polyline in pencil: wobble, taper, graphite grain, write-on; returns the tip color, width, progress, seed, wobble, passes, grain
inkShape(g, path, o) any closed shape as a pencil-annotated mark: flat fill + hatching + hand outline fill (accent) or value (ramp), hatch, progress
plotShape(g, path, o) sample any path into a dot-and-line data series n, r, values, accent, progress
stripesFill(g, path, values, o) fill any closed shape with warming stripes ramp options, accent + accentIndex
dotTrail(g, pts, o) tapering dot trail (cursor tail / comet) color, r0, r1
handText(g, text, x, y, o) handwriting with grain and left→right reveal; returns {tip, w} size, color, progress, align, zoom
setType(g, text, x, y, o) printed type, letter-by-letter typesetting size, font, weight, progress, rise, track
dataDot(g, x, y, r, fill, o) data point with landing pop and ripple fresh (s since landing), ripple, accent, alpha
inkLine, stripe, morphMark(g, u, dot, box, fill) series line, one stripe, dot→stripe morph at 0..1
leader, ringPath, wavePath, heartPath, bracketPath, sparklePath, quadPath annotation geometry
dashedCell(g, x0, y0, x1, y1, o) the dashed "next value" cell, drawn on progress, color, dash, gap
drawPencil(g, o) the red–blue pencil (screen space) tip [sx,sy], s, lift 0–1.2, flip 0–1 (blue→red), blur, focus, ang
caption(g, words, t, o) figure-caption subtitle with timed words t0, t1, kicker, x, y, size

Minimal example: a warm orange #D97757 four-point spark with a cursor tail, drawn in this style (full version: ?engine=1 → demo/stills/engine_demo.jpg).

import * as E from './engine.js';
const g = canvas.getContext('2d'), cam = { x: 0, y: 0, zoom: 1, roll: 0 };
E.drawPaper(g, cam); E.applyCam(g, cam);
E.dotTrail(g, Array.from({ length: 9 }, (_, i) => [-300 + i * 26, 170 - i * 14]), { color: '#D97757', r0: 9, r1: 2 });
E.inkShape(g, E.sparklePath(0, 0, 150), { fill: '#D97757' });            // accent bypasses the ramp
E.leader(g, [110, -200], [40, -110], { color: E.P.blue });
E.handText(g, 'the spark, in pencil', 120, -210, { size: 34, color: E.P.blue });
const tip = E.w2s(cam, 150, 0);
E.drawPencil(g, { tip, s: .8, lift: .2, flip: 0 });                        // flip: 1 = red end