Skip to content

Commit 31210e6

Browse files
jonathanzongclaude
andcommitted
feat(animation): support pausing playback
An animation could only ever run at a constant rate from start to finish. Add two ways to stop it. `select.on.filter` expressions now gate the clock, so a spec can drive play/pause from its own parameters -- a checkbox binding, say. When a filter is given the spec owns the switch, so the internal `is_playing` signal is not emitted at all. That is not just tidiness: the natural way to write this is a parameter named `is_playing` referenced from the filter, which previously produced two signals of that name in the output. `select.pause` dwells on particular frames. Its entries become a small dataset filtered to the current frame, so the gate reduces to asking whether that dataset is empty and, if not, whether the dwell has elapsed. A frame with no entry reads as playing, so only the named frames stop. The tick reference resets whenever any gate term changes. Without that, elapsed time banks up while paused and the animation jumps forward on resume rather than continuing where it left off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 21b71dd commit 31210e6

11 files changed

Lines changed: 578 additions & 24 deletions

File tree

build/vega-lite-schema.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,24 @@
130130
}
131131
]
132132
},
133+
"AnimationPause": {
134+
"additionalProperties": false,
135+
"properties": {
136+
"duration": {
137+
"description": "How long to pause, in milliseconds.",
138+
"type": "number"
139+
},
140+
"value": {
141+
"$ref": "#/definitions/SelectionInit",
142+
"description": "The value in the time field's domain to pause on."
143+
}
144+
},
145+
"required": [
146+
"value",
147+
"duration"
148+
],
149+
"type": "object"
150+
},
133151
"AnyMark": {
134152
"anyOf": [
135153
{
@@ -19532,6 +19550,13 @@
1953219550
],
1953319551
"description": "A [Vega event stream](https://vega.github.io/vega/docs/event-streams/) (object or selector) that triggers the selection. For interval selections, the event stream must specify a [start and end](https://vega.github.io/vega/docs/event-streams/#between-filters).\n\n__See also:__ [`on` examples](https://vega.github.io/vega-lite/docs/selection.html#on) in the documentation."
1953419552
},
19553+
"pause": {
19554+
"description": "For animated selections (those with `\"on\": \"timer\"`), frames to dwell on before playback continues. Each entry names a value in the time field's domain and how long, in milliseconds, to hold there -- useful for drawing attention to a turning point in the data without slowing the whole animation down.",
19555+
"items": {
19556+
"$ref": "#/definitions/AnimationPause"
19557+
},
19558+
"type": "array"
19559+
},
1953519560
"resolve": {
1953619561
"$ref": "#/definitions/SelectionResolution",
1953719562
"description": "With layered and multi-view displays, a strategy that determines how selections' data queries are resolved when applied in a filter transform, conditional encoding rule, or scale domain.\n\nOne of:\n- `\"global\"` -- only one brush exists for the entire SPLOM. When the user begins to drag, any previous brushes are cleared, and a new one is constructed.\n- `\"union\"` -- each cell contains its own brush, and points are highlighted if they lie within _any_ of these individual brushes.\n- `\"intersect\"` -- each cell contains its own brush, and points are highlighted only if they fall within _all_ of these individual brushes.\n\n__Default value:__ `global`.\n\n__See also:__ [`resolve` examples](https://vega.github.io/vega-lite/docs/selection.html#resolve) in the documentation."
@@ -19600,6 +19625,13 @@
1960019625
],
1960119626
"description": "A [Vega event stream](https://vega.github.io/vega/docs/event-streams/) (object or selector) that triggers the selection. For interval selections, the event stream must specify a [start and end](https://vega.github.io/vega/docs/event-streams/#between-filters).\n\n__See also:__ [`on` examples](https://vega.github.io/vega-lite/docs/selection.html#on) in the documentation."
1960219627
},
19628+
"pause": {
19629+
"description": "For animated selections (those with `\"on\": \"timer\"`), frames to dwell on before playback continues. Each entry names a value in the time field's domain and how long, in milliseconds, to hold there -- useful for drawing attention to a turning point in the data without slowing the whole animation down.",
19630+
"items": {
19631+
"$ref": "#/definitions/AnimationPause"
19632+
},
19633+
"type": "array"
19634+
},
1960319635
"resolve": {
1960419636
"$ref": "#/definitions/SelectionResolution",
1960519637
"description": "With layered and multi-view displays, a strategy that determines how selections' data queries are resolved when applied in a filter transform, conditional encoding rule, or scale domain.\n\nOne of:\n- `\"global\"` -- only one brush exists for the entire SPLOM. When the user begins to drag, any previous brushes are cleared, and a new one is constructed.\n- `\"union\"` -- each cell contains its own brush, and points are highlighted if they lie within _any_ of these individual brushes.\n- `\"intersect\"` -- each cell contains its own brush, and points are highlighted only if they fall within _all_ of these individual brushes.\n\n__Default value:__ `global`.\n\n__See also:__ [`resolve` examples](https://vega.github.io/vega-lite/docs/selection.html#resolve) in the documentation."

examples/compiled/animated_gapminder_pause.svg

Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
{
2+
"$schema": "https://vega.github.io/schema/vega/v6.json",
3+
"description": "Gapminder animated over time, dwelling on 1965 and driven by a play/pause checkbox.",
4+
"background": "white",
5+
"padding": 5,
6+
"width": 300,
7+
"height": 300,
8+
"style": "cell",
9+
"data": [
10+
{"name": "animation_frame_store"},
11+
{
12+
"name": "source_0",
13+
"url": "data/gapminder.json",
14+
"format": {"type": "json"},
15+
"transform": [
16+
{
17+
"type": "filter",
18+
"expr": "isValid(datum[\"fertility\"]) && isFinite(+datum[\"fertility\"]) && isValid(datum[\"life_expect\"]) && isFinite(+datum[\"life_expect\"])"
19+
}
20+
]
21+
},
22+
{
23+
"name": "animation_frame_pause_store",
24+
"values": [{"value": 1965, "duration": 2000}],
25+
"transform": [{"type": "filter", "expr": "datum.value === anim_value"}]
26+
},
27+
{
28+
"name": "source_0_curr",
29+
"source": "source_0",
30+
"transform": [
31+
{
32+
"type": "filter",
33+
"expr": "!length(data(\"animation_frame_store\")) || vlSelectionTest(\"animation_frame_store\", datum)"
34+
}
35+
]
36+
}
37+
],
38+
"signals": [
39+
{
40+
"name": "unit",
41+
"value": {},
42+
"on": [
43+
{"events": "pointermove", "update": "isTuple(group()) ? group() : unit"}
44+
]
45+
},
46+
{
47+
"name": "animation_frame",
48+
"update": "vlSelectionResolve(\"animation_frame_store\", \"union\", true, true)"
49+
},
50+
{
51+
"name": "anim_clock",
52+
"init": "0",
53+
"on": [
54+
{
55+
"events": {"type": "timer", "throttle": 16.666666666666668},
56+
"update": "playing && animation_frame_pause_playing ? (anim_clock + (now() - last_tick_at) > max_range_extent ? 0 : anim_clock + (now() - last_tick_at)) : anim_clock"
57+
}
58+
]
59+
},
60+
{
61+
"name": "last_tick_at",
62+
"init": "now()",
63+
"on": [
64+
{
65+
"events": [
66+
{"signal": "anim_clock"},
67+
{"signal": "playing"},
68+
{"signal": "animation_frame_pause_playing"}
69+
],
70+
"update": "now()"
71+
}
72+
]
73+
},
74+
{
75+
"name": "animation_frame_pause_duration",
76+
"update": "length(data(\"animation_frame_pause_store\")) ? data(\"animation_frame_pause_store\")[0].duration : null"
77+
},
78+
{
79+
"name": "animation_frame_pause_since",
80+
"init": "now()",
81+
"on": [
82+
{
83+
"events": [{"signal": "animation_frame_pause_duration"}],
84+
"update": "now()"
85+
}
86+
]
87+
},
88+
{
89+
"name": "animation_frame_pause_playing",
90+
"init": "true",
91+
"on": [
92+
{
93+
"events": {"type": "timer", "throttle": 16.666666666666668},
94+
"update": "animation_frame_pause_duration ? (now() - animation_frame_pause_since > animation_frame_pause_duration) : true"
95+
}
96+
]
97+
},
98+
{"name": "playing", "value": true, "bind": {"input": "checkbox"}},
99+
{"name": "eased_anim_clock", "update": "anim_clock"},
100+
{"name": "animation_frame_domain", "init": "domain('time')"},
101+
{"name": "min_extent", "init": "extent(animation_frame_domain)[0]"},
102+
{"name": "max_range_extent", "init": "extent(range('time'))[1]"},
103+
{"name": "anim_value", "update": "invert('time', eased_anim_clock)"},
104+
{
105+
"name": "animation_frame_tuple",
106+
"update": "{unit: \"\", fields: animation_frame_tuple_fields, values: [anim_value ? anim_value : min_extent]}"
107+
},
108+
{
109+
"name": "animation_frame_tuple_fields",
110+
"value": [{"type": "E", "field": "year"}]
111+
},
112+
{
113+
"name": "animation_frame_modify",
114+
"update": "modify(\"animation_frame_store\", animation_frame_tuple, true)"
115+
}
116+
],
117+
"marks": [
118+
{
119+
"name": "marks",
120+
"type": "symbol",
121+
"style": ["point"],
122+
"interactive": true,
123+
"from": {"data": "source_0_curr"},
124+
"encode": {
125+
"update": {
126+
"opacity": {"value": 0.7},
127+
"cursor": {"value": "pointer"},
128+
"fill": {"value": "transparent"},
129+
"stroke": {"scale": "color", "field": "cluster"},
130+
"ariaRoleDescription": {"value": "point"},
131+
"description": {
132+
"signal": "\"fertility: \" + (format(datum[\"fertility\"], \"\")) + \"; life_expect: \" + (format(datum[\"life_expect\"], \"\")) + \"; cluster: \" + (isValid(datum[\"cluster\"]) ? isArray(datum[\"cluster\"]) ? join(datum[\"cluster\"], ' ') : datum[\"cluster\"] : \"\"+datum[\"cluster\"]) + \"; year: \" + (isValid(datum[\"year\"]) ? isArray(datum[\"year\"]) ? join(datum[\"year\"], ' ') : datum[\"year\"] : \"\"+datum[\"year\"])"
133+
},
134+
"x": {"scale": "x", "field": "fertility"},
135+
"y": {"scale": "y", "field": "life_expect"}
136+
}
137+
}
138+
}
139+
],
140+
"scales": [
141+
{
142+
"name": "x",
143+
"type": "linear",
144+
"domain": {"data": "source_0", "field": "fertility"},
145+
"range": [0, {"signal": "width"}],
146+
"nice": true,
147+
"zero": true
148+
},
149+
{
150+
"name": "y",
151+
"type": "linear",
152+
"domain": {"data": "source_0", "field": "life_expect"},
153+
"range": [{"signal": "height"}, 0],
154+
"nice": true,
155+
"zero": true
156+
},
157+
{
158+
"name": "color",
159+
"type": "ordinal",
160+
"domain": {"data": "source_0", "field": "cluster", "sort": true},
161+
"range": "category"
162+
},
163+
{
164+
"name": "time",
165+
"type": "band",
166+
"domain": {"data": "source_0", "field": "year", "sort": true},
167+
"range": {"step": 500}
168+
}
169+
],
170+
"axes": [
171+
{
172+
"scale": "x",
173+
"orient": "bottom",
174+
"gridScale": "y",
175+
"grid": true,
176+
"tickCount": {"signal": "ceil(width/40)"},
177+
"domain": false,
178+
"labels": false,
179+
"aria": false,
180+
"maxExtent": 0,
181+
"minExtent": 0,
182+
"ticks": false,
183+
"zindex": 0
184+
},
185+
{
186+
"scale": "y",
187+
"orient": "left",
188+
"gridScale": "x",
189+
"grid": true,
190+
"tickCount": {"signal": "ceil(height/40)"},
191+
"domain": false,
192+
"labels": false,
193+
"aria": false,
194+
"maxExtent": 0,
195+
"minExtent": 0,
196+
"ticks": false,
197+
"zindex": 0
198+
},
199+
{
200+
"scale": "x",
201+
"orient": "bottom",
202+
"grid": false,
203+
"title": "fertility",
204+
"labelFlush": true,
205+
"labelOverlap": true,
206+
"tickCount": {"signal": "ceil(width/40)"},
207+
"zindex": 0
208+
},
209+
{
210+
"scale": "y",
211+
"orient": "left",
212+
"grid": false,
213+
"title": "life_expect",
214+
"labelOverlap": true,
215+
"tickCount": {"signal": "ceil(height/40)"},
216+
"zindex": 0
217+
}
218+
],
219+
"legends": [
220+
{
221+
"stroke": "color",
222+
"symbolType": "circle",
223+
"title": "cluster",
224+
"encode": {
225+
"symbols": {
226+
"update": {
227+
"fill": {"value": "transparent"},
228+
"opacity": {"value": 0.7}
229+
}
230+
}
231+
}
232+
}
233+
]
234+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"$schema": "https://vega.github.io/schema/vega-lite/v6.json",
3+
"description": "Gapminder animated over time, dwelling on 1965 and driven by a play/pause checkbox.",
4+
"data": {
5+
"url": "data/gapminder.json"
6+
},
7+
"mark": "point",
8+
"params": [
9+
{
10+
"name": "animation_frame",
11+
"select": {
12+
"type": "point",
13+
"on": {
14+
"type": "timer",
15+
"filter": "playing"
16+
},
17+
"pause": [
18+
{
19+
"value": 1965,
20+
"duration": 2000
21+
}
22+
]
23+
}
24+
},
25+
{
26+
"name": "playing",
27+
"value": true,
28+
"bind": {
29+
"input": "checkbox"
30+
}
31+
}
32+
],
33+
"transform": [
34+
{
35+
"filter": {
36+
"param": "animation_frame"
37+
}
38+
}
39+
],
40+
"encoding": {
41+
"color": {
42+
"field": "cluster",
43+
"type": "nominal"
44+
},
45+
"x": {
46+
"field": "fertility",
47+
"type": "quantitative"
48+
},
49+
"y": {
50+
"field": "life_expect",
51+
"type": "quantitative"
52+
},
53+
"time": {
54+
"field": "year",
55+
"type": "ordinal"
56+
}
57+
}
58+
}

site/_includes/docs_toc.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,7 @@
353353
- [The Time Encoding Channel]({{site.baseurl}}/docs/animation.html#time-encoding)
354354
- [Keyframes vs. Continuous Time]({{site.baseurl}}/docs/animation.html#timing)
355355
- [Rescaling]({{site.baseurl}}/docs/animation.html#rescale)
356+
- [Pausing]({{site.baseurl}}/docs/animation.html#pause)
356357
- [Limitations]({{site.baseurl}}/docs/animation.html#limitations)
357358
- [Config]({{site.baseurl}}/docs/config.html)
358359
- [Top-level Configuration]({{site.baseurl}}/docs/config.html#top-level-config)

site/docs/animation.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,31 @@ By default the scales are fixed across the whole animation, which keeps position
5757

5858
Scales with a discrete output range (`ordinal`, `bin-ordinal`, `quantile`, `quantize`, and `threshold`) are never rescaled, since moving between their outputs is not a continuous change. Neither is the time scale itself, which defines the extent of the animation.
5959

60+
{:#pause}
61+
62+
## Pausing
63+
64+
There are two ways to stop an animation.
65+
66+
To hand control to the viewer, filter the timer on your own parameter:
67+
68+
```json
69+
"params": [
70+
{"name": "frame", "select": {"type": "point", "on": {"type": "timer", "filter": "playing"}}},
71+
{"name": "playing", "value": true, "bind": {"input": "checkbox"}}
72+
]
73+
```
74+
75+
When a filter is given, the spec owns the switch and Vega-Lite does not emit an `is_playing` signal of its own.
76+
77+
To dwell on particular moments in the data, use `pause` on the timer selection. Each entry names a value in the time field's domain and how long, in milliseconds, to hold there:
78+
79+
```json
80+
"pause": [{"value": 1965, "duration": 2000}]
81+
```
82+
83+
{% include table.html props="pause" source="PointSelectionConfig" %}
84+
6085
{:#limitations}
6186

6287
## Limitations

0 commit comments

Comments
 (0)