| 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 |
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.
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))“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.
| 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))“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))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:
| 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.
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:
| 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 }))“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):
| 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.
“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.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 | 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" }))“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:
| 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))“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 }))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))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:
| 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))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))“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))“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))“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 }))“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.
| 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))“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))“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))“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))“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:
| 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 |
| 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" }))“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.