42  A third variable, and more

You have more columns than the flat page has positions. Where do the rest go? x and y are already taken. A further variable can go in one of four places. The first is a refining channel: color, size, shape, pattern or opacity. The second is the third position, z. The third is play, which gives the variable one frame per value, and the fourth is facet, which gives it one panel per value. This chapter uses all four. Each channel answers one kind of question, and a refining channel gets the legend that decodes it. When many measurements matter equally, there is a fifth place: x itself. Each value of each measurement becomes a row, and the measurement names become the categories on x.

42.1 Which plot fits which columns?

The recipes in this chapter start from a question. If you start from your table instead, find the row below that describes your columns, and follow its links:

Your columns What to try Recipes
Measurements in one unit three on x, y and z in a cube; four or more as parallel coordinates Three measurements, four or more
Measurements in different units two on x and y, a third on size or color; for four or more, standardize each one first Size, color, four or more
Measurements and categories a category on color or shape, or one panel or frame for each of its values Color, shape, panels and frames
Categories and one measurement stacked bars in polar, or tiles with the measurement on color; a third category into panels Storm seasons, which are alike
Categories only count the rows, with count or in your language first; then as in the row above Storm seasons

A table often has more columns than one plot can show. Choose the columns that answer your question, and leave the others out.

42.2 Which group does each row belong to?

The first scatter in Two variables, GDP per person against life expectancy, draws every country as an identical dot. Nothing in it says which continent a country belongs to. The continent column is categorical. A category is carried by color, and the palette gives each category one hue:

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)
(data(gapminder_2007) + point + x(col.gdp, scale = "log") + y(col.life) +
  color(col.continent))
data(gapminder_2007) + point + x(:gdp, scale = "log") + y(:life) +
  color(:continent)
plot(data(gapminder_2007), point, x(col.gdp, { scale: "log" }),
  y(col.life), color(col.continent))
1K 10K 40 50 60 70 80 Life Gdp Continent Asia Europe Africa Americas Oceania

“Given gapminder 2007: points, x is gdp on a log scale, y is life, color by continent.”

One atom away. For the same split without hues, swap color for shape, and each continent gets its own glyph instead. The group channel cannot do this on a scatter. It splits a mark that joins rows, such as line, and a point joins nothing. See Channels.

42.3 Will it read in gray?

That plot separates the continents by hue alone. Hue works on a screen, but not in grayscale print, in poor light, or for a colorblind reader. When a plot has to work in all three of those conditions, bind color and shape to the same column, so each group is said twice. The iris_flowers table has three species of flower, and here each one gets both its own color and its own glyph:

iris_flowers: first 5 of 150 rows
sepal_length sepal_width petal_length species
5.1 3.5 1.4 setosa
4.9 3.0 1.4 setosa
4.7 3.2 1.3 setosa
4.6 3.1 1.5 setosa
5.0 3.6 1.4 setosa
data(iris_flowers) + point + x(petal_length) + y(sepal_length) +
  color(species) + shape(species) +
  palette("okabe")
(data(iris_flowers) + point + x(col.petal_length) + y(col.sepal_length) +
  color(col.species) + shape(col.species) +
  palette("okabe"))
data(iris_flowers) + point + x(:petal_length) + y(:sepal_length) +
  color(:species) + shape(:species) + palette("okabe")
plot(data(iris_flowers), point, x(col.petal_length), y(col.sepal_length),
  color(col.species), shape(col.species), palette("okabe"))
2 4 6 5 6 7 8 Sepal Length Petal Length Species setosa versicolor virginica

“Given the iris flowers: points, x is petal length, y is sepal length, color by species, shape by species, with the okabe palette.”

Printed in gray, setosa and versicolor become nearly the same shade. Their glyphs, circles for setosa and squares for versicolor, still show which group is which. Saying one column twice is called redundant encoding. palette() chooses which colors the color channel uses: here the Okabe and Ito colorblind-safe set. Owning chapters: Channels, Setting vs mapping.

42.4 How big is each observation?

The last two recipes put a category on a channel. This one puts a measurement there, and it returns to the countries. Population is a third measurement of each country, and both positions are already taken by GDP per person and life expectancy. A magnitude is carried by size. That channel answers the question how much, so it accepts a continuous column and nothing else:

