Plan: the Animated Vega-Lite bird migration example, one spec at a time
This note plans a GoFish replication of the running example from Animated Vega-Lite (Zong, Pollock, Wootton, Satyanarayan, IEEE VIS 2022). The paper walks an ornithologist through five specs, Fig. 1 panels A to E. Each panel adds one feature. The plan mirrors that: one story per panel, and before each story the smallest library addition that story needs.
All five steps (panels A to E, and the library work they needed) are implemented and uncommitted. Each step below says so.
Sources
- Paper: https://arxiv.org/abs/2208.03869, camera-ready at https://vis.csail.mit.edu/pubs/animated-vega-lite.pdf.
- Implementation and example specs: https://github.com/mitvis/vl-animation,
src/specs/animated/birds-{A-line,B-line-hover,C-circle-no-trail,D-circle,E-slider}.json. - Data:
bird_data.csv(gistjonathanzong/5f4fa36e8c2cd04639bc550540264dc6). Columnsday,lon,lat,species. 72 species x 365 days = 26,280 rows, no gaps.dayruns 1 to 365. Basemap is vega-datasetsworld-110m.json, featurecountries. Projection isequalEarthwith a pixelclipExtentthat frames the Americas. - The repo specs differ from the figure in small ways: the trail selection is called
window_frameand is 20 days (the figure saysspread_window, 5 days); every spec has ageoshapebasemap layer the figure crops;n_daycomes from atoNumber(datum.day)calculate step.
What the paper's constructs are, in GoFish terms
| Animated Vega-Lite | GoFish reading | status |
|---|---|---|
projection: {type: "equalEarth"} + longitude/latitude channels | a geo(...) coordinate transform, scatter({ x: "lon", y: "lat" }) under it | new |
geoshape basemap layer | polygon({ points: <field> }) rows under the same transform | half new |
time encoding channel (keyframes, band time scale) | timer({ domain, duration, step }) — a scale from the field's domain onto wall time, read backward | implemented (uncommitted) |
| default timer selection + filter transform | timer() plus the filter flow operator; no elaboration, the two are written out | implemented (uncommitted) |
anim_value | the timer's own read, e.g. day() | implemented (uncommitted) |
timer selection with a window predicate | time.sequence({ by: "day", on: day, cyclic: true }) + time.history({ last: 20 }, [...]) | implemented |
conditional encoding on current_frame | a second layer of the mark, outside the time.history, shown only during its own day | implemented |
select: {type: "point", on: "mouseover", fields: ["species"]} + condition | pointer().datum() read in live() channels | exists |
tooltip | chart(pointer()) text layer (issue #830 step 1) or a live() readout | half new |
bind: {input: "range"} + auto play/pause checkbox | slider({ value, onInput, … }) and button({ label, onClick }), ordinary marks | implemented (uncommitted) |
highlight on click with shift multi-select | click() input rows (#830); the input shipped, multi-select did not | half new |
key tweening | must reconcile with #831; no spelling yet | deferred |
Precedent survey for the new spellings
- Projection: Observable Plot
projection: "equal-earth"(a top-level chart option, like GoFish'scoord), ggplot2coord_sf()/coord_map(), Vega-Liteprojection. All three put the projection where GoFish putscoord, socoord: geo("equalEarth")follows precedent exactly. - Window relative to the playhead: Vega's window transform
frame: [-20, 0]. Same shape, same inclusive-ends reading. - Keyframes: Rhombus pict
switch(ICFP 2026 pearl, see #831), After Effects keyframes, the paper's own "keyframes".keyframesis the plain-language term and reads as temporal without a namespace. The namespace question in #831 is real forstagger/sequence(spatial homonyms) and is not decided here. - Controls: Observable
Inputs.range/Inputs.toggle/Inputs.bind, ipywidgetsPlaylinked to anIntSlider(the exact play-plus-scrub pair the paper auto-generates), Vega-Litebind. GoFish already spells writable inputs as(): Tplus.set(v)(signal,wheel), so a control is "a DOM view over a.set-able input".
Step 0: data and worktree
- Vendor
bird_data.csvintopackages/gofish-graphics/src/data/birds.ts, coordinates rounded to 3 decimals (about 700 KB instead of 1.5 MB). Typedayas a number at load, so thetoNumbercalculate step disappears. - Decode
world-110m.json(countries) once withtopojson-clientin a script (dev dependency only, not a library dependency) tosrc/data/world110m.ts: one row per ring,{ country, ring: [lon, lat][] }. Holes are emitted as ordinary rings. That is a declared shortcut: a lake inside a country paints over the country instead of cutting it out. The right fix is a polygon mark with holes, which nothing else needs today. - One story file,
stories/animated-vega-lite/BirdMigration.stories.tsx, one story per panel, taggedgallerywith the paper's panel captions as descriptions.
Step 1: cartography, then panel A (static lines)
Status: implemented (uncommitted).
Library addition: geo(projection, opts?) as a CoordinateTransform.
geo("equalEarth" | "mercator" | ((lonLat: [number, number]) => [number, number]),
{ lon?: [number, number], lat?: [number, number] })transformprojects[lon, lat]degrees to the plane. Equal Earth and Mercator are short closed forms, so nod3-geodependency. Passing a function accepts any d3 projection (they are callable) if someone wants one.domainis the lon/lat window. Default: the union of the data extents in the scope.lon/latopts override it. That replaces the paper's pixelclipExtent, which is a hack to frame the Americas; a degree window is the honest version.aliases: { x: "lon", y: "lat" }, like polar'stheta/r.- Position scales under a geo transform are identity in degrees: no nicing, no zero inclusion. Measures are types: a degree is a unit, and the transform consumes degrees. This is the one piece of the geo work touching layout code.
polygonin the fluent chart API takespointsas a field name, so a data row can carry a ring. Adaptive resampling under the transform already exists (src/adaptive-resampling.ts) so curved edges look right under Equal Earth.
Panel A:
const basemap = chart(world, {
coord: geo("equalEarth", { lon: [-170, -30], lat: [-60, 75] }),
legend: false, // added in step 3; see there
}).mark(polygon({ points: "ring", fill: "#f7f7f7", stroke: "#aaa" }));
basemap
.layer(
chart(birds)
.flow(
group({ by: "species" }),
scatter({ by: "day", x: "lon", y: "lat" })
)
.mark(line({ stroke: "species", strokeWidth: 1, opacity: 0.5 }))
)
.render(container, { w: 600, h: 600 });The basemap is the base chart and the birds are a layer over it, which is also the order the Vega-Lite spec lists them. The per-species split relies on the #752 rule: the path tier threads along day, the complement (species) splits. To verify on a render: that .layer() places the bird chart inside the geo coordinate scope, and that the 72-way categorical color cycles acceptably.
Step 2: panel B (hover to highlight a path)
Status: implemented (uncommitted).
No library addition for the highlight. The paper's mouseover point selection on species plus two conditional encodings is pointer() read in live():
const hover = pointer();
const hot = (d: Bird) => hover.datum()?.species === d.species;
chart(birds)
.flow(group({ by: "species" }), scatter({ by: "day", x: "lon", y: "lat" }))
.mark(
line({
stroke: "species",
strokeWidth: live((d) => (hot(d) ? 3 : 0.1)),
opacity: live((d) => (hot(d) ? 1 : 0.5)),
})
);Both channels are paint-only, so hover costs no relayout. To verify: a line mark's stamped datum is its group (homogeneity collapse gives species).
Tooltip, two options:
Recommended: the pointer is a dataset, which is step 1 of #830's staging. A one-row chart places a text mark at the pointer and relays out on move:
ts.layer( chart(hover) .flow(scatter({ x: "x", y: "y" })) .mark(text({ text: (d) => d.datum?.species ?? "" })) )Cost: a full relayout of 72 lines per pointer move. Measure it. If it is too slow this becomes the first concrete case for incremental layout (#674).
Fallback: a
textmark in a fixed corner withtext: live(() => hover.datum()?.species ?? ""). Paint-only, but not a tooltip that follows the cursor.
Steps 3 to 5, revised (2026-09-21, after review)
Josh's review of the first draft: no keyframes primitive; a timer that follows what UI component libraries do; the current day as ordinary data filtering driven by a timer scaled from wall-clock time into dataset time; trails as plain filtering, not baked into an animation primitive; controls inside GoFish's own layout, at the low-level node tier rather than as chart() builders, DOM only in rendering. Tweening and multi-select are out. The table above has been updated to the spellings that shipped; the precedent survey's "keyframes" bullet still describes the first draft, which no longer exists.
Superseded for panel D (2026-09-25): the trail is no longer a filter. Once time.sequence and time.history existed for the connected scatterplot, the trail became the same thing it is there, marks kept on screen after their keyframe, and cyclic on the sequence took over the wrap at the new year. Step 4 below describes both, the filter first and then time.history.
A precedent survey (d3-timer, @solid-primitives, VueUse, Motion useTime, Svelte motion, GSAP Timeline, the Web Animations API, Observable's Scrubber, mafs useStopwatch, Manim ValueTracker) produced two findings that changed the plan:
- Every library that exposes a readable clock exposes elapsed milliseconds (Web Animations
currentTime, MotionuseTime, mafstime). The near unanimous transport isplay,pause, aplayingreadable, and a seek. Rate and easing are minority features. - The first draft said Vega's window transform spells a trail the same way. It does not: Vega
frame: [-20, 0]counts rows, the paper's predicate counts values (n_day > anim_value - 20 and n_day <= anim_value). They coincide only because the bird data has one row per species per day. The right precedents are SQLRANGEand polarsis_between(closed="right").
Step 3: timer() as a scale read backward, then panel C
Status: implemented (uncommitted).
timer() is the only animation primitive, and its model is a scale from a data domain onto wall-clock time, used in the inverse direction:
timer({ domain?, duration, step?, loop?, playing?, interval? })
(): T // invert(elapsed): a DOMAIN value, not milliseconds
.set(v) // seek, in domain units
.play() .pause()
.isPlaying(): boolean
.domain // the resolved [lo, hi] or values array
.stepThe equation is t() = scale(domain → [0, duration]).invert(elapsed).
domain: [lo, hi](two numbers) is continuous, andstepquantizes it tolo + k*step, rounding down. Any other array is a band over those values and emitsvalues[i]— Observable's Scrubber shape, and Animated Vega-Lite's default time scale. With no domain the scale is the identity onto[0, duration], so a plain elapsed-milliseconds clock is the degenerate case rather than a separate mode.durationis milliseconds for one pass;loop(default true) wraps elapsed, and without it the clock clamps at the end of the domain and pauses itself.playingdefaults to true and the clock lazy-starts on the first read, with the old rule kept: a read never resurrects a clock that was explicitly paused.- Elapsed is accumulated
performance.now()deltas, not a tick count, so pause, resume and seek are exact.setInterval(interval ?? 16)only samples the clock; a sample that lands on the same domain value writes nothing, so a quantized clock costs one re-render per step instead of one per frame. .set(v)is the same scale forward. It does not change the playing state: a scrub widget decides that for itself.
Naming convention, decided here: verbs mutate, isX reads a boolean. So isPlaying(), and drag().active() / pointer().down() were renamed to isActive() / isDown() everywhere.
The deferred "reverse-scale sugar" of the first draft is gone: the timer IS the reverse scale, so there is nothing left to sugar. The remaining wish is that the domain could come from inference instead of a literal [1, 365]; nothing needs it yet.
Panel C is then the clock plus an ordinary filter:
const day = timer({ domain: [1, 365], step: 1, duration: 10000 });
basemap() // chart(world110m, { coord: geo(...), legend: false })
.layer(
chart(birds)
.flow(
filter((d) => d.day === day()),
scatter({ x: "lon", y: "lat" })
)
.mark(circle({ r: 3, fill: "species" }))
)
.render(container, { w: 600, h: 600 });Two smaller additions came with it:
legend: falseas a chart option (default true), mirroringaxes. It suppresses legend elaboration; the color scale still paints the marks. Without it a 72-species swatch column takes more of the canvas than the map.circle({ opacity }), which the mark simply did not have. It accepts a number, an accessor evaluated per datum at resolve, or alive()value.
Domain inference, measured rather than guessed. filter runs before inference, so the scales see only the rows that survive. Under geo with an explicit lon/lat window that does not matter at all: rendering one row, two rows and all rows puts the shared row at exactly the same pixel. For a non-geo chart it matters completely — filtering to a single day collapses the x and y domains to that day's extent, so the chart rescales every frame. That is the paper's rescale: true; its default is rescale: false, and GoFish has no spelling for it yet. Open question, not redesigned here.
Step 4: panel D, trails
Status: implemented. The first version was a filter, described first. Panel D now uses time.history, described after it.
The first version had no animation primitive. The trail is a value window anchored at the playhead, half-open on the left exactly as the paper writes it, and the spelling is polars' is_between:
between(v, lo, hi, { closed }); // a bare value test
field("day").between(lo, hi, { closed }); // a row predicate for filterclosed is "both" (default), "left", "right" or "none". Bounds may be numbers or accessors (() => number), which is what lets a window follow a clock; a constant anchor makes it "the 20 days before June 1", so the predicate is not tied to a timer at all. The comparison is by value (SQL RANGE), never by row count (Vega's window frame).
between is not a field-expression op. It returns a predicate, and a predicate belongs to none of the three op slots (domain, aggregate, normalize): it decides no groups, folds no rows, scales nothing. A method returning a plain function keeps the pipeline free of a fourth slot every evaluation site would have to ignore.
filter(pred) itself is the flow operator beside derive: it keeps the rows the predicate accepts, and it takes either a hand-written predicate or a field predicate. Like derive, it is JS-only — the predicate is a live callback, so toJSON emits the opaque { type: "derive" } an untagged operator gets. A field predicate could serialize one day; it does not today.
Day-of-year is cyclic, so the window the story ships is on the WRAPPED distance back from the playhead — otherwise the trail collapses to three days at the loop boundary and grows back over the next three weeks. The bare between predicate expresses that directly; field("day").between(lo, hi) stays the spelling for an ordinary, non-cyclic window (see the filter docs). A period option on between was considered and rejected: the modulo is one honest expression, and a cyclic domain is not between's business.
basemap()
.layer(
chart(birds)
.flow(
filter((d) =>
between((day() - d.day + 365) % 365, 0, 20, { closed: "left" })
),
scatter({ x: "lon", y: "lat" })
)
.mark(
circle({
r: 3,
fill: "species",
opacity: (d) => (d.day === day() ? 1 : 0.1),
})
)
)
.render(container, { w: 600, h: 600 });Panel D now: time.history. The connected scatterplot work added time.sequence, one keyframe per value of a field, and time.history({ last }), which keeps marks on screen for last after their keyframe. A trail is exactly that, so panel D spells it the same way. Each day is a keyframe, and each day's mark is two layers: a faint circle under time.history, which is the trail, and a solid circle shown only during its own day. cyclic: true says the day of the year is a cycle, so time.history measures back across the new year and on day 5 the trail reaches into December. The modulo in the filter is gone, and so is the opacity callback that picked out the current day.
basemap()
.layer(
chart(birds)
.flow(
time.sequence({ by: "day", on: day, cyclic: true }),
scatter({ x: "lon", y: "lat" })
)
.mark(
layer([
time.history({ last: 20 }, [
circle({ r: 3, fill: "species", opacity: 0.1 }),
]),
circle({ r: 3, fill: "species" }),
])
)
)
.render(container, { w: 600, h: 600 });The two spellings differ by one day. The filter's window is half-open, 20 days counting the current one. time.history({ last: 20 }) keeps a day while its band overlaps [T - 20, T], which is 21 days counting the current one. Both are fine for this picture.
The filter was kept at first because of speed. Played, each of the 52,560 circles (365 days x 72 species x 2 layers) read the clock on every tick to decide whether it shows. Panels D and E now use time.history, and the decision is made once per keyframe rule per tick and shared by the circles under it, so a tick touches only the circles whose day entered or left the window (#848).
Step 5: panel E, controls at the low-level tier
Status: implemented (uncommitted). The spellings below are the ones that shipped.
The timer is the single source of truth (Vega and the paper, and the rule already in animation.md 2.2: a scrub write wins over the clock until playback explicitly resumes). A slider displays the timer in one direction and writes it in the other. A button toggles playing. Both are ordinary marks, laid out under the map with spreadY/spreadX, and both follow the Solid/React controlled-input shape, value plus onInput, one way each. There is no two-way binding construct.
slider({ value, onInput, domain, step?, w?, wrap?, format? }); // w defaults to 300
button({ label, onClick, w?, h? }); // 24x24 by defaultPanel E, in full:
const day = timer({ domain: [1, 365], step: 1, duration: 10000 });
const map = basemap({ padding: 0 }).layer(
chart(birds)
.flow(
time.sequence({ by: "day", on: day, cyclic: true }),
scatter({ x: "lon", y: "lat" })
)
.mark(
layer([
time.history({ last: 20 }, [
circle({ r: 3, fill: "species", opacity: 0.1 }),
]),
circle({ r: 3, fill: "species" }),
])
)
);
const timeSlider = slider({
value: day,
onInput: (v) => {
day.pause(); // the drag takes ownership (the paper's delegation rule)
day.set(v);
},
domain: day.domain,
step: day.step,
w: 300,
wrap: true, // day-of-year is a cycle, like the time axis above
format: (d) => `day ${d}`,
});
const playButton = button({
label: () => (day.isPlaying() ? "pause" : "play"),
onClick: () => (day.isPlaying() ? day.pause() : day.play()),
});
gofish(container, { w: 600, h: 660, legend: false }, () =>
spreadY({ spacing: 12 }, [
frame({ w: 600, h: 600 }, [map]),
spreadX({ spacing: 8 }, [playButton, timeSlider]),
])
);(The story's real captions are the glyphs the paper uses, U+2759 U+2759 and U+25B6.)
What it needed from the library, and what each turned into:
click()- an input besidepointer()/drag(), rows{ x, y, datum, id, t }accumulating per click, pluscount()andisArmed(). A click is the PAIR press-then-release on one target, which is neither a pointer state nor a drag; the release is accepted by the same test the press passed, so a two-node control (a box plus its caption) reads as one target. Theshiftfield of the #830 sketch is not here: multi-select is out of scope.timertransport from step 3, unchanged.- A builder as a child of a low-level operator.
createNodeOperatoralready resolved aChartBuilderchild; it now resolves aLayerBuildertoo, through the builder's OWNresolve()(which is where a rootcoordis hoisted over every tier). SospreadY([map, controls])works for a layered chart, the mirror of.layer(node). - Handle geometry was a pipeline-tier read of
value(), so the control relaid out with the map on every tick. Superseded: the slider now reads the value at paint (the handle'scxis a live slot, the readout is live text), and a timer's play state is a dependency separate from its value, so a playing clock re-renders nothing. See Reactivity. drag({ hitTest })sees the hit, not just the point, so a control claims exactly the drags that start on the nodes it drew.drag().nodeBox(uid)- the on-screen box (svg px) of a node in the frame the chart last published.publishFramerecords one box per uid in the same walk that builds the hit-test map, folding in any enclosinggrouptransform; primitives with no box of their own (path,text) are left out. This is what makes the slider's pixel to value map absolute (see below), and it is the geometric counterpart of the frame conversions: the interaction layer reads off what layout computed, it never re-derives or re-measures.
Three shape decisions worth recording:
- A control is a MARK, not a node. The pipeline is tree-consuming: a node laid out twice keeps its first placement, so a control has to be rebuilt whenever the spec around it resolves - which is exactly what a mark is. The
drag()/click()input and the write effect are created ONCE, when the widget is made. So the widget is constructed outside the render thunk and its node inside it, and a composition with nochart()at its root needs the THUNK form of the terminal,gofish(container, opts, () => node). - The pixel to domain map is absolute, through the frame. The pointer's position along the handle's travel IS the value:
valueAt((x - (track.x + r)) / (track.w - 2r)), quantized bystep. Track and handle are both drag targets, so a press on the bare track puts the handle under the pointer and the handle then follows it. The track's box comes fromnodeBoxabove, not from the DOM, so layout still owns placement. The travel is inset by the handle radius, which keeps the control's bbox independent of the value and makes the handle's center coincide with the pointer. (The first version mappeddelta.xfrom the press, with the declared cost that clicking the bare track did nothing;nodeBoxremoved the reason for it.) wrapis a cycle length. Withwrap: truethe fraction is not clamped and the quantized value is folded modulo the cycle, which isspanfor a continuous domain (hiandloare one point, as for an angle) andspan + stepfor a quantized one (365 distinct days, so day 365 is followed by day 1 instead of being identified with it). Panel E sets it, since day-of-year is as cyclic for the scrub as it already is for the time axis. Precedent: Qt'swrappingproperty (QAbstractSpinBox,QDial).
The readout beside the track (format, default String) is a text node with textAnchor: "end" at a fixed x. For text, x is the anchor and the box runs leftward from it, so the widget's width does not depend on the label and a value change never jostles its siblings; the reserved slot width is only an estimate of the widest end label, deciding how close a long readout comes to the track.
Declared shortcut, to be replaced by #830 work, not by more widget code:
- The write is a Solid
createEffect(inside the widget, so no spec sees it), the repo's existing precedent. The principled replacement is one declarative write primitive such asbind(t, () => ...), evaluated after event dispatch against the previous frame's conversions. That is the one genuinely new mechanism controls need and it should be designed under #830, not here.
drag().within(ref("track")) from #830 is still the spelling this wants: it would make the handle's x live in day units, with nodeBox as the mechanism underneath it rather than a widget-private arithmetic step.
Rendering stays SVG; nothing here is a DOM widget.
Composition gotcha, found on the render. A chart's padding (30 by default) is drawn OUTSIDE the node's box, so a padded chart paints past the box its siblings are stacked against - in panel E the controls first landed inside the map. padding: 0 on a composed chart is the fix, and the spacing then belongs to the composition. Related: under a low-level operator the geo coord's box follows the proposal it gets rather than its lon/lat window, so panel E wraps the map in frame({ w: 600, h: 600 }) to say how big the map is. Both deserve a follow-up: a composed chart should report the box it actually paints.
Sequencing
| PR | library | story |
|---|---|---|
| 1 | geo(), degree-identity scales, polygon({ points: field }), data | A |
| 2 | none, or chart(pointer()) for the tooltip | B |
| 3 | timer() as a scale read backward, filter, between, legend: false, circle({ opacity }) | C, D |
| 4 | click(), slider/button marks, builder-inside-operator composition | E |
Deferred: bind, .within/.lock, tweening (#831), multi-select (#830), a rescale: false spelling for domains that must ignore a filter.
Risks and open questions
Whether filtering before domain inference shrinks the domains (paper'sAnswered: it does, exactly, for any chart without an explicit domain; underrescale).geowith a lon/lat window it cannot. No spelling forrescale: falseyet.Whether aAnswered: it does, once the operator's child reification knows aboutchart()builder composes inside a low-level operator.LayerBuilderas well asChartBuilder(step 5). What it exposed is that a composed chart's box does not cover what it paints — its padding is drawn outside the box — so a composed chart wantspadding: 0and an explicitframe({ w, h }). That is the real open question now.- Basemap relayout every frame. Josh: ignore performance for now.
- Holes in the basemap drawn as filled rings (declared shortcut).
circle({ r })takes a number only, so panel E has no hover-size highlight (the paper's E enlarges the hovered species). It needsrwidened to the same accessor/live()shapeopacityalready accepts.
Follow-up: fuzzy hover on panel B (deferred to the Meros port)
Hovering panel B needs a pixel-exact hit on a thin path. UI toolkits enlarge the target (44pt touch targets, hitSlop, a fat invisible stroke); visualization libraries pick the nearest item instead (Vega-Lite nearest: true via Voronoi, Observable Plot pointer with maxRadius: 40, Plotly hovermode: "closest", Bokeh line_policy: "nearest"). In GoFish that is a Meros Match projection, pointer().snap(selectAll("paths")) with a radius, dispatched on the underlying space (point distance for circles, polyline distance for connectors, band for ordinal). Decided 2026-09-21: not a one-off option on pointer(); panel B becomes a probe example for that port.
