38  Play

How did the whole picture change from year to year? play cuts the plot into frames, one picture for each value of a column, and shows them in sequence. It is faceting read in time. A facet splits the rows by a column’s distinct values and lays the pieces out across the page; play splits them exactly the same way and lays them out in time, one after another.

gm_all: first 5 of 1704 rows
country continent year life population gdp
Afghanistan Asia 1952 28.801 8425333 779.4453
Afghanistan Asia 1957 30.332 9240934 820.8530
Afghanistan Asia 1962 31.997 10267083 853.1007
Afghanistan Asia 1967 34.020 11537966 836.1971
Afghanistan Asia 1972 36.088 13079460 739.9811
data(gm_all) + point + x(gdp, scale = "log") + y(life) + color(continent) +
  play(year)
(data(gm_all) + point + x(col.gdp, scale = "log") + y(col.life) + color(col.continent) +
  play(col.year))
data(gm_all) + point + x(:gdp, scale = "log") + y(:life) +
  color(:continent) + play(:year)
plot(data(gm_all), point, x(col.gdp, { scale: "log" }), y(col.life),
  color(col.continent), play(col.year))
1K 10K 100K 40 60 80 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Life Gdp Continent Asia Europe Africa Americas Oceania

“Given all the gapminder years: points, x is gdp on a log scale, y is life, color by continent, played by year.”

Twelve frames, one per year in the data, running from 1952 to 2007. The strip above the panel names the one you are looking at.

38.1 The same sentence, as panels

A sequence is not the only way to show the years. Panels show them too, and the choice between the two is easiest to judge over the same rows. play and facet split the rows in the same way, so here the sentence changes only at its end: | facet(period) becomes + play(period). The gm_four table holds the gapminder rows for 1952, 1972, 1992 and 2007. It stores the year twice: year is a number, and period is the same year as a label. facet names its panels, so it needs the label.

gm_four: first 5 of 568 rows
country continent year life population gdp period
Afghanistan Asia 1952 28.801 8425333 779.4453 1952
Afghanistan Asia 1972 36.088 13079460 739.9811 1972
Afghanistan Asia 1992 41.674 16317921 649.3414 1992
Afghanistan Asia 2007 43.828 31889923 974.5803 2007
Albania Europe 1952 55.230 1282697 1601.0561 1952
data(gm_four) + point + x(gdp, scale = "log") + y(life) + color(continent) |
  facet(period)
(data(gm_four) + point + x(col.gdp, scale = "log") + y(col.life) + color(col.continent) |
  facet(col.period))
data(gm_four) + point + x(:gdp, scale = "log") + y(:life) +
  color(:continent) | facet(:period)
plot(data(gm_four), point, x(col.gdp, { scale: "log" }), y(col.life),
  color(col.continent), across(col.period))
1K 10K 100K 40 60 80 1K 10K 100K 1K 10K 100K 1K 10K 100K 1952 1972 1992 2007 Life Gdp Continent Asia Europe Africa Americas Oceania

“Given four of the gapminder years: points, x is gdp on a log scale, y is life, color by continent, split into panel columns by period.”

data(gm_four) + point + x(gdp, scale = "log") + y(life) + color(continent) +
  play(period)
(data(gm_four) + point + x(col.gdp, scale = "log") + y(col.life) + color(col.continent) +
  play(col.period))
data(gm_four) + point + x(:gdp, scale = "log") + y(:life) +
  color(:continent) + play(:period)
plot(data(gm_four), point, x(col.gdp, { scale: "log" }), y(col.life),
  color(col.continent), play(col.period))
1K 10K 100K 40 60 80 1952 1972 1992 2007 Life Gdp Continent Asia Europe Africa Americas Oceania

Four panels, then four frames, over identical rows. The grid is better for comparing two moments carefully, because both are in front of you and your eye can move between them freely. The sequence is better for seeing movement, because you watch a point move right and up instead of finding the same point twice in two panels. The two are not different plots. They are one plot read two ways, which is why one grammar draws both, with one column bound differently.

In the PDF, only the first frame is drawn: 1952, with its strip. There, the faceted version above is what a reader sees instead of the motion.

38.2 The column’s order

play and facet split the rows the same way, so you might expect the same columns to suit both. The same columns do not suit both. The test for playing a column is not its type. It is whether the column has an order.

A facet lays its panels out across the page, and you can read a page in any order you like. Your eye goes to any panel you choose, then comes back. A sequence lays its pieces out in time, and time arrives in one order only. So a sequence claims something a grid never claims: this frame comes after that one.

Bind a column with no order, and the plot shows movement the data does not have:

gapminder_2007: first 5 of 142 rows
country continent year life population gdp
Afghanistan Asia 2007 43.828 31889923 974.5803
Albania Europe 2007 76.423 3600523 5937.0295
Algeria Africa 2007 72.301 33333216 6223.3675
Angola Africa 2007 42.731 12420476 4797.2313
Argentina Americas 2007 75.320 40301927 12779.3796
data(gapminder_2007) + point + x(gdp, scale = "log") + y(life) +
  color(continent) + play(continent)
(data(gapminder_2007) + point + x(col.gdp, scale = "log") + y(col.life) +
  color(col.continent) + play(col.continent))
data(gapminder_2007) + point + x(:gdp, scale = "log") + y(:life) +
  color(:continent) + play(:continent)
plot(data(gapminder_2007), point, x(col.gdp, { scale: "log" }),
  y(col.life), color(col.continent), play(col.continent))
gog: `play(continent)` has no stated order, so each frame is a snapshot of one `continent` and the frames run in the order the rows arrive: Asia, Europe, Africa, Americas, Oceania. A sequence claims that one frame comes after another. Declare the column's levels in the table (a factor in R, `ordered()` in Python, Julia and JavaScript), or use `facet(continent)`, which claims no order.
1K 10K 40 50 60 70 80 Asia Europe Africa Americas Oceania Life Gdp Continent Asia Europe Africa Americas Oceania

Watch the strip: Asia, Europe, Africa, Americas, Oceania. That order is not alphabetical, and it is not geographic. It is the order the continents first appear in the table, because the rows are sorted by country and Afghanistan comes before Albania. The sequence is showing you how a file was sorted. The points jump between frames, and none of the jumps mean anything.

gog draws the plot anyway, because nothing about the sentence is malformed and you may have meant that order. The message above the plot names the order it had to choose, and two answers: declare the column’s levels, or facet instead. Give the column its levels and the frames follow them. Bind facet(continent) instead and the panels claim no order at all.

Channels describes the opposite mistake. There the data had an order the hues could not carry; here the frames carry an order the data does not have, so a reader looks for a pattern that was never there.