data(gapminder_2007) + point + x(gdp, scale = "log") + y(life) +
  size(population)
(data(gapminder_2007) + point + x(col.gdp, scale = "log") + y(col.life) +
  size(col.population))
data(gapminder_2007) + point + x(:gdp, scale = "log") + y(:life) +
  size(:population)
plot(data(gapminder_2007), point, x(col.gdp, { scale: "log" }),
  y(col.life), size(col.population))
1K 10K 40 50 60 70 80 Life Gdp Population 199.6K 659.4M 1.3B

“Given gapminder 2007: points, x is gdp on a log scale, y is life, size by population.”

The two largest circles are China and India. A legend decodes the radius. Owning chapter: Channels.

42.5 What if four variables matter?

The recipes so far bound color to the continent and size to the population, one column at a time. You may need both at once, to see which continent a country is in and how many people live there. Channels compose: any channel a mark accepts works beside any other channel it accepts. That rule is Orthogonality. GDP per person, life expectancy, population and continent in one sentence:

data(gapminder_2007) + point +
  x(gdp, scale = "log") + y(life) +
  color(continent) + size(population) +
  title("The health and wealth of nations, 2007")
(data(gapminder_2007) + point +
  x(col.gdp, scale = "log") + y(col.life) +
  color(col.continent) + size(col.population) +
  title("The health and wealth of nations, 2007"))
data(gapminder_2007) + point + x(:gdp, scale = "log") + y(:life) +
  color(:continent) + size(:population) +
  title("The health and wealth of nations, 2007")
plot(data(gapminder_2007), point, x(col.gdp, { scale: "log" }),
  y(col.life), color(col.continent), size(col.population),
  title("The health and wealth of nations, 2007"))
1K 10K 40 50 60 70 80 The health and wealth of nations, 2007 Life Gdp Continent Asia Europe Africa Americas Oceania Population 199.6K 659.4M 1.3B

“Given gapminder 2007: points, x is gdp on a log scale, y is life, color by continent, size by population.”

Every channel keeps its own rule and its own legend; none of them changed meaning by being combined. Four variables are usually the most a single plot can carry. Beyond four, use panels instead of more channels.

42.6 What if the third variable is continuous?

Now return to three columns. A continuous third column does not have to go on size. Here population goes on color instead, so every dot keeps the same size and the color carries the number. color accepts either kind of column. A continuous column gets a sequential ramp, from light to dark, instead of a categorical palette. A skewed column reads better on a log ramp. On a linear ramp, China and India would be alone at the dark end, and almost every other country would get the same light shade:

data(gapminder_2007) + point + x(gdp, scale = "log") + y(life) +
  color(population, scale = "log")
(data(gapminder_2007) + point + x(col.gdp, scale = "log") + y(col.life) +
  color(col.population, scale = "log"))
data(gapminder_2007) + point + x(:gdp, scale = "log") + y(:life) +
  color(:population, scale = "log")
plot(data(gapminder_2007), point, x(col.gdp, { scale: "log" }),
  y(col.life), color(col.population, { scale: "log" }))
1K 10K 40 50 60 70 80 Life Gdp Population 1.3B 16.2M 199.6K

“Given gapminder 2007: points, x is gdp on a log scale, y is life, color by population on a log scale.”

The legend becomes a labeled ramp. The channel and its rule did not change; only the kind of column did. Scales owns the log scale.

42.7 What if three measurements matter equally?

The iris flowers from the grayscale recipe above carry three measurements each, and none of them is less important than the others. color or size would make the third measurement a refinement, and the plot would still be about the other two. When three measurements matter equally, the third one is a position, not a refinement. Put one measurement on each of x, y and z, and the plot becomes a cube:

data(iris_flowers) + point +
  x(sepal_length) + y(sepal_width) + z(petal_length) +
  color(species)
(data(iris_flowers) + point +
  x(col.sepal_length) + y(col.sepal_width) + z(col.petal_length) +
  color(col.species))
data(iris_flowers) + point + x(:sepal_length) + y(:sepal_width) +
  z(:petal_length) + color(:species)
plot(data(iris_flowers), point, x(col.sepal_length), y(col.sepal_width),
  z(col.petal_length), color(col.species))
8 7 6 5 4 3 2 6 4 2 Sepal Length Sepal Width Petal Length Species setosa versicolor virginica

