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