A column does not have to hold numbers to be ordered. A year is ordered, and so is a date. So is a categorical column whose levels you declared: months in calendar order, or a rating from low to high. gog runs the frames in the order the levels declare, the same order it uses on an axis (Data). A categorical column with no declared levels takes the order its values first appear in, which is the mistake the plot above showed.

Having an order is not on its own a reason to play a column. A sequence should move only when its frames hold the same things in different states. Then a reader can follow one of those things across the frames. The four frames above are that case: the same countries at four dates, and their points climb. The five continent frames above are not that case: each holds different countries, so nothing moves between them. The reader waits, and then has to remember what a facet would have shown at once. This needs saying plainly, because animation is the choice most people want to make.

How many values there are is a separate question, and the next section answers it. The gapminder years give twelve frames, and the glider flight in the next section gives 170. Both are worth animating, because in both the same things change from frame to frame. A long sequence takes more of the reader’s time without being harder to understand, and speed is there for that.

38.3 Too many panels for one page

Whether a column has an order decides whether it should be played. How many values it has decides something else: whether a facet of it is still readable. A facet has to fit every piece on one page. A sequence shows them one at a time, so nothing limits how many it holds.

A facet’s limit is the point where its panels stop being readable. They share one page, so every panel added makes every panel smaller. wrap folds a long line into a rectangle, which makes each panel wider and shorter (Faceting). The rectangle is still one page.

Here are the two gliders from the cube, with one panel for each sample of the flight, taken every two seconds. The thermal_moments table is that flight with the time stored twice: second is a number, and moment is the same time as a label:

thermal_moments: first 5 of 340 rows
east north altitude glider second moment
330.0000 0.00000 900.0000 Alpha 0 0
327.5939 39.77710 907.0082 Alpha 2 2
320.4108 78.97417 914.0163 Alpha 4 4
308.5554 117.01961 921.0245 Alpha 6 6
292.2005 153.35865 928.0327 Alpha 8 8
data(thermal_moments) + point + x(east) + y(north) + color(glider) |
  facet(moment, wrap = 13)
(data(thermal_moments) + point + x(col.east) + y(col.north) + color(col.glider) |
  facet(col.moment, wrap = 13))
data(thermal_moments) + point + x(:east) + y(:north) + color(:glider) |
  facet(:moment, wrap = 13)
plot(data(thermal_moments), point, x(col.east), y(col.north),
  color(col.glider), across(col.moment, { wrap: 13 }))
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34 36 38 40 42 44 46 48 50 52 54 56 58 60 62 64 66 68 70 72 74 76 78 80 82 84 86 88 90 92 94 96 98 100 102 104 106 108 110 112 114 116 118 120 122 124 126 128 130 132 134 136 138 140 142 144 146 148 150 152 154 156 158 160 162 164 166 168 170 172 174 176 178 180 182 184 186 188 190 192 194 196 198 200 202 204 206 208 210 212 214 216 218 220 222 224 226 228 230 232 234 236 238 240 242 244 246 248 250 252 254 256 258 260 262 264 266 268 270 272 274 276 278 280 282 284 286 288 290 292 294 296 298 300 302 304 306 308 310 312 314 316 318 320 322 324 326 328 330 332 334 336 338 North East Glider Alpha Bravo

“Given the thermal moments: points, x is east, y is north, color by glider, split into panel columns by moment, wrapped at 13.”

170 panels, already folded into a rectangle. Every panel still holds its two dots, one for each glider. Their positions are not readable. The tick labels have printed over one another. A dot cannot be read against them, and its position is all this plot measures. A reader can see that something changes, and cannot see what.

The same rows advanced in time instead, one frame per sample (speed is explained below):

thermals: first 5 of 340 rows
east north altitude glider second
330.0000 0.00000 900.0000 Alpha 0
327.5939 39.77710 907.0082 Alpha 2
320.4108 78.97417 914.0163 Alpha 4
308.5554 117.01961 921.0245 Alpha 6
292.2005 153.35865 928.0327 Alpha 8
data(thermals) + point + x(east) + y(north) + color(glider) +
  play(second, speed = 3)
(data(thermals) + point + x(col.east) + y(col.north) + color(col.glider) +
  play(col.second, speed = 3))
data(thermals) + point + x(:east) + y(:north) + color(:glider) +
  play(:second, speed = 3)
plot(data(thermals), point, x(col.east), y(col.north), color(col.glider),
  play(col.second, { speed: 3 }))
gog: `play(second)` cuts 170 frames, so the animation loops every 45 seconds. Run it faster with `play(second, speed = 6)`, or bind a coarser column.
-200 0 200 -200 0 200 0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34 36 38 40 42 44 46 48 50 52 54 56 58 60 62 64 66 68 70 72 74 76 78 80 82 84 86 88 90 92 94 96 98 100 102 104 106 108 110 112 114 116 118 120 122 124 126 128 130 132 134 136 138 140 142 144 146 148 150 152 154 156 158 160 162 164 166 168 170 172 174 176 178 180 182 184 186 188 190 192 194 196 198 200 202 204 206 208 210 212 214 216 218 220 222 224 226 228 230 232 234 236 238 240 242 244 246 248 250 252 254 256 258 260 262 264 266 268 270 272 274 276 278 280 282 284 286 288 290 292 294 296 298 300 302 304 306 308 310 312 314 316 318 320 322 324 326 328 330 332 334 336 338 North East Glider Alpha Bravo

“Given the thermals: points, x is east, y is north, color by glider, played by second at speed 3.”

One panel at full size, one pair of axes, and the two gliders circling through all 170 frames.

gog drew both of those sentences. It refuses neither, and it never will. A plot is refused for not being well formed, never for being a poor idea (Design laws). Which of the two says more about gliders is your judgment, not the engine’s. What the engine does instead is name the one cost it can measure. The message above counts the frames and reports how long one loop takes. It offers two answers: speed, or a column with fewer values.

Choose between the facet and the sequence before anyone else sees the plot. A reader needs one glance to see that a facet was the wrong choice. A sequence has to be watched for as long as the loop runs. If it then shows nothing new, the reader has waited for nothing.

The choice is easiest to see when the number of pieces is large. Imagine a year of hourly temperatures for New York, drawn as one curve for each day. As a facet that is 365 panels, and no fold leaves a curve large enough to read. As a sequence it is 365 frames, and a reader watches a year of weather pass. Below about ten pieces the panels usually read better, and Faceting shows ten of them wrapped and readable. Well above that the panels stop being readable, as the 170 glider panels did, and a sequence is the picture that still works.

A sequence can hold more pieces, and that alone does not make it the better picture. It loses the thing a facet does best: two pieces side by side, where a reader compares them instead of remembering them.

38.4 What every frame shares