“Given the iris flowers: points, x is sepal length, y is sepal width, z is petal length, color by species.”

Setosa is separate from the others, near the bottom of the cube, because its petals are short. Versicolor and virginica are higher, and from this side they overlap.

One atom away. The viewing angle is a property of the space, not of the data. turn says which side of the cube you look at, and tilt says how high your eye is:

data(iris_flowers) + point +
  x(sepal_length) + y(sepal_width) + z(petal_length) +
  color(species) +
  space(turn = -50, tilt = 15)
(data(iris_flowers) + point +
  x(col.sepal_length) + y(col.sepal_width) + z(col.petal_length) +
  color(col.species) +
  space(turn = -50, tilt = 15))
data(iris_flowers) + point + x(:sepal_length) + y(:sepal_width) +
  z(:petal_length) + color(:species) + space(turn = -50, tilt = 15)
plot(data(iris_flowers), point, x(col.sepal_length), y(col.sepal_width),
  z(col.petal_length), color(col.species), space({ turn: -50, tilt: 15 }))
8 7 6 5 4 3 2 6 4 2 Sepal Length Sepal Width Petal Length Species setosa versicolor virginica

“Given the iris flowers: points, x is sepal length, y is sepal width, z is petal length, color by species, turned -50, tilted 15.”

From this side versicolor and virginica overlap less. Space owns the third dimension.

42.8 What if four or more measurements matter equally?

A cube has room for three measurements. The iris flowers have a fourth, petal width, and a table can have many more. A parallel coordinates plot gives each measurement a slot of its own on x, and draws each flower as one line through its values.

For that, the table needs one row for each flower and measurement, with the measurement’s name in one column and its value in another. Making that table is not gog’s job: gog never reshapes a table, as The table has to be tidy explains. You make it before you write the sentence, with the data manipulation tools of the language you are already using: tidyr::pivot_longer() in R, pandas.melt() in Python, DataFrames.stack() in Julia, or a short loop in JavaScript. iris_measurements holds the 150 flowers that way, in 600 rows:

iris_measurements: first 5 of 600 rows
flower species measurement cm standardized
flower 1 setosa sepal length 5.1 -0.898
flower 1 setosa sepal width 3.5 1.016
flower 1 setosa petal length 1.4 -1.336
flower 1 setosa petal width 0.2 -1.311
flower 2 setosa sepal length 4.9 -1.139
data(iris_measurements) + x(measurement) + y(cm) + color(species) +
  line + group(flower) + style(opacity = 0.2) +
  line * mean + style(size = 3)
(data(iris_measurements) + x(col.measurement) + y(col.cm) + color(col.species) +
  line + group(col.flower) + style(opacity = 0.2) +
  line * mean + style(size = 3))
data(iris_measurements) + x(:measurement) + y(:cm) + color(:species) +
  line + group(:flower) + style(opacity = 0.2) + line * mean +
  style(size = 3)
plot(data(iris_measurements), x(col.measurement), y(col.cm),
  color(col.species), line, group(col.flower), style({ opacity: 0.2 }),
  layer(line, mean), style({ size: 3 }))
sepal length sepal width petal length petal width 0 2 4 6 8 Cm Measurement Species setosa versicolor virginica

“Given the iris measurements: x is measurement, y is cm, color by species, lines grouped by flower, with opacity 0.2, and also lines derived by mean, with size 3.”

Each measurement is one slot on x, in the order it first appears in the table. The sentence has two line layers. In the first, group(flower) splits the 600 rows into 150 faint lines, and each line joins one flower’s four values. group(flower) is written after the first line, so it splits that layer alone. The second layer is line * mean. color(species) is written before both marks, so it splits both layers, and in each slot mean averages the flowers of each species. Each species gets one thick line through its four means, and the faint lines show how far each flower is from its species’ line. Each country and its continent’s mean or median uses the same two layers.

The three thick lines have three different shapes. Setosa’s line is low in both petal slots, far below the other two species. Virginica has the longest and widest petals, and versicolor’s petals are between those of setosa and virginica.

One atom away. Add polar(), and the slots are spread around a circle. Each flower’s line closes into a star. This is the radar chart from Line, drawn once for each flower instead of once through the continents’ means:

