37  A third variable, and more

You have more columns than positions. Where do the rest go? Two positions are spoken for. Every further variable rides a refining channel (color, shape, size, opacity) or the third position, z. Each channel answers one kind of question, and each mapping earns the legend that decodes it.

37.1 Which group is each mark?

A category rides color, and the palette gives each category one hue:

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 rainbow, is the group channel; see Many groups.

37.2 Will it survive a photocopier?

Bind color and shape to the same column and each group is said twice, readable for color-blind readers, in grayscale print, and in bad light:

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

palette() chooses which colors the color channel hands out: here the Okabe–Ito color-blind-safe set. Owning chapters: Channels, Setting vs mapping.

37.3 How big is each observation?

A magnitude rides size; the channel’s question is how much, so it only accepts a numeric 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

37.4 All of it at once

Channels compose: that is the whole point of them being orthogonal. 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

Every channel keeps its own rule and its own legend; none of them changed meaning by being combined. Four variables is a sensible ceiling in practice; beyond it, consider panels instead of more channels.

37.5 What if the third variable is a number?

color accepts either kind of column. A numeric 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

The legend becomes a labeled gradient strip: same channel, same rule, different column type. Scales owns the log story.

37.6 The third position

When three measurements deserve equal footing, 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

One atom away. The viewing angle is a property of the space, not of the data; turn swings the scene, tilt lifts your eye:

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

Space owns the third dimension.

37.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 the whole story. Two gliders circling one thermal are measured every few seconds, and each row is where that glider was next, so the marks should be joined, in the order they were recorded, and the mark that joins rows in table order is path:

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

Each thread 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 round while they go up, and the coil is what that is. Watch where the two threads cross, too. Whichever is nearer covers the other, and which one that is changes as they climb, so neither glider is simply drawn on top.

Swing the view and the same route answers a different question, without a single binding changing:

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

From nearly overhead the climb collapses and you are looking down at two circles. The plot has become a picture of the turn radius, and the reading it hands you is one the first view buried: Bravo is flying a far tighter circle than Alpha, which is why Bravo is also the one climbing faster.

One atom away. path is the only stroke that 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; line says so:

data(thermals) + line + x(east) + y(north) + z(altitude) + color(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.

37.8 What if the shape needs three dimensions?

quakes_fiji is a thousand earthquakes recorded near Fiji, each with a place on the globe and a depth below it. The reason it is here is that no pair of its columns shows what all three do:

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 plate they sit 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 plate they sit 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 plate they sit 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 plate they sit on"))
180 170 -10 -20 -30 -200 -400 -600 East North Elevation A thousand earthquakes, and the plate they sit on Magnitude 6.40 5.20 4.00

They are not scattered through the volume. They lie on a sheet, tilted, running from shallow near the trench down past 600 km to the west, which is a slab of ocean floor sinking under the plate beside it. That is a shape, and a shape needs three positions to be a shape.

Now cut it into 90 km slices of depth and run them:

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

The cluster walks down and west as the sequence runs, which is the tilt 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 business, not the channel’s. Depth happens to be the one variable a cross-section is usually cut on, so this is the ordinary tomographic slice, spelled with the atom that was designed for animation.

The same slices laid out across the page instead of along the clock, which is the other half of the same split:

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

Eight cubes, on one shared scale, so the march is a comparison rather than a memory. Which of the two you want is a question about the reader: panels let them look back, frames let them see the movement. Play is where that choice is argued.

37.9 What this section refuses

Glyphs cannot order themselves, so shape refuses a numeric column, and names the channels that do carry magnitude:

data(gapminder_2007) + point + x(gdp) + y(life) + shape(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.