When a plot is cut into frames, can one frame still be compared with the next? Every scale, the color palette and every legend are fitted across the whole sequence at once, never per frame. That shared fitting is what makes a sequence worth trusting, and the fastest way to see why is to imagine a plot without it. If each frame fitted its own axis, 1952’s poorest countries and 2007’s would both be at the left edge. Even if incomes tripled, the points would not move from frame to frame. The only thing actually moving would be the axis, silently, with nothing on the page to say so. So free, which fits a scale to each panel, is refused on a played plot, as Faceting shows.

Because the frames share their scales, movement on the page is movement in the data. In the gapminder sequence this chapter opened with, the points genuinely move right and up, and a country whose point stays in place had no change in income or life expectancy.

The color palette is shared for a second reason. A reader follows a point by its color, so a palette that changed partway through would take away the one thing they are following. Every continent keeps its color in every frame, including frames where it happens to have no rows.

The statistics are not shared. A transform sees each frame’s rows as if they were the whole table, exactly as it sees a facet panel’s rows. That is the No Exceptions law, applied to frames. The gm_past60 table keeps the rows of gm_all where life expectancy is above 60 years:

gm_past60: first 5 of 877 rows
country continent year life population gdp
Albania Europe 1962 64.82 1728137 2312.889
Albania Europe 1967 66.22 1984060 2760.197
Albania Europe 1972 67.69 2263554 3313.422
Albania Europe 1977 68.93 2509048 3533.004
Albania Europe 1982 70.42 2780097 3630.881
data(gm_past60) + bar * count + x(continent) + play(year)
data(gm_past60) + bar * count + x(col.continent) + play(col.year)
data(gm_past60) + bar * count + x(:continent) + play(:year)
plot(data(gm_past60), layer(bar, count), x(col.continent), play(col.year))
Europe Africa Americas Oceania Asia 0 10 20 30 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Count Continent

“Given the gapminder past 60 table: bars derived by count, x is continent, played by year.”

Each bar is that year’s tally of the countries that had passed sixty years of life expectancy. Asia climbs from four to thirty, Africa opens with none and ends with twelve, and Europe starts at twenty-three and then runs out of countries to add.

A tally can also be the same in every frame. Then bars that stay still are a correct result, not a mistake:

data(gm_all) + bar * count + x(continent) + play(year)
data(gm_all) + bar * count + x(col.continent) + play(col.year)
data(gm_all) + bar * count + x(:continent) + play(:year)
plot(data(gm_all), layer(bar, count), x(col.continent), play(col.year))
Asia Europe Africa Americas Oceania 0 20 40 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Count Continent

Twelve frames run and not a bar moves. The statistic is recomputed in every frame, and every frame gets the same number. The gapminder table holds the same 142 countries in every year, so each frame counts the same countries. Only the strip changes, and bars that stay still under a changing strip report that the count did not change. The plot above this one reads gm_past60, which keeps only the rows where life expectancy is above 60. That turns a count of all countries into a count of the countries above 60.

What the frames do share is the cut, when there is one. A bin’s edges are positions on the axis, and the axis is shared. One set of bins is cut from every frame’s rows at once, and each frame then counts only its own rows into those bins. Faceting explains why, and the rule is the same with “panel” read as “frame”.

data(gm_all) + bar * bin + x(life) + play(year)
data(gm_all) + bar * bin + x(col.life) + play(col.year)
data(gm_all) + bar * bin + x(:life) + play(:year)
plot(data(gm_all), layer(bar, bin), x(col.life), play(col.year))
40 60 80 0 10 20 30 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Count Life

“Given all the gapminder years: bars derived by bin, x is life, played by year.”

The distribution moves right across half a century. Every frame uses the same bins, and that is what makes the movement readable.

A density shares one thing across the frames too: its bandwidth, which sets how smooth the curve is. It is the average of the bandwidths each frame’s rows would get alone. A change in the curve’s shape from frame to frame therefore comes from the data and not from the smoothing.

38.5 A layer with no frames

So far every layer in a plot has been cut into the same frames. A layer does not have to be. play is a channel, so it obeys the ordinary scope rule: written before any mark it reaches every layer, and written after one it binds that layer alone. A layer that never names it is drawn whole in every frame, which is how a fixed reference is drawn behind something that moves.

life_bands: all 3 rows
life band
60 Low
70 Middle
80 High
data(gm_all) + point + x(gdp, scale = "log") + y(life) + play(year) +
  data(life_bands) + rule + y(life) + style(color = "#c0392b")
(data(gm_all) + point + x(col.gdp, scale = "log") + y(col.life) + play(col.year) +
  data(life_bands) + rule + y(col.life) + style(color = "#c0392b"))
data(gm_all) + point + x(:gdp, scale = "log") + y(:life) + play(:year) +
  data(life_bands) + rule + y(:life) + style(color = "#c0392b")
plot(data(gm_all), point, x(col.gdp, { scale: "log" }), y(col.life),
  play(col.year), data(life_bands), rule, y(col.life),
  style({ color: "#c0392b" }))
1K 10K 100K 40 60 80 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Life Gdp

“Given all the gapminder years: points, x is gdp on a log scale, y is life, played by year; then given the life bands: rules, y is life.”

The three lines do not move while the points climb past them. Nothing was added to the grammar to allow that: the reference layer simply never mentioned year, and a layer with no play binding has no frames to be cut into.

nest() is the one space where every layer has to play. There a text names the regions that a bar packs, and each layer is packed from the rows it is given. A play() written after one mark would pack that layer from one frame’s rows and the other layer from every row, so the names would land in the wrong regions. gog refuses that sentence and asks for play() before the marks.

The same scope rule works in the cube and across panels together. thermal_marks holds one row per glider per instant, saying where that glider was at that moment:

thermal_marks: first 5 of 34 rows
east north altitude glider instant
330.0000 0.0000 900.0000 Alpha 0
117.0196 308.5554 970.0817 Alpha 20
-247.0085 218.8305 1040.1634 Alpha 40
-292.2005 -153.3586 1110.2450 Alpha 60
39.7771 -327.5939 1180.3267 Alpha 80

The route is the whole flight, drawn once; the marker is where the glider actually is:

data(thermals) + path + x(east) + y(north) + z(altitude) + color(glider) +
  data(thermal_marks) + point + play(instant, speed = 3) | facet(glider)
(data(thermals) + path + x(col.east) + y(col.north) + z(col.altitude) + color(col.glider) +
  data(thermal_marks) + point + play(col.instant, speed = 3) | facet(col.glider))
data(thermals) + path + x(:east) + y(:north) + z(:altitude) +
  color(:glider) + data(thermal_marks) + point +
  play(:instant, speed = 3) | facet(:glider)
plot(data(thermals), path, x(col.east), y(col.north), z(col.altitude),
  color(col.glider), data(thermal_marks), point,
  play(col.instant, { speed: 3 }), across(col.glider))