data(iris_measurements) + x(measurement) + y(cm) + color(species) +
  line + group(flower) + style(opacity = 0.2) +
  line * mean + style(size = 3) + polar()
(data(iris_measurements) + x(col.measurement) + y(col.cm) + color(col.species) +
  line + group(col.flower) + style(opacity = 0.2) +
  line * mean + style(size = 3) + polar())
data(iris_measurements) + x(:measurement) + y(:cm) + color(:species) +
  line + group(:flower) + style(opacity = 0.2) + line * mean +
  style(size = 3) + polar()
plot(data(iris_measurements), x(col.measurement), y(col.cm),
  color(col.species), line, group(col.flower), style({ opacity: 0.2 }),
  layer(line, mean), style({ size: 3 }), polar())
sepal length sepal width petal length petal width 0 2 4 6 8 Cm Measurement Species setosa versicolor virginica

“Given the iris measurements: x is measurement, y is cm, color by species, lines grouped by flower, with opacity 0.2, and also lines derived by mean, with size 3, in polar.”

The ring marked 0 is a small ring at the center, so each arm grows with the measurement in centimeters. Sepal length has the largest values, so every star is longest toward sepal length. The three thick stars, one for each species, have three shapes. Setosa’s star has very short petal arms. Virginica’s star is the largest toward every slot except sepal width.

All four measurements are in centimeters, so they share the y axis as they are. Every slot shares that one axis. So when the units differ, such as dollars and years, the measurement with the largest numbers sets the range of the axis, and the lines of the others look almost flat. In that case, standardize each measurement before you plot: subtract its mean and divide by its standard deviation. Every measurement then has a mean of 0 and a standard deviation of 1. Like the reshaping, this is a step for the data manipulation tools of your language, done before you write the sentence. The iris measurements need no such step, but standardizing them shows something new: 0 becomes the average in every slot. The standardized column holds the result:

data(iris_measurements) + x(measurement) + y(standardized) + color(species) +
  line + group(flower) + style(opacity = 0.2) +
  line * mean + style(size = 3)
(data(iris_measurements) + x(col.measurement) + y(col.standardized) + color(col.species) +
  line + group(col.flower) + style(opacity = 0.2) +
  line * mean + style(size = 3))
data(iris_measurements) + x(:measurement) + y(:standardized) +
  color(:species) + line + group(:flower) + style(opacity = 0.2) +
  line * mean + style(size = 3)
plot(data(iris_measurements), x(col.measurement), y(col.standardized),
  color(col.species), line, group(col.flower), style({ opacity: 0.2 }),
  layer(line, mean), style({ size: 3 }))
sepal length sepal width petal length petal width -2 0 2 Standardized Measurement Species setosa versicolor virginica

The axis is no longer in centimeters. Its unit is the standard deviation, and 0 in each slot is the mean of that measurement over all 150 flowers. A line above 0 marks a flower larger than average on that measurement. Setosa’s thick line is the only one above 0 in the sepal width slot, and it is the lowest in both petal slots.

42.9 What if the rows form a route?

In the iris cube two sections above, each flower is its own dot, and the order of the rows means nothing. Sometimes the order is what matters. Here are the two gliders from Path again. Each row records where one glider was at one moment, and that glider’s next row is two seconds later. So the rows have to be joined in the order they were recorded. The mark that joins rows in table order is path:

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) + path + x(east) + y(north) + z(altitude) +
  color(glider) +
  title("Two gliders, one thermal")
(data(thermals) + path + x(col.east) + y(col.north) + z(col.altitude) +
  color(col.glider) +
  title("Two gliders, one thermal"))
data(thermals) + path + x(:east) + y(:north) + z(:altitude) +
  color(:glider) + title("Two gliders, one thermal")
plot(data(thermals), path, x(col.east), y(col.north), z(col.altitude),
  color(col.glider), title("Two gliders, one thermal"))
200 0 -200 200 0 -200 2500 2000 1500 1000 East North Altitude Two gliders, one thermal Glider Alpha Bravo

“Given the thermals: paths, x is east, y is north, z is altitude, color by glider.”

Each route rises because each glider gains altitude. Both gliders start at 900 meters and were recorded for the same 338 seconds. Bravo’s first point is drawn higher than Alpha’s, but only because it is at the back of the cube. In this view, the back is drawn higher on the page. By the end, Bravo has reached about 2,500 meters and Alpha about 2,100, so Bravo climbs faster.

