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.
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
accentcolour 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
globalAlphamust 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:asrset 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#DDD6C91 px, zero line#BDB4A51.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 intools/subs.py. - The engine showcase uses a
#D97757accent 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
- Download the dataset and write the extractor. Print every fact the narration will claim.
- Write
timeline.js(grid plus year → beat map) before any drawing. - Build
engine.js, thenfilm.js, and render gate frames from the real engine (node core/render/still.mjs … --range). sh demo/build.shrebuilds 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.- 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