200 0 -200 200 0 -200 2500 2000 1500 1000 East North Altitude 200 0 -200 200 0 -200 2500 2000 1500 1000 East North Altitude Alpha Bravo 0 20 40 60 80 100 120 140 160 180 200 220 240 260 280 300 320 Glider Alpha Bravo

“Given the thermals: paths, x is east, y is north, z is altitude, colored by glider; then given the thermal marks: points, played by instant at speed 3, split into panel columns by glider.”

play is bound to the second layer because it is written after point, and a channel written after a mark reaches that mark alone. So the coil does not move in either panel while a dot climbs it. The route shows the shape of each climb, and only the moving dot shows its pace: the glider with the tighter coil climbs faster.

One detail of the second table changes the number of frames, but not what the plot shows. thermal_marks calls its time column instant where thermals calls the same measurement second. The frames are gathered from every table that carries the played column, not only from the layer that bound play, on the same rule that gives the plot one scale rather than one per layer. If the marker had also called its column second, the route’s 170 samples would have set the frame count, and the sequence would have run 170 frames instead of 17.

38.6 How fast

Two sequences in this chapter have already carried speed = 3. A slow loop makes a reader wait for a pattern they have already seen. speed is a multiple of the normal pace, which shows a frame for 0.8 seconds. It is written inside the binding, the way scale and limits are written inside theirs, because it belongs to play and to nothing else:

data(gm_all) + point + x(gdp, scale = "log") + y(life) + color(continent) +
  play(year, speed = 3)
(data(gm_all) + point + x(col.gdp, scale = "log") + y(col.life) + color(col.continent) +
  play(col.year, speed = 3))
data(gm_all) + point + x(:gdp, scale = "log") + y(:life) +
  color(:continent) + play(:year, speed = 3)
plot(data(gm_all), point, x(col.gdp, { scale: "log" }), y(col.life),
  color(col.continent), play(col.year, { speed: 3 }))
1K 10K 100K 40 60 80 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Life Gdp Continent Asia Europe Africa Americas Oceania

These are the same twelve frames the chapter opened with, running three times as fast. speed = 0.5 would run them at half pace. speed exists for long sequences. The length of a loop is a default you did not choose, and the sentence does not show it. So when a sequence runs past thirty frames at the normal pace, gog reports how many seconds one loop takes.

When the played column holds numbers or dates, gog shows each frame for a time in proportion to the gap between its value and the next. Each sequence is timed against its own gaps: a frame whose gap is the middle one, when the gaps are put in order, shows for the normal 0.8 seconds. The gapminder years are all five years apart, so their frames all show for the same time. Here are the four years drawn as panels earlier, played by the number year instead of the category period:

data(gm_four) + point + x(gdp, scale = "log") + y(life) + color(continent) +
  play(year)
(data(gm_four) + point + x(col.gdp, scale = "log") + y(col.life) + color(col.continent) +
  play(col.year))
data(gm_four) + point + x(:gdp, scale = "log") + y(:life) +
  color(:continent) + play(:year)
plot(data(gm_four), point, x(col.gdp, { scale: "log" }), y(col.life),
  color(col.continent), play(col.year))
1K 10K 100K 40 60 80 1952 1972 1992 2007 Life Gdp Continent Asia Europe Africa Americas Oceania

Twenty years separate 1952 from 1972 and 1972 from 1992, so 1952 and 1972 show for 0.8 seconds. Only fifteen separate 1992 from 2007, so 1992 shows for 0.6. The last frame has no next value and shows for 0.8. Categories have an order but no distance between them, so the earlier play(period) shows every frame for the same time. A frame shows for at most four times the normal 0.8 seconds, so one long gap cannot make the sequence seem to stop. It shows for at least a quarter of 0.8 seconds, however short its gap.

38.7 The column that cannot do both jobs

One sentence looks perfectly reasonable and is still refused. Bind one column to both x and play, on a mark that draws a curve along x such as line, and every frame then holds a single position:

gapminder_asia: first 5 of 60 rows
country continent year life population gdp
China Asia 1952 44.00000 556263527 400.4486
China Asia 1957 50.54896 637408000 575.9870
China Asia 1962 44.50136 665770000 487.6740
China Asia 1967 58.38112 754550000 612.7057
China Asia 1972 63.11888 862030000 676.9001
data(gapminder_asia) + line + x(year) + y(life) + color(country) +
  play(year, speed = 3)
(data(gapminder_asia) + line + x(col.year) + y(col.life) + color(col.country) +
  play(col.year, speed = 3))
data(gapminder_asia) + line + x(:year) + y(:life) + color(:country) +
  play(:year, speed = 3)
plot(data(gapminder_asia), line, x(col.year), y(col.life),
  color(col.country), play(col.year, { speed: 3 }))
Error:
! gog: `line` reads a function along `x`, but `year` both cuts the plot into frames and supplies `x` — so every frame holds a single `year`, and a line needs at least two positions to read between. Animate a different column — `play` is what the frames advance through, and `x(year)` is what the line draws along. Or use `point`, which places each row on its own and so needs no domain.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

A line needs at least two positions to connect. If each frame holds one year, that frame has one position and no range to draw across. Drawn anyway, the plot would be axes and a legend around an empty panel, with a frame strip counting up over it and nothing to say why. The refusal says why instead.

The engine refuses the same column split into panels, | facet(year), for the same reason: panels split the rows exactly as frames do. Marks that place each row on its own (point, bar, box) need no range of x within a frame, so they draw in every frame:

data(gapminder_asia) + point + x(year) + y(life) + color(country) + play(year)
data(gapminder_asia) + point + x(col.year) + y(col.life) + color(col.country) + play(col.year)
data(gapminder_asia) + point + x(:year) + y(:life) + color(:country) +
  play(:year)
plot(data(gapminder_asia), point, x(col.year), y(col.life),
  color(col.country), play(col.year))
1960 1980 2000 40 50 60 70 80 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Life Year Country China India Indonesia Japan Korea, Rep.

Each frame draws that year’s five points one above another, at that year on the x axis, so the points move right as the years pass.

38.8 Crossed with a facet

Do all five continents climb at the same pace? facet and play split the rows by different columns, so the two compose. The panels move through the years together:

data(gm_all) + point + x(gdp, scale = "log") + y(life) + play(year) |
  facet(continent)
(data(gm_all) + point + x(col.gdp, scale = "log") + y(col.life) + play(col.year) |
  facet(col.continent))
data(gm_all) + point + x(:gdp, scale = "log") + y(:life) + play(:year) |
  facet(:continent)
plot(data(gm_all), point, x(col.gdp, { scale: "log" }), y(col.life),
  play(col.year), across(col.continent))