Nothing in the sentence draws a spiral. The sentence asks path to connect the rows in order. Each glider moves in a circle while it rises, so the connected rows form a spiral. Look at the crossings as well. At each one the route nearer to the eye is drawn over the route behind it, and which route is nearer changes as the two gliders climb. Neither route is drawn entirely in front of the other: the pieces of both routes are sorted by depth together, as Path explains.

Turn the view, and the same two routes answer a different question. No binding changes:

data(thermals) + path + x(east) + y(north) + z(altitude) +
  color(glider) +
  space(turn = 20, tilt = 80) +
  title("The same two routes, seen from overhead")
(data(thermals) + path + x(col.east) + y(col.north) + z(col.altitude) +
  color(col.glider) +
  space(turn = 20, tilt = 80) +
  title("The same two routes, seen from overhead"))
data(thermals) + path + x(:east) + y(:north) + z(:altitude) +
  color(:glider) + space(turn = 20, tilt = 80) +
  title("The same two routes, seen from overhead")
plot(data(thermals), path, x(col.east), y(col.north), z(col.altitude),
  color(col.glider), space({ turn: 20, tilt: 80 }),
  title("The same two routes, seen from overhead"))
200 0 -200 200 0 -200 2500 2000 1500 1000 East North Altitude The same two routes, seen from overhead Glider Alpha Bravo

“Given the thermals: paths, x is east, y is north, z is altitude, color by glider, turned 20, tilted 80.”

Seen from nearly overhead the climb is no longer visible, and you are looking down at two circles, one inside the other. Bravo’s circle is half as wide as Alpha’s. Together, the two views show that Bravo climbs faster and turns in a tighter circle.

These two plots raise one more question. line, step, area and ribbon also join their rows, so why is path the only one of the five that takes z? The answer is the difference that separates path from line on the flat page: the order in which each one joins its rows. A path joins rows in the table’s order. That order does not depend on any axis, so a third axis does not change it. A line sorts its rows by x. In the cube, x is one of three equal positions. At some angles it points straight into the page, and then you cannot see the order the line was sorted in. That is why a rising route is drawn with path, and why line refuses z rather than draw a stroke you cannot read:

data(thermals) + line + x(east) + y(north) + z(altitude) + color(glider)
data(thermals) + line + x(col.east) + y(col.north) + z(col.altitude) + color(col.glider)
data(thermals) + line + x(:east) + y(:north) + z(:altitude) +
  color(:glider)
plot(data(thermals), line, x(col.east), y(col.north), z(col.altitude),
  color(col.glider))
Error:
! gog: `line` reads a *domain* left to right — it sorts by `x` and draws one value for each — and a cube has no left to right: `x` is one of three equal positions, and at some viewing angles it runs into the page and becomes depth. A `line` in space would be sorted by an axis the reader cannot see, so this is refused rather than drawn. For a route through three dimensions use `path`, which is `line` with that sort removed: `path + x(<a>) + y(<b>) + z(altitude)`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

42.10 What if the structure needs three dimensions?

A route is one kind of data that needs three positions, and a physical structure is another. quakes_fiji is the thousand earthquakes from Map, each with a place on the globe and a depth below it. Those three columns, east, north and elevation, show together what no pair of them shows alone:

quakes_fiji: first 5 of 1000 rows
east north elevation magnitude slab
181.62 -20.42 -562 4.8 540-630 km
181.03 -20.62 -650 4.2 630-720 km
184.10 -26.00 -42 5.4 0-90 km
181.66 -17.97 -626 4.1 540-630 km
181.96 -20.42 -649 4.0 630-720 km
data(quakes_fiji) + point + x(east) + y(north) + z(elevation) +
  color(magnitude) + palette("viridis") + space(turn = 120, tilt = 20) +
  title("A thousand earthquakes, and the sheet they lie on")
(data(quakes_fiji) + point + x(col.east) + y(col.north) + z(col.elevation) +
  color(col.magnitude) + palette("viridis") + space(turn = 120, tilt = 20) +
  title("A thousand earthquakes, and the sheet they lie on"))
