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. Every further variable is carried by a refining channel (color, size, shape, pattern, opacity) or by the third position, z. Each channel answers one kind of question, and a refining channel earns the legend that decodes it.

42.1 Which group does each mark belong to?

The scatter of wealth against life expectancy draws every country as an identical dot. Nothing in it says which continent a country belongs to, and that third 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. The same split without color, when you want the grouping but not the hues, is the group channel; see Channels.

42.2 Will it read in gray?

Hue alone separates groups on a screen but not in grayscale print, in poor light, or for a colorblind reader. When the plot must survive all three, bind color and shape to the same column. Each group is then said twice:

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 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.”

palette() chooses which colors the color channel uses: here the Okabe and Ito colorblind-safe set. Saying one column twice is redundant encoding. Owning chapters: Channels, Setting vs mapping.

42.3 How big is each observation?

Population is a third measurement of each country, and both positions are already taken by wealth and health. A magnitude is carried by size; the channel’s question is how much, so it only accepts a continuous column:

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.” A legend decodes the radius. Owning chapter: Channels.

42.4 What if four variables matter?

The recipes above 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: every one of them works beside every other. That rule is Orthogonality. Wealth, health, 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 is usually the most a single plot can carry. Past that, use panels instead of more channels.

42.5 What if the third variable is a number?

The third column can be continuous rather than categorical. Population is one, carried here by hue rather than by radius, so every dot keeps one size. color accepts either kind of column. A continuous column gets a sequential ramp instead of a palette, and a skewed one reads better on a log ramp, exactly like a skewed axis:

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 gradient strip: same channel, same rule, different column type. Scales owns the log scale.

42.6 What if three measurements matter equally?

Each iris here has three measurements, and none of them is less important than the others. color or size would carry the third one as a refinement of a plot about the other two. When three measurements matter equally, the third one is a position, not a decoration. One more vowel and the plot stands up:

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.”

One atom away. The viewing angle is a property of the space, not of the data; turn rotates the scene sideways, tilt raises the viewing height:

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.”

Space owns the third dimension.

42.7 What if the rows form a route?

The scatter above is a cloud: each flower is its own dot and the table’s order means nothing. Sometimes the order is what matters. Here are the two gliders from Path again. Each row records where that glider was a moment later, so the marks belong 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 the gliders do. Nothing in the sentence draws a spiral: path was told to connect the rows in order, the rows happen to go around while they go up, and the coil is the result. Watch where the two routes cross, too. Whichever route is nearer covers the other, and the nearer one changes as they climb, so neither glider is simply drawn on top.

Turn the view and the same route answers a different question, with no binding changed:

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.”

From nearly overhead the climb collapses and you are looking down at two circles. The plot has become a picture of how wide each circle is, and it shows what the first view hid: Bravo is flying a far tighter circle than Alpha. The side view above showed Bravo climbing faster; this view shows the tighter turn that goes with it.

Of line, step, area, ribbon and path, only path takes z, and the reason is the one that separates it from line in the plane. A path joins rows in the table’s order, and an order belongs to no axis, so it survives the third dimension untouched. A line sorts by x, and the cube has no left to right: at some angles x runs straight into the page and becomes depth rather than position, which would leave a line sorted by an axis you cannot see. So a rising route is path, and line refuses to pretend otherwise:

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.8 What if the shape needs three dimensions?

quakes_fiji is the thousand earthquakes from Map, each with a place on the globe and a depth below it. The reason it is here is that 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"))
180 170 -10 -20 -30 -200 -400 -600 East North Elevation A thousand earthquakes, and the sheet they lie on Magnitude 6.40 5.20 4.00

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

The column is named elevation, so a measurement below sea level is a negative number: the deepest quake in the table sits at −680 km. The quakes are not scattered through the cube. They trace a sheet of ocean floor sinking into the earth. It starts shallow in the east and runs down past 600 km to the west. That is a shape, and a shape needs three positions to be a shape.

Now cut the quakes into slices of depth and run 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))
180 170 -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.”

The band of points moves down and west as the sequence runs, which is the slope of that 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 the column’s job, not the channel’s. Depth happens to be the one variable a cross-section is usually cut on, so this is the ordinary cross-section view, spelled with the atom that also animates a year.

The same slices laid out across the page instead of shown 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 }))
180 170 -10 -30 -200 -400 -600 East North Elevation 180 170 -10 -30 -200 -400 -600 East North Elevation 180 170 -10 -30 -200 -400 -600 East North Elevation 180 170 -10 -30 -200 -400 -600 East North Elevation 180 170 -10 -30 -200 -400 -600 East North Elevation 180 170 -10 -30 -200 -400 -600 East North Elevation 180 170 -10 -30 -200 -400 -600 East North Elevation 180 170 -10 -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, split into panel columns by slab, wrapped at 4.”

Eight cubes, on one shared scale, so the reader compares them side by side instead of remembering them. Choosing between them is a question about the reader: panels let them look back, frames let them see the movement. Play owns that choice.

42.9 Which rows and columns are alike?

The nutrients table 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. Two categorical axes and one value make a tile plot, and nothing in the data chose its slot order. Build a cluster tree on each axis, then compose the three plots: the shared axes take the trees’ leaf order, and tiles of similar color sit 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)) |
     (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)) |
     (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)) |
  (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)),
  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 49.90 24.95 0 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.” Owning chapters: Cluster, Zone.

42.10 What this section refuses

Population was bound to size earlier, and shape may look like another channel that could carry it. Five glyph shapes have no order from smallest to largest, so shape refuses a continuous column and names the 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 `size` or `color` to show a numeric column.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.