1K 10K 100K 40 60 80 1K 10K 100K 1K 10K 100K 1K 10K 100K 1K 10K 100K Asia Europe Africa Americas Oceania 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Life Gdp

“Given all the gapminder years: points, x is gdp on a log scale, y is life, played by year, split into panel columns by continent.”

Five continents, twelve frames, one shared pair of axes. Each panel is its own continent, and the strip above them all names the year they share. They do not climb at the same pace. Asia’s points climb the most, Africa’s stop rising in the late 1980s, and Europe and Oceania, which start highest, climb the least.

38.9 Frames in the cube

The gapminder table has a third measurement, population, and every country moves through the years. play does not depend on a flat plot. A frame is a subset of the rows, and the third position is a way of looking at whatever rows you have, so the two compose, and neither needed anything added for the other:

data(gm_all) + point + x(gdp, scale = "log") + y(life) + z(population) +
  color(continent) + play(year)
(data(gm_all) + point + x(col.gdp, scale = "log") + y(col.life) + z(col.population) +
  color(col.continent) + play(col.year))
data(gm_all) + point + x(:gdp, scale = "log") + y(:life) +
  z(:population) + color(:continent) + play(:year)
plot(data(gm_all), point, x(col.gdp, { scale: "log" }), y(col.life),
  z(col.population), color(col.continent), play(col.year))
100K 10K 1K 80 60 40 1000M 500M 0 Gdp Life Population 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Continent Asia Europe Africa Americas Oceania

“Given all the gapminder years: points, x is gdp on a log scale, y is life, z is population, color by continent, played by year.”

Three of the kernel’s ideas meet in that sentence: the third position, the frames, and the selection. None of them was designed around the others, so they combine with no new rule. The sentence above uses two of them, and Selection adds the third to the same sentence.

The cube is fitted once across the whole sequence, exactly as a flat plot’s axes are. A box that resized itself per frame would move its own corners while the points stayed where they were, and every apparent motion would belong to the guide rather than to the countries. Fitted once, the height axis has room for 2007’s China from the first frame onward. That is why 1952 opens with the points low in the cube and only China and India far above the floor.

A transform inside the cube is the same transform. The floor is cut once from every frame’s rows and each frame then counts only its own into the cells, which is this chapter’s shared-cut rule read with a second axis present:

data(gm_all) + bar * bin(12) + x(life) + y(continent) + color(continent) +
  palette("okabe") + space() + play(year)
(data(gm_all) + bar * bin(12) + x(col.life) + y(col.continent) + color(col.continent) +
  palette("okabe") + space() + play(col.year))
data(gm_all) + bar * bin(12) + x(:life) + y(:continent) +
  color(:continent) + palette("okabe") + space() + play(:year)
plot(data(gm_all), layer(bar, bin(12)), x(col.life), y(col.continent),
  color(col.continent), palette("okabe"), space(), play(col.year))
80 60 40 Asia Europe Africa Americas Oceania 20 10 0 Life Continent Count 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Continent Asia Europe Africa Americas Oceania

“Given all the gapminder years: bars derived by bin into 12, x is life, y is continent, color by continent, with the okabe palette, in the cube, played by year.”

Follow one continent’s row of bars rather than the whole floor. Each pile slides toward longer lives as the sequence runs, and it slides against cells that never move, so what you are watching is the countries changing which cell they fall in. color(continent) names the same column the y axis does, and it is there so that following one row is possible at all. A floor axis in a cube runs diagonally into the picture. Finding a row along that axis means tracing a receding edge while five rows of bars block the view. The legend names the row at once; the axis makes you trace it.

The sentence sets the palette by hand, and the cube is the reason for choosing the okabe set. A solid in space is shaded per face, so one category is already three tones before any two categories are compared. Shading has therefore narrowed the gap between hues before the reader looks, and a palette whose hues are close together narrows it again. Okabe and Ito’s eight are spaced for readers who cannot separate some hues at all, which is the same requirement as separating them once shading has removed a third of the light.

What the frames do not touch is the viewing angle. space(turn = , tilt = ) is a property of the space rather than of the data, so one angle serves the whole sequence and every frame is seen from the same place. That is a design choice, not something missing. A viewing angle that swung while the sequence played would add motion that no column asked for, and a reader would then have to separate it from the motion a column does explain. play moves the data, and the viewing angle stays fixed.

You can still turn the plot yourself while it runs, and that is not the same as a viewing angle that moves on its own. A reader who drags knows which motion is theirs, and can stop it. Turning matters more in this chapter than anywhere else in the book, because something is moving inside a solid shape. The only way to see what the near edge hides is to turn the cube and look from another side.

Panels and frames still compose in the cube. facet and play each split the rows by their own column, so crossing the two needs no new rule. Each panel draws its cube at whatever size its own rectangle allows.

data(gm_all) + point + x(gdp, scale = "log") + y(life) + z(population) +
  color(continent) + play(year) | facet(continent, wrap = 3)
(data(gm_all) + point + x(col.gdp, scale = "log") + y(col.life) + z(col.population) +
  color(col.continent) + play(col.year) | facet(col.continent, wrap = 3))
data(gm_all) + point + x(:gdp, scale = "log") + y(:life) +
  z(:population) + color(:continent) + play(:year) |
  facet(:continent, wrap = 3)
plot(data(gm_all), point, x(col.gdp, { scale: "log" }), y(col.life),
  z(col.population), color(col.continent), play(col.year),
  across(col.continent, { wrap: 3 }))
100K 10K 1K 80 60 40 1000M 500M 0 Gdp Life Population 100K 10K 1K 80 60 40 1000M 500M 0 Gdp Life Population 100K 10K 1K 80 60 40 1000M 500M 0 Gdp Life Population 100K 10K 1K 80 60 40 1000M 500M 0 Gdp Life Population 100K 10K 1K 80 60 40 1000M 500M 0 Gdp Life Population Asia Europe Africa Americas Oceania 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Continent Asia Europe Africa Americas Oceania

“Given all the gapminder years: points, x is gdp on a log scale, y is life, z is population, color by continent, played by year, split into panel columns by continent, wrapped at 3.”

Five cubes and twelve frames, with one strip above them all naming the year. There is only one year strip because a frame is the whole plot, while a panel is one piece of it. That is four of the kernel’s ideas in one sentence: the third position, the panels, the frames, and a scale shared across every one of them so the cubes stay comparable while they run.

Points and bars are not the only marks that can play in a cube. A surface plays too: ripples describes a wave tank tapped in one corner, computed on a grid at twelve instants, in a shallow tank and a deep one.

ripples: first 5 of 5400 rows
across along height tank instant
20 20 3.547 Shallow 1
40 20 5.845 Shallow 1
60 20 4.353 Shallow 1
80 20 -0.178 Shallow 1
100 20 -4.265 Shallow 1

