38  Play

How did the whole picture change from year to year? play cuts the plot into frames and shows them in sequence. The shortest way to say what play does is that 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, in space

A sequence is not the only way to show the years. The choice is easier to make with the same rows drawn as panels. Everything play does, facet does too, and swapping one operator for the other is the whole difference:

gm_four <- gm_all[gm_all$year %in% c(1952, 1972, 1992, 2007), ]
gm_four$period <- factor(gm_four$year)
data(gm_four) + point + x(gdp, scale = "log") + y(life) + color(continent) |
  facet(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 go back and forth. The sequence is better for seeing movement, because a point that shifts left is a point you watch move rather than one you have to find twice. Neither is a different plot; they are one plot read two ways, which is why they are one grammar with one column bound differently.

On paper only the first frame is printed: in the PDF this page shows 1952 with its strip, while the web edition plays. That is why the faceted version is here beside it: the grid is what a reader holding the printed book 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 is not the column’s type. It is whether the column has an order.

A facet lays its panels out in space, and you read space 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. Set the column's factor levels where the data lives, 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 says so above the plot, and it draws it anyway. Nothing about the sentence is malformed, so the sequence is yours to keep if you meant it. The message names the order it had to choose, and the two answers to it: 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.

This is the rainbow-ramp mistake (Channels) in reverse. 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. Written as | facet(continent), the five groups sit side by side and claim nothing about sequence.

Ordered does not mean numeric. A year is ordered, and so is a date. So is a text 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 way it runs an axis (Data). A categorical column with no declared levels takes the order its values first appear in, which is the trap above.

How many values there are is a separate question, and the next section answers it. Twelve frames and a hundred and seventy frames are both worth animating, as long as each one says something new after the one before it. A long sequence costs the reader time, not meaning, and speed is there for that.

38.3 Too many panels for one page

Whether a column has an order decides whether it can 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 does not.

Nothing limits how many pieces a sequence shows, because it shows them one at a time.

Having an order is not on its own a reason to play a column. A short sequence should move only when its frames hold the same things in different states. Then a reader can follow one of them across the frames. The four frames above are that case: the same countries at four dates, and the cloud climbs. Four frames holding four different sets of things are not that case. Nothing moves between them. The reader waits, and then has to remember what a facet would have shown at once. This is worth saying plainly, because animation is the choice most people want to make.

The limit is the point where 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, cut into one panel per second instead:

thermal_moments <- thermals
thermal_moments$moment <- factor(thermal_moments$second)
data(thermal_moments) + point + x(east) + y(north) + color(glider) |
  facet(moment, wrap = 13)
-200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -200 0 200 -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 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. The shapes are still there: two dots in every cell, 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 second of flight (speed is explained under How fast, 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.

That judgment is worth making before anyone else sees the plot. A facet that was the wrong choice costs a reader one glance. A sequence asks a reader to watch, and keeps them for as long as the loop runs. If it then says nothing, that time was spent for nothing, and a reader can usually tell.

The choice is easiest to see with a large number. 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 above were, and a sequence is the picture that still works.

Holding more does not make a sequence the better picture. What it loses is what a facet does best: two pieces side by side, where a reader compares them instead of remembering them.

38.4 What every frame shares

Every scale, the color palette and every legend are fitted across the whole sequence at once, never per frame. That is the property that makes a sequence worth trusting, and it is worth seeing what its absence would look like. If each frame fitted its own axis, 1952’s poorest countries and 2007’s would both sit at the left edge. A world whose incomes tripled would draw as a world that stood still. The only thing actually moving would be the axis, silently, with nothing on the page to say so.

Because the frames share their scales, the opposite reads correctly: in the plot above, the cloud genuinely moves right and up, and the countries that do not move are countries that did not.

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: the No Exceptions law applied to frames:

gm_past60 <- gm_all[gm_all$life > 60, ]
data(gm_past60) + bar * count + x(continent) + play(year)
Americas Oceania Europe Asia Africa 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 have nothing to report, and it is worth seeing that once, because a still sequence is sometimes an answer rather than a fault:

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 being recomputed in every one of them; it keeps arriving at the same number, because gapminder holds the same 142 countries in every year, so counting rows per continent counts the table and not the year. Only the strip changes, which is how the plot shows it. Filtering first, as the sentence before this one does, is what turns a count of countries into a count of something that happened.

What the frames do share is the cut, when there is one. A bin’s cells describe the axis rather than measuring the rows. One set of bins is cut from every frame’s rows at once, and each frame then counts only its own into them. Faceting explains why, and the rule is the same word for word 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, and it does so against bars of one width, which is the only way that movement is legible.

38.5 A layer with no frames

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 sits 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 hold still while the cloud climbs 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.

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, color 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 stands still in both panels while a dot climbs it, and you can see the glider with the tighter coil climbing faster than the wider one, which is the whole reason the two tables are drawn together.

One detail of the second table is worth knowing, because it decides how many frames the plot has rather than what it means. 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. Had the marker also called its column second, the backdrop’s 170 samples would have set the frame count and the sequence would have run 170 frames instead of 17.

38.6 How fast

A slow loop makes a reader wait for a pattern they have already seen. speed is a multiple of the normal pace, written on the binding beside scale and limits, 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. Long sequences are the reason it exists. Past about thirty frames, gog reports how many seconds the loop will take. The length of a loop is a default you did not choose and cannot see.

38.7 The column that cannot do both jobs

One sentence is worth knowing, because it looks so reasonable. Bind the same column to x and to play, on a mark that reads a function along x, 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 read between. If each frame is one year, there is no domain left in it, and what you get is axes and a legend around an empty panel. The refusal matters because the alternative is silent: a frame strip counting up over an empty panel, with nothing to say why.

The same is true of the same column split across the page rather than the clock, | facet(year), and for the same reason; the engine refuses both, because panels and frames are one split asked twice. Marks that place each row on its own (point, bar, box) have no domain to lose, 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.

38.8 Crossed with a facet

Do all five continents climb at the same pace? Panels split the page and frames split the clock, so the two compose and every panel advances 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

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.

38.9 Frames in the cube

The gapminder cloud has a third measurement, population, and the whole cloud moves through the years. Nothing about play belongs to the plane. 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 0M 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.”

That one is worth reading slowly, because it is the sentence this kernel was designed around. Three of the kernel’s ideas were designed to compose before any of them was drawn: the third position, the sequence, and the selection. The sentence above spends two of them at once. Selection adds the third to this very sentence, so all three stand in one line.

The cube is fitted once across the whole sequence, exactly as a flat plot’s axes are, and it matters more in a sequence than in a still picture. A box that resized itself per frame would swing its own corners around a cloud that had not moved, 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, which is why 1952 opens with the cloud pressed low and only China and India standing well clear of 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 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. Picking a row off it by eye 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 palette is a choice the cube makes for you, and it is the argument the okabe set was built on, reached a second way. A solid in space is shaded per face, so one category is already three tones before any two categories are compared, and a palette whose hues sit close together loses the difference that shading has already reduced. Okabe and Ito’s eight are spaced for readers who cannot separate some hues at all, which turns out to be the same requirement as separating them after a third of the light has been taken out.

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 a gap: a viewpoint that swung while the sequence played would be motion that no column asked for, and a reader would then have to subtract it from the motion that does carry one. play moves the data past a fixed viewpoint.

You can still turn the plot yourself while it runs, and that is a different thing. The design choice above is about a viewpoint that moves on its own, with no column behind it. A reader who drags knows which motion is theirs, and can stop it. This is worth more here than anywhere else in the book. Something is moving inside a solid shape. The only way to see what the near edge hides is to look around it.

Panels and frames still compose in the cube, and the plot below shows it. They are one split asked twice, so crossing them costs nothing: the panels divide the page, the frames divide the clock, and each panel projects the cube its own rectangle has room for.

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 0M Gdp Life Population 100K 10K 1K 80 60 40 1000M 500M 0M Gdp Life Population 100K 10K 1K 80 60 40 1000M 500M 0M Gdp Life Population 100K 10K 1K 80 60 40 1000M 500M 0M Gdp Life Population 100K 10K 1K 80 60 40 1000M 500M 0M 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

Five cubes, twelve frames, and one strip above them all naming the year, 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.

And the sheet moves 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. It is the same tapping in both panels and it is drawn against one shared height scale, so the difference you see is a wavelength and not a scaling. 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 stand in front of the troughs behind them, so a shape that is plain at the front is guesswork at the back. Bright crest bands survive 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 sit 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. The next coordinate space works the same way, and for the same reason. 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, which 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 is the one to watch, from 46 years to 71. Africa’s climbs steadily until the late 1980s and then stops for fifteen years, which is a pause the average reports rather than smooths away.

The pie is where that shared axis says something surprising, and it is worth following, because it is the flat rule bent rather than a rule of its own:

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, that axis is fitted across the sequence like every other, and 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 column that stops well short of 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%. Two sentences, one atom apart, answering two different questions about the same column. Choosing between them is the reader’s job and the grammar’s business is to keep both sayable.

38.11 Taking the sequence with you

Every plot on this page moves because the SVG carries its own timing. A browser reads that timing. Most other places do not. A message to 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 742 KB

Nothing has to be installed first. The engine converts and encodes the file itself, so the same line 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. The pace is kept without being restated: this sequence was written speed = 4, and each frame holds for a fifth of a second in the file as it does on the page.

The file cannot disagree with the plot beside it, and the reason is worth one paragraph, because it is the reason 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 map and each legend fitted across the whole sequence, and what you see at any time is that single drawing with one frame left showing. So there is no second set of decisions that could come out different.

Two sentences are refused. 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, file.path(tempdir(), "still.gif"))
Error:
! gog: this plot does not play, so it has no moments 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")`.

One thing changes when the file is written, and it is worth knowing before you send the file rather than after. A GIF is pixels. The text stops being text, so it no longer sharpens when a reader zooms in, and the colors are fitted to a palette of 256. The SVG remains the plot itself. The GIF is a copy of it made for somewhere that cannot read the original.

38.11.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, once for every platform a package supports, and its license does not allow it to travel inside a package licensed the way this one is. The other route is to require the reader to install it separately. That sounds harmless until you count the readers who cannot. A reader who installs a prebuilt package rather than building from source gets whatever the package carries, so a tool that cannot fit inside it never reaches them at all. The feature would work for some readers and refuse for others, which is a worse answer than not having it.

A GIF asks for nothing. The engine writes it on every machine that can draw the plot, in every one of the four languages. When the file looks soft, 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.12 What it refuses

Having crossed frames with panels, you may try the same column on both. One column names the panels or names the frames, and not both. If it were drawn, one panel’s rows would fall in one frame and every other panel would be empty:

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 that is not a pace is refused at the binding that carries it:

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. A refusal for that mistake is not shown here, and the reason is worth saying. None of the four packages gives any other channel a speed argument at all, so color(continent, speed = 2) fails in R (or in Python, or Julia, or JavaScript) before a specification is ever built. The engine still checks it, because a specification can also be written by hand, but you will never see that message from a sentence: the language you are writing in refuses first, which is the earlier and better place for it.

38.13 What the page adds

A played plot on a web page carries three more buttons. They sit 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, 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.

Nothing displays the 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 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 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 is the camera’s own rule holding rather than a rule about animation: it saves what you are looking at, the same way it saves how far you have zoomed in.

On paper, and in the PDF edition, a played plot is the first frame with its strip. The buttons are the page’s, so a printed sequence has none, and none is missing there.

38.14 Cuts between frames

A sequence cuts. Frames do not glide into one another: a point in 1957 and a point in 1962 are two complete pictures shown in turn, not one point eased between two positions. Easing 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 the word until you notice that a point does not take it, and the point everyone pictures gliding is exactly that: a point. So the difference is a word the vocabulary does not have, not a picture the engine cannot draw.