data(quakes_fiji) + point + x(:east) + y(:north) + z(:elevation) +
  color(:magnitude) + palette("viridis") + space(turn = 120, tilt = 20) +
  title("A thousand earthquakes, and the sheet they lie on")
plot(data(quakes_fiji), point, x(col.east), y(col.north),
  z(col.elevation), color(col.magnitude), palette("viridis"),
  space({ turn: 120, tilt: 20 }),
  title("A thousand earthquakes, and the sheet they lie on"))
185 180 175 170 165 -10 -20 -30 -200 -400 -600 East North Elevation A thousand earthquakes, and the sheet they lie on Magnitude 6.4 5.2 4

“Given quakes Fiji: points, x is east, y is north, z is elevation, color by magnitude.”

Color carries a fourth column here, the magnitude of each quake. The vertical axis is elevation, the height above sea level, so a quake 680 km down has the value −680. That is the deepest quake in the table.

The quakes are not scattered through the cube. They trace a sheet of ocean floor sinking into the earth. At this angle, east is on the left. The sheet begins near the surface at the eastern edge and dips west. About a third of the way from that edge, it is more than 600 km down. The shallower quakes farther west do not lie on this sheet. They form a separate group. That sheet lies in three dimensions, so drawing it takes three positions. Any two of them show it flattened onto the page.

Now cut the quakes into slices of depth and show the slices one after another. play(slab) steps through slab, a column that holds the depth in 90 km bands:

data(quakes_fiji) + point + x(east) + y(north) + z(elevation) +
  space(turn = 120, tilt = 20) + play(slab)
(data(quakes_fiji) + point + x(col.east) + y(col.north) + z(col.elevation) +
  space(turn = 120, tilt = 20) + play(col.slab))
data(quakes_fiji) + point + x(:east) + y(:north) + z(:elevation) +
  space(turn = 120, tilt = 20) + play(:slab)
plot(data(quakes_fiji), point, x(col.east), y(col.north),
  z(col.elevation), space({ turn: 120, tilt: 20 }), play(col.slab))
185 180 175 170 165 -10 -20 -30 -200 -400 -600 East North Elevation 0-90 km 90-180 km 180-270 km 270-360 km 360-450 km 450-540 km 540-630 km 630-720 km

“Given quakes Fiji: points, x is east, y is north, z is elevation, played by slab.”

In the web edition the frames play one after another. As they run, the points move down, and their eastern end moves west. That movement is the slope of the sheet, read one slice at a time. The frames are not time here. play advances through the levels of an ordered column, and whether that column measures years or kilometers is a fact about the column, not about play. The same atom that steps through years steps through depths here.

In the PDF, only the first frame is drawn, the slice from 0 to 90 km, and the panels below replace the motion. They lay the same slices out across the page instead of showing them one after another in time:

data(quakes_fiji) + point + x(east) + y(north) + z(elevation) +
  space(turn = 120, tilt = 20) | facet(slab, wrap = 4)
(data(quakes_fiji) + point + x(col.east) + y(col.north) + z(col.elevation) +
  space(turn = 120, tilt = 20) | facet(col.slab, wrap = 4))
data(quakes_fiji) + point + x(:east) + y(:north) + z(:elevation) +
  space(turn = 120, tilt = 20) | facet(:slab, wrap = 4)
plot(data(quakes_fiji), point, x(col.east), y(col.north),
  z(col.elevation), space({ turn: 120, tilt: 20 }),
  across(col.slab, { wrap: 4 }))
185 175 165 -10 -20 -200 -400 -600 East North Elevation 185 175 165 -10 -20 -200 -400 -600 East North Elevation 185 175 165 -10 -20 -200 -400 -600 East North Elevation 185 175 165 -10 -20 -200 -400 -600 East North Elevation 185 175 165 -10 -20 -200 -400 -600 East North Elevation 185 175 165 -10 -20 -200 -400 -600 East North Elevation 185 175 165 -10 -20 -200 -400 -600 East North Elevation 185 175 165 -10 -20 -200 -400 -600 East North Elevation 0-90 km 90-180 km 180-270 km 270-360 km 360-450 km 450-540 km 540-630 km 630-720 km

“Given quakes Fiji: points, x is east, y is north, z is elevation, split into panel columns by slab, wrapped at 4.”