One row is one grid node at one instant in one tank, so instant counts frames from 1 to 12 rather than measuring a time, and height is a signed height in millimeters. Depth sets how fast water carries a wave, so the same tapping makes crests 140 mm apart in the shallow tank and 220 mm apart in the deep one:

data(ripples) + surface + x(across) + y(along) + z(height) +
  color(height) + palette("plasma") + play(instant, speed = 4) | facet(tank)
(data(ripples) + surface + x(col.across) + y(col.along) + z(col.height) +
  color(col.height) + palette("plasma") + play(col.instant, speed = 4) | facet(col.tank))
data(ripples) + surface + x(:across) + y(:along) + z(:height) +
  color(:height) + palette("plasma") + play(:instant, speed = 4) |
  facet(:tank)
plot(data(ripples), surface, x(col.across), y(col.along), z(col.height),
  color(col.height), palette("plasma"), play(col.instant, { speed: 4 }),
  across(col.tank))
300 200 100 300 200 100 5 0 -5 Across Along Height 300 200 100 300 200 100 5 0 -5 Across Along Height Shallow Deep 1 2 3 4 5 6 7 8 9 10 11 12 Height 5.95 0 -5.95

“Given the ripples: a surface, x is across, y is along, z is height, color by height, with the plasma palette, played by instant at speed 4, split into panel columns by tank.”

Watch one arc leave the corner and cross the floor. In the PDF you see the first instant, with the arcs already spread across both tanks and closer together in the shallow one. It is the same tapping in both panels, and the panels share every scale, so the closer spacing in the shallow tank is a shorter wavelength, not a different scale. speed = 4 matters here: at the normal pace twelve frames take nearly ten seconds, which is a slideshow of a wave rather than a wave.

The height is said twice, once as the third position and once as the color, and that is deliberate rather than a redundancy. A sheet seen from one angle hides part of itself. The far corner is foreshortened, and the near crests are in front of the troughs behind them, so a shape that is clear at the front has to be guessed at the back. Bright crest bands stay readable after the projection where the height alone does not, and the wavelength this plot exists to compare is the spacing of those bands. palette("plasma") runs dark blue through magenta and orange to yellow. Channels has the rest, including two diverging ramps whose neutral color would be at the still-water line if the sign of the height were the point.

38.10 Frames in polar

Every picture in the polar chapter held one moment, and a rose can grow across the years too. Polar is the second space this chapter plays a sequence in, and it composes with frames for the same reason the cube does. polar() bends the panel, the frames choose the rows, and neither has to know about the other:

data(gm_all) + bar * mean + x(continent) + y(life) + polar() + play(year)
data(gm_all) + bar * mean + x(col.continent) + y(col.life) + polar() + play(col.year)
data(gm_all) + bar * mean + x(:continent) + y(:life) + polar() +
  play(:year)
plot(data(gm_all), layer(bar, mean), x(col.continent), y(col.life),
  polar(), play(col.year))
Asia Europe Africa Americas Oceania 20 40 60 80 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Life Continent

“Given all the gapminder years: bars derived by mean, x is continent, y is life, in polar, played by year.”

A rose of mean life expectancy, one petal per continent, opening across half a century. The radial axis is fitted over the whole sequence exactly as a flat one is. That is why the ring at 80 is already drawn in 1952 with nothing reaching it: the petals grow inside a circle that does not resize under them. Asia’s petal changes most, from 46 years to 71. Africa’s climbs steadily until the late 1980s and then stops for fifteen years. The pause is in the data, and the mean shows it rather than hiding it.

A pie is where that shared axis does something surprising. No new rule applies here, only the flat rule bent into a circle:

data(gm_all) + bar * sum * stack + y(population) + color(continent) + polar() +
  play(year)
(data(gm_all) + bar * sum * stack + y(col.population) + color(col.continent) + polar() +
  play(col.year))
data(gm_all) + bar * sum * stack + y(:population) + color(:continent) +
  polar() + play(:year)
plot(data(gm_all), layer(bar, sum, stack), y(col.population),
  color(col.continent), polar(), play(col.year))
1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Population Continent Asia Europe Africa Americas Oceania

“Given all the gapminder years: bars derived by sum and stack, y is population, color by continent, in polar, played by year.”

The circle does not close until the last frame. A pie’s turn is its measure axis, and that axis is fitted across the whole sequence like every other. 1952’s world population is a little under two fifths of 2007’s, so 1952 sweeps a little under two fifths of the turn. The identical sentence without polar() draws a stacked bar that stops far below the top of its axis, and nobody finds that surprising. A pie that does not close is that stacked bar bent.

If the question is each year’s shares rather than each year’s total, the sentence can say so, and then every frame closes:

data(gm_all) + bar * sum * stack(share = TRUE) + y(population) + color(continent) +
  polar() + play(year)
(data(gm_all) + bar * sum * stack(share = True) + y(col.population) + color(col.continent) +
  polar() + play(col.year))
data(gm_all) + bar * sum * stack(share = true) + y(:population) +
  color(:continent) + polar() + play(:year)
plot(data(gm_all), layer(bar, sum, stack({ share: true })),
  y(col.population), color(col.continent), polar(), play(col.year))
1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Share Continent Asia Europe Africa Americas Oceania

“Given all the gapminder years: bars derived by sum and stack as shares, y is population, color by continent, in polar, played by year.”

The turn is one whole in every frame now, and what moves is how it divides. Asia widens a little from 58% to 61%, Europe falls from 17% to 9%, and Africa grows by half, from 10% to 15%. The two sentences differ by one argument, share = TRUE, and they answer two different questions about the same column. Choosing between them belongs to the reader; the grammar only has to let you write both.

38.11 Frames on the globe

The cube and polar each played a sequence with nothing added to the grammar. The globe is a third space, and play works on it in the same way. The Globe chapter drew all of cyclones at once, on a dark globe. The table holds every storm from 1980 to 2025 that reached hurricane strength. That picture shows where the storms go. It does not show the time of year when they happen. The table’s month column can show that: it holds the calendar month of each position, in a declared order from January to December. The land comes from world_borders, as it did in the Globe chapter:

cyclones: first 5 of 29669 rows
storm month category lon lat
Peni 1980 Jan 1 or 2 173.5 -12.0
Peni 1980 Jan 1 or 2 174.4 -13.0
Peni 1980 Jan 1 or 2 175.0 -15.0
Peni 1980 Jan 1 or 2 175.8 -17.5
Peni 1980 Jan 1 or 2 178.0 -19.4
world_borders: first 5 of 4150 rows
lon lat country continent piece
66.519 37.363 Afghanistan Asia p001
69.196 37.151 Afghanistan Asia p001
70.807 38.486 Afghanistan Asia p001
71.845 36.738 Afghanistan Asia p001
73.260 37.495 Afghanistan Asia p001
data(world_borders) + zone + x(lon) + y(lat) + group(country) +
  style(color = "#1c2636") +
  data(cyclones) + path + x(lon) + y(lat) + group(storm) + color(category) +
  palette(c("mediumvioletred", "darkorange", "gold")) + style(opacity = 0.6) +
  play(month) + globe(turn = 135, tilt = 10) +
  theme(background = "#050a14", grid = "none")
