| 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 |
32 Space
What do you do when a third measurement matters as much as the first two? Most plots so far have been drawn on a plane: a mark, an x, a y. The grammar has a third position channel, z. Binding it is enough to draw the mark in three dimensions.
32.1 z: one more vowel
Here are three flower measurements. Two of them make an ordinary scatter, and the species already separate, although versicolor and virginica overlap a little where they meet along the diagonal:
data(iris_flowers) + point +
x(petal_length) + y(sepal_length) + color(species)(data(iris_flowers) + point +
x(col.petal_length) + y(col.sepal_length) + color(col.species))data(iris_flowers) + point + x(:petal_length) + y(:sepal_length) +
color(:species)plot(data(iris_flowers), point, x(col.petal_length), y(col.sepal_length),
color(col.species))“Given the iris flowers: points, x is petal length, y is sepal length, color by species.”
Now add the third measurement as z. Nothing else about the sentence changes: the same point, the same x and y, and one more position, which the Grammar chapter calls a vowel. The flat plot becomes a cube:
data(iris_flowers) + point +
x(petal_length) + y(sepal_length) + z(sepal_width) + color(species)(data(iris_flowers) + point +
x(col.petal_length) + y(col.sepal_length) + z(col.sepal_width) + color(col.species))data(iris_flowers) + point + x(:petal_length) + y(:sepal_length) +
z(:sepal_width) + color(:species)plot(data(iris_flowers), point, x(col.petal_length), y(col.sepal_length),
z(col.sepal_width), color(col.species))“Given the iris flowers: points, x is petal length, y is sepal length, z is sepal width, color by species.”
That is the whole idea. A 3-D scatter is not a different chart with a different name; it is point with its third position bound, the same way a horizontal bar is just bar with its categories on y. A flat plot is a 3-D plot with z held constant. So every flat plot you have read so far was a special case of the cube, and the same rules place a mark in both.
Drawing a 3-D plot on a flat page takes two steps, and the second is the one every flat plot already uses. First the engine turns the three coordinates of every point to match the angle you are looking from. Then it draws the turned points on the page, the way it draws any flat plot. Points nearer to you are drawn last, so they cover the ones behind them. The fill is translucent, so a point behind still shows through the one in front. The drawing order is what gives a flat picture its depth.
The faint box behind the points is a guide rather than part of the data. Points alone give you no way to tell which direction is x, which is y and which is z. The box draws the edges of the space the points are drawn in, and each of the three axes puts its numbers along one of those edges. Those three edges are drawn darker than the other nine.
Now look at the flowers. At this opening angle setosa is separate from the other two, as it was on the flat page, but versicolor and virginica overlap much more than they did there. The data has not changed, so the extra overlap comes from the angle you are looking from.
In the web edition you can turn this cube yourself. Drag it in any direction and watch versicolor and virginica. Some angles separate the two colors, and others leave them mixed together. When you find an angle that separates them, read the two numbers under the plot. They are called turn and tilt, and they describe the view you are looking at. The next section shows how to write those two numbers into the sentence, so the plot opens at the angle you found.
32.2 The viewing angle
A 3-D plot has to be seen from somewhere, and that angle is not part of the data. It is a property of the space the data is drawn in, the way a polar plot needs to know where its circle starts. Polar is the next chapter, and polar(start = ) is the same kind of parameter. So the angle is not a channel (it maps no column) and not a mark. It is space():
data(iris_flowers) + point +
x(petal_length) + y(sepal_length) + z(sepal_width) + color(species) +
space(turn = -95, tilt = 15)(data(iris_flowers) + point +
x(col.petal_length) + y(col.sepal_length) + z(col.sepal_width) + color(col.species) +
space(turn = -95, tilt = 15))data(iris_flowers) + point + x(:petal_length) + y(:sepal_length) +
z(:sepal_width) + color(:species) + space(turn = -95, tilt = 15)plot(data(iris_flowers), point, x(col.petal_length), y(col.sepal_length),
z(col.sepal_width), color(col.species), space({ turn: -95, tilt: 15 }))“Given the iris flowers: points, x is petal length, y is sepal length, z is sepal width, color by species, turned -95, tilted 15.”
This is the first cube again, seen from another side and from a little lower down. The choice matters. From this side petal length runs across the page, and sepal length runs into the page, away from you. Virginica has the longer petals, so it is to the right of versicolor, and the two barely overlap. At the default angle the two lengths ran in opposite directions across the page, so most of the difference between the two species was lost. The low tilt points the z axis up the page and flattens the floor. Sepal width and petal length are easy to read, but the flat floor squeezes sepal length into a short axis. A higher tilt does the reverse: the floor takes up more of the page and the heights grow shorter.
Turning the plot changes how you look at the data, never what the plot claims about it. That is why the angle is set in space(), and not by a channel or a mark. Omit space() and the plot opens at a default angle; use space() when that default hides what you want to see.
Both angles are given in degrees, and both move your eye rather than the data.
turn says which side of the cube you look at. It moves your eye around the plot, the way you walk around an object to see another side of it. At turn = 0 you look straight along the x axis, so x runs into the page. As turn grows from 0, the direction you look moves from the x axis toward the y axis. At turn = 90 you look straight along y, so y runs into the page. A negative turn moves your eye around the other way, and a full circle is 360 degrees.
tilt says how high your eye is. At tilt = 0 you are level with the floor and see the cube from the side. Raise it and you look down from higher up. At tilt = 90 you are directly above the cube, looking straight down. The floor fills the picture. The z axis now points straight at you, so it has no length left on the page and even its label disappears. A negative tilt puts your eye below the floor, looking up, as far as tilt = -90 from directly underneath. Dragging stops at tilt = 90 and at tilt = -90. A written tilt must also stay between -90 and 90, so dragging reaches every tilt you can write.
A plot with no space() opens at turn = 30, tilt = 25. That is far enough around and high enough up to show all three axes at once, which is why it is the default. Name one angle and the other keeps its default, so space(tilt = 85) looks down from high up without changing which side you stand on. Writing space() with nothing inside it takes both defaults, which is how the histogram sentences later in this chapter ask for a cube. (Astronomy calls these two angles azimuth and elevation, and the plain words say the same thing.)
32.3 Turning the plot yourself
In the web edition you turn every cube in this book the same way, by dragging it. The cube follows your pointer. Drag right and the side facing you moves right; drag down and it tips down to show you the top. A row under each cube shows the two angles of the current view, and the numbers change as you drag. So you can find an angle by hand, then write it into the sentence as space(turn = , tilt = ).
A reset button is beside those numbers. It returns the plot to the view it opened at, which is the angle the sentence asked for, or the default angle if the sentence named none. It is dimmed until you turn the plot, because until then there is nothing to undo.
The same buttons under every plot are on a line of their own, between the plot and the angles, and each button does the same work here as on a flat plot. The camera saves the picture as you are looking at it, so the angle you turned the cube to is saved with it.
The cube differs from a flat plot in two ways, and both follow from one fact: dragging turns the cube and everything in it, rather than moving the picture across the page. So a zoomed cube cannot be dragged to one side, and the cube has a second control that undoes something: reset. The two undo different things: the frame button undoes the zoom, and reset undoes the angle. So zooming in never loses the angle you found, and going back to the opening angle keeps the zoom you set. A flat plot has no angle to restore, so it has no reset.
Turning a cube with the pointer changes nothing in its sentence. space() sets the angle the plot opens at, and dragging moves the view to any other angle from there. The data, the marks and the scales are the same at every angle.
On a flat plot, the word brush lets a reader select rows by dragging across the panel (Selection). A drag on the cube turns it instead, so it cannot select. Put a flat plot of the same table with a brush beside the cube, and drag across the flat plot. The rows you select keep their color in the cube as well. A cube beside a flat plot shows how to write it.
Dragging needs the web edition. In the PDF, each plot is the still image its sentence asks for. That is the reason to name the angle even for readers who can drag: the PDF shows the view you chose, and the web edition opens at it.
32.4 path: the route through the cube
So far the cube has held one mark. In a scatter no dot is joined to another, and the table’s order means nothing. When the order of the rows is what matters, the mark that says so is path, which joins rows in the order the table gives them. Give it three positions and the route runs through the cube. Here are two gliders circling one thermal (a rising column of warm air), sampled as they climb:
| 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)data(thermals) + path + x(col.east) + y(col.north) + z(col.altitude) + color(col.glider)data(thermals) + path + x(:east) + y(:north) + z(:altitude) +
color(:glider)plot(data(thermals), path, x(col.east), y(col.north), z(col.altitude),
color(col.glider))“Given the thermals: paths, x is east, y is north, z is altitude, color by glider.”
Nothing in that sentence asks for a spiral. path was told to join the rows in order, and the gliders circled while they climbed. The spiral is what that looks like when it is drawn. The same three bindings would draw a straight line for an aircraft flying level, and a tangle for a tumbling one, because the route is only the order of the rows.
Look at where the two routes cross. The one in front covers the one behind, and which is which changes along the climb, so neither glider is simply “on top”. A stroke running through the cube has no single distance from the eye. Its far end and its near end are different distances. So the engine sorts the segments rather than the strokes, and sorts the segments of both routes together. Sorting whole strokes would put one glider entirely in front of the other: a picture of two coils that never meet, instead of two that pass through each other along the climb.
path is the only mark that draws a route through the cube. The reason is the one that already separates it from line on the flat page: the order of the rows. An order belongs to no axis, so it means the same thing when the plane becomes a cube. A line sorts by x and draws one y for each, read left to right along a domain. The cube has no left to right. x is one of three equal positions, and at some angles it runs straight into the page and becomes depth. A line in the cube would be sorted by an axis you cannot see, so the engine refuses it instead:
data(thermals) + line + x(east) + y(north) + z(altitude)data(thermals) + line + x(col.east) + y(col.north) + z(col.altitude)data(thermals) + line + x(:east) + y(:north) + z(:altitude)plot(data(thermals), line, x(col.east), y(col.north), z(col.altitude))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.
32.5 The 3-D histogram
Every cube so far bound a column to each of the three axes. The third axis can also carry something the data does not hold. The heatmap cuts a plane into cells and colors each one by how many rows fell in it. Those cells already cover a plane, and in the cube that plane is the floor. The color on each cell shows its count. Two shades of one color are hard to compare exactly, and two heights are easy. Here is the heatmap first, and then the same counts drawn as heights:
| 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 |
data(gm_all) + zone * bin(20) + x(gdp, scale = "log") + y(life)data(gm_all) + zone * bin(20) + x(col.gdp, scale = "log") + y(col.life)data(gm_all) + zone * bin(20) + x(:gdp, scale = "log") + y(:life)plot(data(gm_all), layer(zone, bin(20)), x(col.gdp, { scale: "log" }),
y(col.life))“Given all the gapminder years: zones derived by bin into 20, x is gdp on a log scale, y is life.”
data(gm_all) + bar * bin(20) + x(gdp, scale = "log") + y(life) + space() +
style(color = "#2a9d8f")(data(gm_all) + bar * bin(20) + x(col.gdp, scale = "log") + y(col.life) + space() +
style(color = "#2a9d8f"))data(gm_all) + bar * bin(20) + x(:gdp, scale = "log") + y(:life) +
space() + style(color = "#2a9d8f")plot(data(gm_all), layer(bar, bin(20)), x(col.gdp, { scale: "log" }),
y(col.life), space(), style({ color: "#2a9d8f" }))“Given all the gapminder years: bars derived by bin into 20, x is gdp on a log scale, y is life, in the cube.”
The mark changed from zone to bar, and space() asks for the cube. The transform is the same bin, cutting the same two axes into the same cells, and the count it writes is the same count. What differs is the mark that receives the count. The mark decides which visual attribute carries it. A zone measures nothing by length, so its count has to become a color. A bar is a length from a baseline, so its count becomes a height. The bar’s color then carries nothing, so every bar has the same color, and style() only chooses which one.
Nothing was added to the grammar to do that, and the rule that decided it was written for the flat page. How many axes a transform cuts is decided by the mark. bar * bin cuts one: a bar uses one axis for the domain and keeps the other to measure along. zone * bin cuts two, because a zone keeps none. A cube gives the bar a third axis. It can use two for the floor and still have one to measure along, so the same bar * bin that cuts one axis on the page cuts two here. Read that way, the 3-D histogram needs no feature of its own. It is what the flat rule already said, applied to three axes.
Note also what the sentence leaves out. There is no z(), for the same reason a flat histogram needs no y(). The transform writes the count itself, so you do not have to name it.
The two plots hold the same counts. What differs is what each one lets you see easily. The heatmap shows every cell at once, and no cell hides another, but cells with similar shades are hard to rank, because you read a shade less precisely than a length (Munzner, 2014). The bars show the counts as heights, but a tall bar can hide a shorter one behind it. In the web edition you turn the cube until no bar hides another; in print you see only the angle written in space(turn = , tilt = ). Draw the heatmap to see which regions hold many rows, and the bars to compare how many rows each cell holds.
32.6 Transforms in the cube
That floor was cut from two continuous axes. A floor is made of cells, so the question is where the cells come from. The zone chapter answered it for a plane, and the same two answers hold here. A continuous axis has to be cut into cells before anything can be counted in them; a categorical axis arrives already cut, because a category is a cell. So which transform you want depends on which kind of axis you have. Two categorical axes arrive already cut:
| country | continent | year | life | population | gdp | era |
|---|---|---|---|---|---|---|
| Afghanistan | Asia | 1957 | 30.332 | 9240934 | 820.8530 | 1957 |
| Afghanistan | Asia | 2007 | 43.828 | 31889923 | 974.5803 | 2007 |
| Albania | Europe | 1957 | 59.280 | 1476505 | 1942.2842 | 1957 |
| Albania | Europe | 2007 | 76.423 | 3600523 | 5937.0295 | 2007 |
| Algeria | Africa | 1957 | 45.685 | 10270856 | 3013.9760 | 1957 |
data(gm_eras) + bar * count + x(continent) + y(era) + space() + style(color = "#2a9d8f")data(gm_eras) + bar * count + x(col.continent) + y(col.era) + space() + style(color = "#2a9d8f")data(gm_eras) + bar * count + x(:continent) + y(:era) + space() +
style(color = "#2a9d8f")plot(data(gm_eras), layer(bar, count), x(col.continent), y(col.era),
space(), style({ color: "#2a9d8f" }))“Given the gapminder eras: bars derived by count, x is continent, y is era, in the cube.”
That sentence counts the rows for every pair of categories, and each count is drawn as a bar’s height. A floor with one axis of each kind is a distribution per category, drawn as bars:
data(gm_all) + bar * bin(12) + x(life) + y(continent) + space() + style(color = "#2a9d8f")data(gm_all) + bar * bin(12) + x(col.life) + y(col.continent) + space() + style(color = "#2a9d8f")data(gm_all) + bar * bin(12) + x(:life) + y(:continent) + space() +
style(color = "#2a9d8f")plot(data(gm_all), layer(bar, bin(12)), x(col.life), y(col.continent),
space(), style({ color: "#2a9d8f" }))Compare the two floors. The count floor holds a category on both axes, so its bars leave a gap in both directions. The bin floor holds one axis of each kind, so its bars touch along the binned axis and leave a gap along the categorical one. That is not a decision about how a 3-D plot should look; it is the flat rule, per axis. A histogram’s bins are adjacent intervals, so its bars fill them and meet. A category is one slot, and its neighbors are separate slots rather than adjacent intervals. A bar fills four fifths of its slot and leaves one fifth empty, and that gap is what says the categories are separate. So a floor cut on one axis and slotted on the other has no gaps one way and gaps the other. Each axis follows the rule for its own kind of column, and neither depends on the other.
Ask for a cut where there is nothing to cut and the engine names the other transform, exactly as it does on the plane:
data(gm_eras) + bar * bin + x(continent) + y(era) + space()data(gm_eras) + bar * bin + x(col.continent) + y(col.era) + space()data(gm_eras) + bar * bin + x(:continent) + y(:era) + space()plot(data(gm_eras), layer(bar, bin), x(col.continent), y(col.era),
space())Error:
! gog: `bin` cuts a continuous axis into intervals, and `x(continent)` and `y(era)` are both categorical — a category is one slot, with no width to cut. To tally rows per category, `count` is the transform that does it: `bar * count`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
count and bin both supply their own measure: you give count no column to reduce, because it counts rows. The other kind of statistic is the kind you give a column to reduce: a mean, a sum, a median. Those work on a floor too, and the sentence names the column on the axis that measures:
data(gm_eras) + bar * mean + x(continent) + y(era) + z(life) + space() +
style(color = "#2a9d8f")(data(gm_eras) + bar * mean + x(col.continent) + y(col.era) + z(col.life) + space() +
style(color = "#2a9d8f"))data(gm_eras) + bar * mean + x(:continent) + y(:era) + z(:life) +
space() + style(color = "#2a9d8f")plot(data(gm_eras), layer(bar, mean), x(col.continent), y(col.era),
z(col.life), space(), style({ color: "#2a9d8f" }))“Given the gapminder eras: bars derived by mean, x is continent, y is era, z is life.”
Each bar is on the cell of one continent and one era, and its height is that group’s mean life expectancy. Read that beside the flat bar * mean + x(continent) + y(life). The rule is the same in both. A summary reduces the column on the axis the mark measures along, and groups by the other positions. On the flat page, a bar measures with y, so it groups by x and averages y. In the cube it measures with z, so it groups by the pair and averages z. Nothing about mean changed. The mark simply has one more position to group by.
That is the same rule the histogram used, applied a second time: take the positions the space offers, and set aside the one the mark measures along. Applied to bin, it says how many axes are cut. Applied to mean, it says how many columns the rows are grouped by. One rule answers both questions, and in both the answer depends on the mark.
The two kinds of statistic differ in what you give them, not in what they can draw. count and bin write their own measurement, so they need no column, and a color or a height shows the count. mean, sum, median, max, min and quantile reduce a column you name, so the sentence has to name it. Leave z() off and the engine says so rather than guessing.
One statistic does stay on the plane. It is smooth, which fits a curve instead of reducing a column:
data(gm_eras) + bar * smooth + x(continent) + y(era) + z(life) + space()data(gm_eras) + bar * smooth + x(col.continent) + y(col.era) + z(col.life) + space()data(gm_eras) + bar * smooth + x(:continent) + y(:era) + z(:life) +
space()plot(data(gm_eras), layer(bar, smooth), x(col.continent), y(col.era),
z(col.life), space())Error:
! gog: `smooth` fits a curve of `y` against `x`, and `x(continent)` and `y(era)` are both categorical — a fit needs a number line to run along. For a typical value per category, `bar * mean` (or `point * mean`) says it directly.
gog: `bar * smooth` fits a curve of one column against another, and a curve needs a domain to run along — the cube's floor is a *pair* of positions with no left to right, which is why `line` and `area` cannot stand here either. Smooth on the plane, where there is a domain: `line * smooth + x(<a>) + y(<b>)`. To summarize a column within each pair of categories instead, the six reductions (`sum`, `mean`, `median`, `max`, `min`, `quantile`) do read a floor: `bar * mean + x(<a>) + y(<b>) + z(<column>) + space()`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
The engine gives two reasons, and only the second belongs to the cube. The first would refuse the same sentence on the flat page, because both floor columns are categories. The second holds on any floor: a smoother traces a curve of one column against another, and a curve needs a domain to run along. The cube’s floor is a pair of positions with no left to right. That is why line and area cannot be drawn here either. It is the same reason, stated by a transform this time instead of by a mark.
32.7 interval and box in the cube
A mean per cell hides how far the values in that cell spread, and the spread is often the question. A bar is not the only mark that is drawn in a slot and measures along the other axis. An interval does the same with a whisker, which spans a range from end to end, and a box does it with its quartiles and its median. So all three can be drawn in the cube, each on one cell of the floor, measuring along z:
data(gm_eras) + interval * range + x(continent) + y(era) + z(life) + space() +
style(color = "#2a9d8f")(data(gm_eras) + interval * range + x(col.continent) + y(col.era) + z(col.life) + space() +
style(color = "#2a9d8f"))data(gm_eras) + interval * range + x(:continent) + y(:era) + z(:life) +
space() + style(color = "#2a9d8f")plot(data(gm_eras), layer(interval, range), x(col.continent), y(col.era),
z(col.life), space(), style({ color: "#2a9d8f" }))“Given the gapminder eras: intervals derived by range, x is continent, y is era, z is life.”
Every cell’s whisker spans that cell’s own low and high. The caps (the short lines across each end of a whisker) are a cross rather than a single tick, and that is not decoration. On the flat page a cap runs across the slot, in the one direction the whisker does not use. A cube has two such directions. Picking one of them would be arbitrary, and a reader would look for a meaning that is not there. So the cap runs in both.
Ask for confidence, which carries a center as well as two ends, and gog draws the pointrange: a dot at the center with a whisker on each side.
data(gm_eras) + interval * confidence + x(continent) + y(era) + z(life) +
space() + style(color = "#2a9d8f")(data(gm_eras) + interval * confidence + x(col.continent) + y(col.era) + z(col.life) +
space() + style(color = "#2a9d8f"))data(gm_eras) + interval * confidence + x(:continent) + y(:era) +
z(:life) + space() + style(color = "#2a9d8f")plot(data(gm_eras), layer(interval, confidence), x(col.continent),
y(col.era), z(col.life), space(), style({ color: "#2a9d8f" }))“Given the gapminder eras: intervals derived by confidence, x is continent, y is era, z is life.”
The box plot is drawn in the cube the same way. Its box is a solid between the two quartiles, and its whiskers reach beyond the box:
data(gm_eras) + box + x(continent) + y(era) + z(life) + space() + style(color = "#2a9d8f")data(gm_eras) + box + x(col.continent) + y(col.era) + z(col.life) + space() + style(color = "#2a9d8f")data(gm_eras) + box + x(:continent) + y(:era) + z(:life) + space() +
style(color = "#2a9d8f")plot(data(gm_eras), box, x(col.continent), y(col.era), z(col.life),
space(), style({ color: "#2a9d8f" }))“Given the gapminder eras: boxes, x is continent, y is era, z is life.”
Look at the median on each box. It is drawn only where the box’s own sides face you. The median is a plane inside an opaque solid, so the far half of it is behind the box. That is the back-face rule (a face turned away is not drawn), the same rule the solid’s own six faces follow. One case is handled differently. When every value in a group is the same, its two quartiles are equal, so the box has no height and no sides to turn toward you. The whole median is drawn instead, as a flat rectangle. On the flat page, a box of no height becomes a single line in the same way.
The small dots beyond the whiskers are outliers, each drawn at the center of its cell. Interval and Box are the chapters for these two marks on the flat page.
32.8 z_label(): naming the third axis
The bar, the interval and the box were each placed by x and y and measured along z. Whatever is drawn in the cube, the third axis still needs a label. Each axis takes its label from the name of the column bound to it, and you can replace any of the three. Each position has its own label function, and z_label() is the third. Units are the usual reason to set all three at once:
data(thermals) + path + x(east) + y(north) + z(altitude) + color(glider) +
x_label("Meters east") + y_label("Meters north") + z_label("Altitude (m)") +
title("Two gliders, one thermal")(data(thermals) + path + x(col.east) + y(col.north) + z(col.altitude) + color(col.glider) +
x_label("Meters east") + y_label("Meters north") + z_label("Altitude (m)") +
title("Two gliders, one thermal"))data(thermals) + path + x(:east) + y(:north) + z(:altitude) +
color(:glider) + x_label("Meters east") + y_label("Meters north") +
z_label("Altitude (m)") + title("Two gliders, one thermal")plot(data(thermals), path, x(col.east), y(col.north), z(col.altitude),
color(col.glider), x_label("Meters east"), y_label("Meters north"),
z_label("Altitude (m)"), title("Two gliders, one thermal"))Ask for a z_label() on a plot with no z and the label has no axis to name, so gog says so and draws the plot anyway. It is guidance rather than a refusal, because nothing else about the plot is wrong.
Path is the chapter for the mark itself; the same two gliders appear in the third-variable cookbook.
32.9 What the cube refuses
Three sentences in this chapter have been refused already, each with a message that says why. What follows is what the cube itself will not do, and why. The first two are not refusals but fixed drawing orders, and the engine draws the plot without a message. The first concerns the guide box: the engine does not sort it by depth with the marks. The box is drawn behind the marks and its labels are drawn over them, whatever the angle. So an edge of the box never passes in front of a point, even where the edge is nearer to you. And no mark can cover the numbers that say what it measures, because a guide describes the scene rather than being part of it.
Turning the whole cube is cheap, and the engine does it every time you drag. Working out, piece by piece, which part of the box hides which point costs far more computing, and the renderer does not do it. That is a limit of the renderer, not a rule of the grammar, so the box stays behind the marks however far you turn the scene.
Layers keep a fixed order as well. The depth sort runs within a layer, and layers are painted in the order you wrote them, exactly as they are on the flat page. So a point layer written after a path is drawn in front of the whole route rather than passing through it. That is the ordinary layering rule applied in the cube, not a new exception, so read a layered 3-D plot as layers, not as one depth-sorted scene.
A tilt beyond 90 or -90 is refused, and a turn beyond a full circle is not. One of the two angles has an end and the other does not, and that is the whole reason:
data(thermals) + point + x(east) + y(north) + z(altitude) + space(tilt = 95)data(thermals) + point + x(col.east) + y(col.north) + z(col.altitude) + space(tilt = 95)data(thermals) + point + x(:east) + y(:north) + z(:altitude) +
space(tilt = 95)plot(data(thermals), point, x(col.east), y(col.north), z(col.altitude),
space({ tilt: 95 }))Error:
! gog: `space(tilt = 95)` is outside -90 to 90, which is where an elevation lives. At 90 the camera looks straight down at the floor and at -90 straight up from underneath; past either the scene turns over. Use `space(tilt = 90)` for the nearest view, or `space(turn = )` to swing around the plot instead — a bearing wraps and an elevation does not.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
tilt is how high your eye is, and there is a highest point. At 90 you are directly above the cube, and at -90 directly underneath. Going past either end would turn the scene upside down, and an upside-down cube shows nothing the sentence asked for. So dragging stops at both ends, and a written tilt must stay between them.
turn is which side you stand on, and it has no end. A full circle brings you back, so 390 is the same view as 30 and gog draws it without comment:
data(thermals) + point + x(east) + y(north) + z(altitude) + space(turn = 390)data(thermals) + point + x(col.east) + y(col.north) + z(col.altitude) + space(turn = 390)data(thermals) + point + x(:east) + y(:north) + z(:altitude) +
space(turn = 390)plot(data(thermals), point, x(col.east), y(col.north), z(col.altitude),
space({ turn: 390 }))That is space(turn = 30) drawn a second time: 390 is 30 plus a full circle, so the two sentences produce exactly the same file. tilt has two ends and turn has none, so the two rules come from the angles themselves, not from a choice gog made.
When the cube refuses a sentence, the engine’s message gives one of three reasons. The grammar may forbid it: that is decided. Drawing it may need the renderer to work out, piece by piece, what hides what: that is blocked. Or the engine may only lack the drawing code: that is not built.
Some marks are decided. line, step, area and ribbon read a domain left to right. They sort by x and draw one value for each, which is what makes a line a function rather than a route. A cube has no left to right, so all four are refused there. The path section showed that refusal for line. It is a rule rather than a missing feature, and the refusal says so: the route through three dimensions is path, which is line with exactly that sort removed. On the page line and path differ by one rule: whether the rows are sorted by x. In the cube that one rule decides which of the two can be drawn at all.
32.10 What the cube blocks
The second reason is blocked, and it is a different kind of refusal. A rule in a cube is a plane. It takes one position, as it does on a flat plot, and spans the other two axes. Here z(altitude) places the rule, so each row would be a horizontal plane at its own altitude, and the engine refuses the sentence:
data(thermals) + rule + z(altitude) + space()data(thermals) + rule + z(col.altitude) + space()data(thermals) + rule + z(:altitude) + space()plot(data(thermals), rule, z(col.altitude), space())Error:
! gog: `rule + z(altitude)` in a cube is a **plane**: it marks `altitude` on `z` and spans `x` and `y`. Marks in space are sorted by their footprint, and a plane's footprint runs across the whole floor, so it could only be drawn wholly in front of or wholly behind the data when its job is to cut through it. A plane in a cube is valid grammar, but this engine does not draw it: that would mean working out, piece by piece, what hides what. Draw the plot flat, where a rule is a line.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
Marks in the cube are painted in order, from far to near. The engine sorts each solid, such as a bar, into that order by the part of the floor it covers. A horizontal plane spans the whole floor, so part of it is nearer to you than a given point and part is farther away. Drawn in one piece, it could only be entirely in front of the data or entirely behind it, but its purpose is to cut through the data. A zone in a cube is a slab, a plane with thickness, and it is refused for the same reason. Neither is drawn, because drawing one means working out, piece by piece, what hides what, and the engine does not do that.
The third reason is the simplest: the grammar allows the sentence, and the engine has no drawing code for it. A log scale on z is the example. The engine draws the z axis only on a linear scale, so a log z is refused. The message says to drop the log scale, or to put it on x or y:
data(iris_flowers) + point + x(petal_length) + y(sepal_length) +
z(sepal_width, scale = "log")(data(iris_flowers) + point + x(col.petal_length) + y(col.sepal_length) +
z(col.sepal_width, scale = "log"))data(iris_flowers) + point + x(:petal_length) + y(:sepal_length) +
z(:sepal_width, scale = "log")plot(data(iris_flowers), point, x(col.petal_length), y(col.sepal_length),
z(col.sepal_width, { scale: "log" }))Error:
! gog: a log `z`-axis is valid grammar, but this engine does not draw it: `z` is linear in 3-D. Drop `scale = "log"` on `z`, or put the log channel on `x`/`y`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
The offsets are not built either. On the flat page, dodge and stack arrange bars that share a slot, and jitter spreads points within one. In the cube a slot is a cell of the floor, seen at an angle, and the engine has no drawing code for arranging marks there, so all three are refused. For the same reason, a bar may not share its cell with another bar. Bars in the cube are solids, and two solids in one cell would be drawn inside one another, so only the taller would show. The gm_eras table has many countries in each cell of continent and era, so a plain bar is refused:
data(gm_eras) + bar + x(continent) + y(era) + z(life) + space()data(gm_eras) + bar + x(col.continent) + y(col.era) + z(col.life) + space()data(gm_eras) + bar + x(:continent) + y(:era) + z(:life) + space()plot(data(gm_eras), bar, x(col.continent), y(col.era), z(col.life),
space())Error:
! gog: a `bar` in `space()` stands one solid in each cell of the floor, and 10 of its 10 cells hold more than one row, so the solids would be drawn inside one another and only the largest in each cell would show. To draw one bar for each cell, summarize the rows: `bar * sum` or `bar * mean`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
The message offers a statistic, such as mean, which draws one bar for each cell, as in Transforms in the cube. When color() splits the bars, it also offers | facet() and play(), which give each group its own cube or its own frame. A statistic on a point or a text is not built in the cube either, and its message points to the bar, which reads the floor that way.
A pattern on a bar or a box is refused in the cube, for the reason the surface refuses it. A hatch is drawn in the page’s own units, and the projection shrinks each face of a solid by a different amount, so one hatch would look denser on some faces than on others. Use color() to tell the groups apart in the cube.
Every refusal above comes with a message that says why. Silence would be worse than a refusal, because a plot that quietly dropped a dimension would still look finished and would answer a question you never asked.
32.11 Faceting: one cube per panel
A cube of 142 countries mixes five continents together. The question is how one continent’s points differ from another’s. A facet splits the rows into panels, and a panel is a rectangle. The cube is drawn inside a rectangle. So a faceted 3-D plot is one cube per panel, each projected into its own panel:
| 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) + y(life) + z(population) +
color(continent) | facet(continent)(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + z(col.population) +
color(col.continent) | facet(col.continent))data(gapminder_2007) + point + x(:gdp) + y(:life) + z(:population) +
color(:continent) | facet(:continent)plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
z(col.population), color(col.continent), across(col.continent))“Given gapminder 2007: points, x is gdp, y is life, z is population, color by continent, split into panel columns by continent.”
The scales are shared, exactly as they are on the flat page, so the five cubes share one set of axes, and a given height means the same population in every panel. Oceania holds two countries, so its cube is nearly empty, and that is the correct reading. The shared scale is what makes the difference visible.
The same split, on a route rather than a scatter:
data(thermals) + path + x(east) + y(north) + z(altitude) + color(glider) |
facet(glider)(data(thermals) + path + x(col.east) + y(col.north) + z(col.altitude) + color(col.glider) |
facet(col.glider))data(thermals) + path + x(:east) + y(:north) + z(:altitude) +
color(:glider) | facet(:glider)plot(data(thermals), path, x(col.east), y(col.north), z(col.altitude),
color(col.glider), across(col.glider))The guides are the one thing that needs explaining. Each cube draws its own tick numbers and its own three axis labels. A faceted flat plot writes each axis once, along an edge of the page. That is a rule rather than an oversight. A guide drawn on the panel’s boundary is drawn once, because the panels share that boundary. A guide drawn inside the panel appears in every panel, because there is no boundary to share it on. A faceted polar plot works the same way: each panel draws the category names around its own circle. A cube’s three axes are edges of the cube, and the cube is inside the panel. A shared column of numbers beside the page would have nothing to point at. The tick reading -200 lands in a different place in every panel, because each panel projects from its own rectangle.
Each axis puts its numbers along one of the three darker edges. Which edges they are depends on the viewing angle, and the engine picks them by a rule. The cube has four edges parallel to each axis. Two of the four edges parallel to x lie on the floor, and x takes the one that is lower in the picture. y does the same with its own two floor edges. Seen from above, with a positive tilt, the lower edges are the ones nearest you. Seen from below, with a negative tilt, they are the ones farthest from you. From below, the floor edges nearest you cross the middle of the picture, so numbers on them would cover the data. In both views, the two numbered edges form a V shape at the bottom of the picture. z takes the vertical edge on the left side of the cube’s outline, the edges around the outside of the drawn cube. All three lie on that outline, so their numbers are outside the cube, away from the marks. Surface shows the same rule: the surface fills most of the picture, and the z numbers are still outside the surface.
Before any text is drawn, the engine checks the numbers and axis labels against each other for overlap. Where two would overlap, it moves one of them farther from the cube, or leaves it out. So numbers from two axes never overlap where their edges meet, and an axis label never covers its own numbers.
One case is left: an axis that is drawn very short. Raise the tilt and the measure axis is drawn shorter, until its numbers no longer fit. Here is the same summary at two tilts; the second is seen from almost straight above:
((data(gm_eras) + bar * mean + x(continent) + y(era) + z(life) + space() +
style(color = "#2a9d8f") +
z_label("Mean life expectancy") + title("The default view")) |
(data(gm_eras) + bar * mean + x(continent) + y(era) + z(life) +
space(tilt = 85) + style(color = "#2a9d8f") +
z_label("Mean life expectancy") + title("Tilted up, with fewer numbers"))) +
theme(height = 430)(((data(gm_eras) + bar * mean + x(col.continent) + y(col.era) + z(col.life) + space() +
style(color = "#2a9d8f") +
z_label("Mean life expectancy") + title("The default view")) |
(data(gm_eras) + bar * mean + x(col.continent) + y(col.era) + z(col.life) +
space(tilt = 85) + style(color = "#2a9d8f") +
z_label("Mean life expectancy") + title("Tilted up, with fewer numbers"))) +
theme(height = 430))((data(gm_eras) + bar * mean + x(:continent) + y(:era) + z(:life) +
space() + style(color = "#2a9d8f") + z_label("Mean life expectancy") +
title("The default view")) |
(data(gm_eras) + bar * mean + x(:continent) + y(:era) + z(:life) +
space(tilt = 85) + style(color = "#2a9d8f") +
z_label("Mean life expectancy") +
title("Tilted up, with fewer numbers"))) + theme(height = 430)beside(plot(data(gm_eras), layer(bar, mean), x(col.continent), y(col.era),
z(col.life), space(), style({ color: "#2a9d8f" }),
z_label("Mean life expectancy"), title("The default view")),
plot(data(gm_eras), layer(bar, mean), x(col.continent), y(col.era),
z(col.life), space({ tilt: 85 }), style({ color: "#2a9d8f" }),
z_label("Mean life expectancy"),
title("Tilted up, with fewer numbers")), theme({ height: 430 }))“Given the gapminder eras: bars derived by mean, x is continent, y is era, z is life, beside the same tilted 85.”
The right-hand plot draws fewer numbers up its measure axis, and that is all it does about the crowding: it drops numbers and never changes the scale. A coarser tick step would also clear the crowding, and gog does not use it. The step decides where the axis stops, so a bigger step pushes the top of the axis above the tallest bar and leaves empty space above the bars. The numbers that remain are always a subset of the ones the left-hand plot shows. Each one is at the same height in both, so a value read from either panel is the same value.
A second kind of split lays the panels out in time instead of across the page. play is that chapter, and it also shows the two kinds of split used together.
32.12 In short
Binding z places a mark in the cube, and nothing else in the sentence changes. A transform cuts or groups by each position the mark does not measure along, so bin cuts two axes in the cube and one on the page. Only path draws a route through the cube: it follows the order of the rows, and an order belongs to no axis. Turning the plot changes how you look at it, never what it claims about the data.
32.13 What comes next
A cube keeps the axes straight and adds one. Polar bends them instead: the same marks on the same data, with x running around a circle and y out from its center.