The eight panels share one set of scales. You compare two depths by looking from one panel to the next, not by remembering the frame that just played. Whether you choose panels or frames depends on your readers. Panels let them compare any two slices, and frames let them see the movement. Play owns that choice.

42.11 What if every column is categorical?

Every recipe above had at least one measurement. Some tables have none, only categories. The storms on the globe in Play can be described this way. Each storm begins in one month and one hemisphere, and has one category on the Saffir-Simpson scale of strength, from 1, the weakest, to 5. Nothing can go on a continuous axis until you count the storms. The globe’s table has a row for every twelve hours of each storm, so gog’s count would count those positions, not storms. Making one row per storm and counting the storms is a step for the data manipulation tools of your language, like the reshaping for parallel coordinates. storm_seasons holds the counts for the 2,174 storms of hurricane strength from 1980 to 2025. Each storm is counted once: in the month and hemisphere where its track begins, and in the category of its strongest wind. category holds the digits 1 to 5 as names, so gog_table() needs its text argument for this table (Labels that look like numbers shows it in all four languages):

storm_seasons: first 5 of 98 rows
month category hemisphere storms
Jan 1 Northern 5
Jan 1 Southern 51
Jan 2 Northern 2
Jan 2 Southern 28
Jan 3 Northern 1

Three categorical columns need three places. The column with the most different values goes on an axis, and the one with the fewest goes into panels. month has twelve values, category five and hemisphere two. The months repeat every year, so polar() puts them around a circle, with December next to January. stack puts the five categories of each month into one wedge, with category 1 at the center. This is a stacked rose (Polar):

data(storm_seasons) + bar * stack + x(month) + y(storms) + color(category) +
  palette(c("gold", "yellowgreen", "lightseagreen", "royalblue", "indigo")) +
  polar() | facet(hemisphere)
(data(storm_seasons) + bar * stack + x(col.month) + y(col.storms) + color(col.category) +
  palette(["gold", "yellowgreen", "lightseagreen", "royalblue", "indigo"]) +
  polar() | facet(col.hemisphere))
data(storm_seasons) + bar * stack + x(:month) + y(:storms) +
  color(:category) +
  palette(["gold", "yellowgreen", "lightseagreen", "royalblue", "indigo"]) +
  polar() | facet(:hemisphere)
plot(data(storm_seasons), layer(bar, stack), x(col.month), y(col.storms),
  color(col.category),
  palette(["gold", "yellowgreen", "lightseagreen", "royalblue",
  "indigo"]), polar(), across(col.hemisphere))
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec 100 200 300 Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec 100 200 300 Northern Southern Storms Month Category 1 2 3 4 5

“Given the storm seasons: bars derived by stack, x is month, y is storms, color by category, with a palette of gold, yellowgreen, lightseagreen, royalblue and indigo, in polar, split into panel columns by hemisphere.”

The two seasons fall in opposite halves of the year. In the Northern Hemisphere most storms begin from June to November, and September has the most, 378. In the Southern Hemisphere most begin from December to April, February has the most, 142, and no storm began in July or August. Play counted 477 storms for September in the north, because there a storm is counted in every month it lasts. The north has more than twice as many storms, 1,560 against 614. The colors run from light for category 1 to dark for category 5, and the strongest storms are the dark outer end of each wedge. Over the whole year, category 4 storms outnumber category 2 and category 3 storms in both hemispheres.

You can also put the category on an axis and show each count as the shade of a tile. gog puts the first category at the top of a y axis. order(category, desc = TRUE) runs the axis the other way, so category 1 is at the bottom, as in the population pyramid:

data(storm_seasons) + zone + x(month) + y(category) + color(storms) +
  order(category, desc = TRUE) | facet(hemisphere)
(data(storm_seasons) + zone + x(col.month) + y(col.category) + color(col.storms) +
  order(col.category, desc = True) | facet(col.hemisphere))
data(storm_seasons) + zone + x(:month) + y(:category) + color(:storms) +
  order(:category, desc = true) | facet(:hemisphere)
plot(data(storm_seasons), zone, x(col.month), y(col.category),
  color(col.storms), order(col.category, { desc: true }),
  across(col.hemisphere))
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec 1 2 3 4 5 Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec Northern Southern Category Month Storms 116 58.5 1

“Given the storm seasons: zones, x is month, y is category, color by storms, ordered by category, largest first, split into panel columns by hemisphere.”