(data(world_borders) + zone + x(col.lon) + y(col.lat) + group(col.country) +
  style(color = "#1c2636") +
  data(cyclones) + path + x(col.lon) + y(col.lat) + group(col.storm) + color(col.category) +
  palette(["mediumvioletred", "darkorange", "gold"]) + style(opacity = 0.6) +
  play(col.month) + globe(turn = 135, tilt = 10) +
  theme(background = "#050a14", grid = "none"))
data(world_borders) + zone + x(:lon) + y(:lat) + group(:country) +
  style(color = "#1c2636") + data(cyclones) + path + x(:lon) + y(:lat) +
  group(:storm) + color(:category) +
  palette(["mediumvioletred", "darkorange", "gold"]) +
  style(opacity = 0.6) + play(:month) + globe(turn = 135, tilt = 10) +
  theme(background = "#050a14", grid = "none")
plot(data(world_borders), zone, x(col.lon), y(col.lat),
  group(col.country), style({ color: "#1c2636" }), data(cyclones), path,
  x(col.lon), y(col.lat), group(col.storm), color(col.category),
  palette(["mediumvioletred", "darkorange", "gold"]),
  style({ opacity: 0.6 }), play(col.month),
  globe({ turn: 135, tilt: 10 }),
  theme({ background: "#050a14", grid: "none" }))
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec Category 1 or 2 3 or 4 5

“Given the world borders: zones, x is lon, y is lat, grouped by country; then given the cyclones: paths, x is lon, y is lat, grouped by storm, colored by category, with a palette of mediumvioletred, darkorange and gold, played by month, on the globe.”

Twelve frames, one for each calendar month, and each frame holds 46 years of that month. From June to November most storms are in the Northern Hemisphere, and almost half of those are in the western North Pacific. September has the most storms of any month: 477 in the north over the 46 years. From December to April most storms are south of the equator, in the southern Indian Ocean, near northern Australia and in the South Pacific. February is the busiest southern month. No storm appears south of the equator in July or August. May is the quietest month of the year.

Two rules from earlier in this chapter make the sequence work. The frames follow the calendar because month has a declared order, as The column’s order explained. The land has no play of its own, so it is drawn whole in every frame, like the reference lines in A layer with no frames. Only the storms change from one frame to the next.

38.12 Taking the sequence with you

You may want to send one of these sequences to someone. Every sequence on this page moves because the SVG carries its own timing, and a browser reads that timing. Most other places do not. A chat with a friend, a slide in a talk, a post on a social site: each of them shows the first frame and stops.

save_gif() writes the same sequence as a file that those places will play:

wave <- file.path(tempdir(), "wave.gif")
save_gif(data(ripples) + surface + x(across) + y(along) + z(height) +
           color(height) + palette("plasma") + play(instant, speed = 4) |
           facet(tank),
         wave, scale = 2)
cat(basename(wave), round(file.size(wave) / 1024), "KB\n")
wave.gif 739 KB
import os, tempfile

wave = os.path.join(tempfile.gettempdir(), "wave.gif")
save_gif(data(ripples) + surface + x(col.across) + y(col.along) + z(col.height) +
           color(col.height) + palette("plasma") + play(col.instant, speed = 4) |
           facet(col.tank),
         wave, scale=2)
print(os.path.basename(wave), round(os.path.getsize(wave) / 1024), "KB")
wave.gif 739 KB
wave = joinpath(tempdir(), "wave.gif")
save_gif(data(ripples) + surface + x(:across) + y(:along) + z(:height) +
           color(:height) + palette("plasma") + play(:instant, speed = 4) |
           facet(:tank),
         wave, scale = 2)
println(basename(wave), " ", round(Int, filesize(wave) / 1024), " KB")
wave.gif 739 KB
import { tmpdir } from "node:os";
import { join, basename } from "node:path";
import { statSync } from "node:fs";

const wave = join(tmpdir(), "wave.gif");
save_gif(plot(data(ripples), surface, x(col.across), y(col.along), z(col.height),
              color(col.height), palette("plasma"), play(col.instant, { speed: 4 }),
              across(col.tank)),
         wave, { scale: 2 });
console.log(basename(wave), Math.round(statSync(wave).size / 1024), "KB");
wave.gif 739 KB

Nothing has to be installed first. The engine converts and encodes the file itself, so the same call works on every machine that can draw the plot at all.

scale multiplies the canvas. A plot is 800 by 600 pixels unless its theme says otherwise, which is small for a post, so scale = 2 doubles both numbers. You do not restate the pace. This sequence was written with speed = 4, so each frame holds for a fifth of a second in the file, exactly as it does on the page.

The file cannot disagree with the plot beside it, and the reason is the one Design laws gives for having one renderer. The frames are not drawn again one at a time. The plot is drawn once, with every scale, the color palette and each legend fitted across the whole sequence. What you see at any time is that single drawing with only one frame visible, so there is no second set of decisions that could differ.

save_gif() refuses some calls. A plot with no play() has no frames to write, and saying so is more useful than writing a file of one frame:

still <- data(gapminder_2007) + point + x(gdp) + y(life)
save_gif(still, "still.gif")
Error:
! gog: a GIF of a still plot is valid grammar, but this engine does not draw it: this plot does not play, so it has no frames to write. Bind a column with an order to `play` — `play(year)` — or save the still picture it already is.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
still = data(gapminder_2007) + point + x(col.gdp) + y(col.life)
save_gif(still, "still.gif")
gog: a GIF of a still plot is valid grammar, but this engine does not draw it: this plot does not play, so it has no frames to write. Bind a column with an order to `play` — `play(year)` — or save the still picture it already is.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
still = data(gapminder_2007) + point + x(:gdp) + y(:life)
save_gif(still, "still.gif")
gog: a GIF of a still plot is valid grammar, but this engine does not draw it: this plot does not play, so it has no frames to write. Bind a column with an order to `play` — `play(year)` — or save the still picture it already is.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
const still = plot(data(gapminder_2007), point, x(col.gdp), y(col.life));
save_gif(still, "still.gif");
gog: a GIF of a still plot is valid grammar, but this engine does not draw it: this plot does not play, so it has no frames to write. Bind a column with an order to `play` — `play(year)` — or save the still picture it already is.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

And the path has to end in .gif, because the name of a file is a claim about what is inside it:

moving <- data(gm_all) + point + x(gdp) + y(life) + play(year)
save_gif(moving, "wave.png")
Error:
! gog: `save_gif()` writes a GIF, so the path ends in `.gif` — `save_gif(p, "wave.gif")`.
moving = data(gm_all) + point + x(col.gdp) + y(col.life) + play(col.year)
save_gif(moving, "wave.png")
gog: `save_gif()` writes a GIF, so the path ends in `.gif` — `save_gif(p, "wave.gif")`.
moving = data(gm_all) + point + x(:gdp) + y(:life) + play(:year)
save_gif(moving, "wave.png")
gog: `save_gif()` writes a GIF, so the path ends in `.gif` — `save_gif(p, "wave.gif")`.
const moving = plot(data(gm_all), point, x(col.gdp), y(col.life), play(col.year));
save_gif(moving, "wave.png");
gog: `save_gif()` writes a GIF, so the path ends in `.gif` — `save_gif(p, "wave.gif")`.

An arrangement of two plots is refused as well, because each plot keeps its own clock and nothing says which one the file should follow. Save the played plot on its own.

One thing changes when the file is written, and you should know it before you send the file rather than after. A GIF is pixels. The text stops being text, so it becomes blurred when a reader zooms in, and every color in the plot is matched to one of 256. The SVG remains the plot itself. The GIF is a copy of it made for somewhere that cannot read the original.

To save one frame as a picture instead, for a document or a slide, keep only that frame’s rows in the table and use save_png(). It refuses a plot that plays more than one frame, because a PNG holds one picture.

38.12.1 Why a GIF and not a video

A video file would be smaller and sharper than a GIF, and a few places accept video and nothing else. gog writes no video, and the reason is about what a package can carry rather than about what a plot is.

Making a video needs a video encoder. The one nearly everybody uses is more than ten times the size of this whole engine, and a package would need one copy for every platform it supports. Its license also does not allow it to be included in a package licensed the way this one is. The other choice is to require the reader to install the encoder separately. That sounds harmless, until you count the readers who cannot install a separate program. A prebuilt package cannot supply the encoder for them either, because the encoder cannot be included in it. The feature would work for some readers and refuse for others, which is a worse answer than not having it.

A GIF needs nothing else installed. The engine writes it on every machine that can draw the plot, in every one of the four languages. When the file looks blurry, raise scale. When you genuinely need a video, converting a GIF into one is easy, and free converters online do it in one step.

38.13 What it refuses

Two more sentences are refused, and neither has appeared yet. Earlier in this chapter, frames and panels were used together, so you may try one column for both. A column names the panels or the frames, not both. If it were drawn, every panel but one would be empty in every frame:

data(gm_four) + point + x(gdp) + y(life) + play(period) | facet(period)
data(gm_four) + point + x(col.gdp) + y(col.life) + play(col.period) | facet(col.period)
data(gm_four) + point + x(:gdp) + y(:life) + play(:period) |
  facet(:period)
plot(data(gm_four), point, x(col.gdp), y(col.life), play(col.period),
  across(col.period))
Error:
! gog: `period` cannot both name the frames and name the panels — every panel but one would be empty in every frame. Split the page by one column and the time by another, or drop `| facet(period)` and let `play(period)` carry it.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

A speed of zero or less is refused as soon as play() is written:

data(gm_all) + point + x(gdp) + y(life) + play(year, speed = 0)
data(gm_all) + point + x(col.gdp) + y(col.life) + play(col.year, speed = 0)
data(gm_all) + point + x(:gdp) + y(:life) + play(:year, speed = 0)
plot(data(gm_all), point, x(col.gdp), y(col.life),
  play(col.year, { speed: 0 }))
Error:
! gog: `speed = 0` — a speed is a multiple of the normal pace, so it has to be above zero. `speed = 2` is twice as fast, `speed = 0.5` half.

speed belongs to play and to nothing else, because nothing else in the grammar takes time to draw: a color is drawn all at once, so there is no duration for a multiplier to scale. This page cannot show a refusal for speed on another channel. None of the four packages gives any other channel a speed argument at all, so color(continent, speed = 2) fails in R, Python, Julia or JavaScript before a specification is ever built. The engine still checks it, because a specification can also be written by hand. You will never see that message from a sentence, though: the language you are writing in refuses first, which is the earlier and better place for it.

38.14 What the page adds

A played plot on a web page carries three more buttons. They are on the same line as the four every plot has, a little to the right of them, and they control the clock:

Control What it does
the left arrow Goes back one frame, and stops the clock
the pause Stops the clock. It becomes a play arrow, which starts it again
the right arrow Goes forward one frame, and stops the clock

Stepping stops the clock, and it has to. A running clock would move past the frame you just asked for, often in less than a second. So the arrows stop it, and the middle button is how you start it again.

The two ends are connected. Go back from the first frame and you arrive at the last. The sequence already runs that way: it loops, so the last frame is followed by the first, every time. Buttons that stopped at the ends would disagree with the sequence they control.

No button shows which frame you are on, because the strip above the panel already names it. That strip is the sequence’s guide, drawn on every played plot, the way an axis is drawn for x.

None of the three buttons is in the sentence, and that is the rule rather than an omission. Every frame is already in the file: the whole sequence is drawn once, and a clock decides which frame you see at each moment. Stepping moves that clock. It chooses no rows, hides no rows, and changes nothing the sentence said. So it is like turning a cube (Space), which changes how you look at the plot and never what is drawn. Neither one needs a word in the grammar.

Stop the clock and the camera photographs the frame you stopped on. That follows from the camera’s own rule, not from a rule about animation: it saves what you are looking at, the same way it saves how far you have zoomed in.

The PDF shows no buttons, because they belong to the web page rather than to the plot.

38.15 Why points jump between frames

The arrows move from one frame to the next, which raises a question about what happens in between. Nothing happens in between. Frames do not blend into one another: the frame for 1957 and the frame for 1962 are two complete pictures shown in turn. Moving a point smoothly between them needs a key, something that says Afghanistan in one frame is the same country as Afghanistan in the next. The grammar has no such word. group looks like that word, and it is not: a point does not take group. The mark everyone imagines moving smoothly is exactly the mark that cannot carry a key. So what is missing is a word in the vocabulary, not an ability in the engine.

38.16 In short

play splits the rows exactly as a facet does and lays the pieces out in time. One set of scales and one palette serve every frame. A sequence claims that one frame follows another, so playing an unordered column prints a message naming the two answers. A line draws a curve along the column bound to x, so playing that same column leaves one position in every frame, and gog refuses the pair.

38.17 What comes next

Play runs the frames and you watch them. In Selection you use the pointer instead: drag across a group of rows, and the plot dims the ones you did not choose.