Each tile is one count, and an empty cell is a month and category with no storm. The three darkest tiles are all in the north: category 1 in August and September, 116 and 111, and category 4 in September, 103. A shade is harder to compare than a length. So the rose shows the shape of each season more clearly, and the tiles give each count its own place, by month and category.

Two other charts are made for tables of categories. A mosaic gives each combination an area as large as its count. An alluvial diagram makes each column a stage, and carries each count from one stage to the next in a band.

42.12 Which categories are alike?

The storm tiles had an order on both axes, months and categories. The nutrients table has none. It gives the amount of five nutrients in each of eight foods. The question is which foods resemble each other, and which nutrients are found in the same foods. As with the storms, the amount goes on color in a tile plot. The slot order on each axis is the order the rows arrived in, and that order carries no meaning. So here the amount also sets the order of both axes. Build a cluster tree on each axis, then compose the three plots: the tree above, the tiles below it, and the second tree on the right. theme(legend = "bottom") puts the tile plot’s legend under the tiles, so the tree on the right is next to them. Each shared axis takes its leaf order from the tree along that side. Tiles of similar color are then next to each other.

nutrients: first 5 of 40 rows
food nutrient amount
salmon protein 25.4
salmon fat 12.4
salmon carbs 0.0
salmon fiber 0.0
salmon iron 0.5
(data(nutrients) + path * cluster(amount, over = nutrient) + x(food) +
   theme(height = 150)) /
  ((data(nutrients) + zone + x(food) + y(nutrient) + color(amount) +
      theme(legend = "bottom")) |
     (data(nutrients) + path * cluster(amount, over = food) + y(nutrient) +
        theme(width = 150)))
((data(nutrients) + path * cluster(col.amount, over=col.nutrient) + x(col.food) +
   theme(height = 150)) /
  ((data(nutrients) + zone + x(col.food) + y(col.nutrient) + color(col.amount) +
      theme(legend = "bottom")) |
     (data(nutrients) + path * cluster(col.amount, over=col.food) + y(col.nutrient) +
        theme(width = 150))))
(data(nutrients) + path * cluster(:amount, over = :nutrient) + x(:food) +
  theme(height = 150)) /
  ((data(nutrients) + zone + x(:food) + y(:nutrient) + color(:amount) +
  theme(legend = "bottom")) |
  (data(nutrients) + path * cluster(:amount, over = :food) +
  y(:nutrient) + theme(width = 150)))
below(plot(data(nutrients),
  layer(path, cluster(col.amount, { over: col.nutrient })), x(col.food),
  theme({ height: 150 })),
  beside(plot(data(nutrients), zone, x(col.food), y(col.nutrient),
  color(col.amount), theme({ legend: "bottom" })),
  plot(data(nutrients),
  layer(path, cluster(col.amount, { over: col.food })), y(col.nutrient),
  theme({ width: 150 }))))
0 20 40 Distance chicken salmon spinach oats rice beans lentils almonds carbs fiber iron protein fat Nutrient Food Amount 0 24.95 49.9 0 10 20 30 40 Distance

“Given nutrients: paths derived by cluster on amount, over nutrient, x is food; above zones, x is food, y is nutrient, color by amount, beside paths derived by cluster on amount, over food, y is nutrient.”

Chicken and salmon are side by side, both dark on protein, and oats, rice, beans and lentils are next to each other, dark on carbs. Almonds join the top tree last, and their fat tile is the darkest on the page. In the tree on the right, iron and fiber join first: their amounts are the most alike across the eight foods. Owning chapters: Cluster, Zone.

42.13 What this section refuses

An earlier recipe put population on size, and shape may look like a channel that could carry it too. The seven glyphs have no order from smallest to largest, so shape refuses a continuous column and names channels that do carry magnitude:

data(gapminder_2007) + point + x(gdp) + y(life) + shape(population)
data(gapminder_2007) + point + x(col.gdp) + y(col.life) + shape(col.population)
data(gapminder_2007) + point + x(:gdp) + y(:life) + shape(:population)
plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  shape(col.population))
Error:
! gog: `shape(population)` maps a continuous (numeric) column, but `shape` on `point` needs a categorical (text) column. Use `color`, `size` or `opacity` to show a numeric column.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.