32  Space

What do you do when a third measurement matters as much as the first two? Every plot so far has been drawn on a plane: a mark, an x, a y. The grammar has always held a third position channel, z. Binding it is enough to lift the mark off the page.

32.1 z: one more vowel

Here are three flower measurements. Two of them make an ordinary scatter, and the species already begin to separate, but versicolor and virginica still blur together along the diagonal:

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)
(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))
2 4 6 5 6 7 8 Sepal Length Petal Length Species setosa versicolor virginica

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

Now add the third measurement as z. Nothing else about the sentence changes (the same point, the same x and y, one more vowel), and the plot lifts into space:

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))
6 4 2 8 7 6 5 4 3 2 Petal Length Sepal Length Sepal Width Species setosa versicolor virginica

“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 spatial one with z held constant, so the same rules that place a mark on the page place it in the cube. Every flat plot you have read so far was the special case.

Drawing a 3-D plot on a flat page takes two steps, and neither one is new. 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 points behind them. That overlap is what gives a flat picture its depth.

The faint box behind the cloud of points is a guide rather than part of the data. A bare cloud of points gives 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 stand in, and each of the three axes puts its numbers along one of those edges.

On a screen 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 sits 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 it 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 = -50, tilt = 15)
(data(iris_flowers) + point +
  x(col.petal_length) + y(col.sepal_length) + z(col.sepal_width) + color(col.species) +
  space(turn = -50, tilt = 15))
data(iris_flowers) + point + x(:petal_length) + y(:sepal_length) +
  z(:sepal_width) + color(:species) + space(turn = -50, tilt = 15)
plot(data(iris_flowers), point, x(col.petal_length), y(col.sepal_length),
  z(col.sepal_width), color(col.species), space({ turn: -50, tilt: 15 }))
6 4 2 8 7 6 5 4 3 2 Petal Length Sepal Length Sepal Width Species setosa versicolor virginica

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

Same data, same bindings: turned around and seen from lower down. The choice matters. From this angle the three species separate along z more cleanly than they did from the default view. A different angle again would mix them back together. Turning the plot changes how you look, never what is drawn, which is exactly why it belongs to the coordinate space and not to the grammar of the plot itself. Omit space() and the plot opens at a default angle; use space() when that default hides what you came 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. A positive turn moves you one way around, a negative turn moves you 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 further up. At tilt = 90 you are directly above the cube, looking straight down. The floor fills the picture, and the z axis has nothing left to draw, so even its name 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 tilt = -90, so the mouse reaches every angle 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

Every plot drawn in a cube can be turned with the mouse. 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 line under each plot names the two angles of the view it is showing, 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 sits 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.

Beside it sit the same boxes that every plot in this book has. They mean here what they mean everywhere, and the camera box saves the angle you turned the cube to, because that angle is already drawn into the picture.

The cube differs from a flat plot in two ways, and both follow from one fact: dragging turns the scene rather than moving the picture. So a zoomed cube cannot be dragged to one side, and the boxes do all the looking closer.

And the cube is the one plot with two boxes that undo something. There are two because they undo different things: the frame undoes the zoom, and reset undoes the angle. So zooming in never loses the angle you found, and straightening the angle never costs you your zoom. A flat plot has no angle to return, so it has the frame alone.

This changes nothing about the sentence. space() chooses where a plot opens, and the mouse takes you anywhere else from there. The data, the marks and the scales are the same at every angle. You are changing how you look, never what is drawn.

Dragging needs a web page to run in. On paper, and in the PDF edition, each plot is the still image its sentence asks for. So the angle is still worth naming, even though a reader can move it. The printed figure shows the view you chose, and a reader who can drag starts from it.

32.4 path: the route through the cube

So far the cube has held one mark. A scatter is a cloud: every dot stands alone 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:

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)
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))
200 0 -200 200 0 -200 2500 2000 1500 1000 East North Altitude Glider Alpha Bravo

“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 went round while they went up. The coil 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 whatever the rows did.

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 thread through each other.

path is the only stroke that takes z, and the reason is the one that separates it from line on the flat page. 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 space 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. The cells already cover a floor, and the color represents a quantity. Two shades of one color are hard to compare exactly, and two heights are easy. Bind that quantity to a height instead and the same counts are drawn as standing bars:

gm_all: first 5 of 1704 rows
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))
1K 10K 100K 40 60 80 Life Gdp Count 45.00 23.00 1.00

“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" }))
100K 10K 1K 80 60 40 40 30 20 10 0 Gdp Life Count

“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 invents is the same count. What differs is the mark it was handed to, and therefore where the answer goes. 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.

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 spends an axis on the domain and keeps the other to measure along. zone * bin cuts two, because a zone keeps none. A cube hands the bar a third axis. It can spend two on the floor and still have one to measure along, so the same sentence that cuts one axis on the page cuts two here. Read that way the 3-D histogram is not a feature. It is what the flat rule already said, applied to three axes.

Note also what you did not have to write. There is no z() in that sentence, for the same reason a flat histogram has no y(): the count is invented rather than bound, so naming it would be naming an output.

32.6 Transforms in the cube

That floor was cut from two continuous axes. A floor is made of cells, and the cells come from the two axes. It is the zone chapter’s question one dimension up, with the same answers. 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:

gm_eras: first 5 of 284 rows
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" }))
Oceania Americas Africa Europe Asia 1957 2007 50 40 30 20 10 0 Continent Era Count

“Given the gapminder eras: bars derived by count, x is continent, y is era, in the cube.”

That one counts into slots on both axes: a table of counts, standing up. And 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" }))
80 60 40 Asia Europe Africa Oceania 100 50 0 Life Continent Count

Look at the two floors above, one after the other. On the binned axis the bars touch, and on the categorical axis they leave a gap. 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. The empty fifth of the slot is what says so. A floor cut on one axis and slotted on the other is contiguous one way and gapped the other, because each axis is answering only for itself.

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)` is 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: `bin` cuts a continuous axis into intervals, and `y(era)` is 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.

Both of those transforms invent what they measure: you give count no column to reduce, because it counts rows. The other kind of statistic is the kind you point at a column: 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" }))
Oceania Americas Africa Europe Asia 1957 2007 80 70 60 50 40 Continent Era Life

“Given the gapminder eras: bars derived by mean, x is continent, y is era, z is life.”

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 subtract from.

That is the same subtraction the histogram used, read a second time: the positions the space offers, minus the one the mark measures along. The first reading says how many axes bin cuts. This one says how many columns mean groups by. One rule, two questions, and the answer to both is read off the mark.

The two families differ in what you give them, not in what they can draw. count and bin invent a measurement, so they need no column, and color or height carries the answer. 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 really does stay on the plane, and it is the one that fits rather than reduces:

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)` is 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: `smooth` fits a curve of `y` against `x`, and `y(era)` is 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 five reductions 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.

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 stand 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 stands in a slot and measures along the other axis. A whisker spans a range and a box summarizes one. bar, interval and box ask the same question of the same pair of axes, so all three take the third dimension. Give the pair a cube and each stands on a 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" }))
Oceania Americas Africa Europe Asia 1957 2007 80 60 40 Continent Era Life

“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 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 left over, and a cube leaves two. 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 the interval that carries a center and gog draws the pointrange:

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" }))
Oceania Americas Africa Europe Asia 1957 2007 80 70 60 50 40 Continent Era Life

“Given the gapminder eras: intervals derived by confidence, x is continent, y is era, z is life.”

And the five-number summary stands the same way, its box a solid between the quartiles with the whiskers reaching past it:

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" }))
Oceania Americas Africa Europe Asia 1957 2007 80 60 40 Continent Era Life

“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. A group with a single observation is the exception. Its quartiles coincide, so there is no solid at all, and you see the whole median plane face-on. On the flat page, that same group draws as a line, which is what a box of zero height collapses to.

32.8 z_label(): naming the third axis

With the marks settled, the cube still needs its axes named. The cube’s three axes take their names from the columns bound to them, and each can be overridden. Each of the three positions 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"))
200 0 -200 200 0 -200 2500 2000 1500 1000 Meters east Meters north Altitude (m) Two gliders, one thermal Glider Alpha Bravo

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

Everything above this point draws. What follows is the other half: what the cube will not do, and why. The box is drawn behind the marks, so the points are always in front of it. That is a fixed order rather than a calculation, and it means no point disappears behind the far face of the box. Its labels go on afterwards, over the marks. A guide is an annotation about the scene rather than a member of it, so no mark can cover the numbers that say what it measures.

Turning the plot is what makes a 3-D plot readable at all, and the mouse does it. Working out what hides what, piece by piece, is a separate and harder job. It costs far more work than turning does, and the renderer decides it rather than the grammar. So the box stays behind the marks however far you turn the scene.

A tilt past straight up or straight down is refused, and a turn past a full circle is not. The two angles read differently, which 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 height runs out. At 90 you are directly above the cube and at -90 directly underneath, so there is nothing past either end to look from. Past either end the scene turns over, and an upside-down cube answers no question the sentence asked. Dragging stops at both ends, and writing an angle stops there too.

turn is which side you stand on, and you can keep walking. 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 }))
200 0 -200 200 0 -200 2500 2000 1500 1000 East North Altitude

The same picture as space(turn = 30), to the byte. Two angles, two rules, and the difference is in what they measure rather than in a preference.

A mark the cube does not draw has three possible reasons, and the engine says which one you have.

Some 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, for the reason path gave above, so a line in space would be sorted by an axis you cannot see:

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.

That is a rule rather than a gap, and the refusal says so: the route through three dimensions is path, which is line with exactly that sort removed. Two marks that differ by one rule on the page turn out to differ by a whole dimension in the cube.

32.10 What the cube blocks

A mark can also be blocked, which is a different reason. A rule marks a value on one axis and spans the ones it does not name: in a cube that is two axes, so a rule here is a plane:

data(thermals) + rule + x(east) + z(altitude) + space()
data(thermals) + rule + x(col.east) + z(col.altitude) + space()
data(thermals) + rule + x(:east) + z(:altitude) + space()
plot(data(thermals), rule, x(col.east), z(col.altitude), space())
Error:
! gog: `rule` marks a value on one axis and spans the ones it does not name — in a cube that is *two* axes, so a rule here is a **plane**. Marks in space are sorted by their footprint, and a plane's footprint is 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. Drawing it would mean working out, piece by piece, what hides what, and the engine cannot do that yet. Draw it flat, or mark the threshold on a floor axis with `bar`/`point` at `z(altitude)`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

Marks in space are painted far-to-near and sorted by the area they cover, and a plane covers the whole floor. It could only be drawn wholly in front of or wholly behind the data, but cutting through the data is the whole job of a reference plane. A zone is the same problem one dimension wider. Neither is drawn, because the engine places each mark by its whole footprint rather than piece by piece.

The z axis is linear, so a log depth is refused and the message names what to do instead. In every case the engine tells you what it will not do, because a plot that silently drops a dimension is the one failure this project refuses above all others.

32.11 Faceting: one cube per panel

A cube of 142 countries mixes five continents together. The question is how one continent’s cloud differs 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:

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) + 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))
50K 20K 80 70 50 1000M 500M 0M Gdp Life Population 50K 20K 80 70 50 1000M 500M 0M Gdp Life Population 50K 20K 80 70 50 1000M 500M 0M Gdp Life Population 50K 20K 80 70 50 1000M 500M 0M Gdp Life Population 50K 20K 80 70 50 1000M 500M 0M Gdp Life Population Asia Europe Africa Americas Oceania Continent Asia Europe Africa Americas Oceania

“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 are the same cube five times and a height in one is a height in another. 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 cloud:

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))
200 0 -200 200 0 -200 2500 2000 1500 1000 East North Altitude 200 0 -200 200 0 -200 2500 2000 1500 1000 East North Altitude Alpha Bravo Glider Alpha Bravo

The guides are the one thing that needs explaining. Each cube draws its own tick numbers and its own three axis names. 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 already works this way, with a category ring inside every panel. A cube’s three axes are edges of the cube, and the cube sits 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.

A second kind of split lays the panels out in time instead of across the page. play is that chapter, and it is where the two are combined.

Which edges carry the numbers is chosen per viewing angle, and the choice follows a rule. The cube offers four parallel edges per axis. The two domains take the floor edges nearest you, the pair that makes the V at the bottom of the picture, and the measurement takes whichever vertical edge sits on the cube’s outline. All three lie outside the data, so nothing drawn inside the cube can be in front of them. Look at the left-hand edge of the tilted-up sheet in Surface: you can still read heights off it while the mesh fills the rest of the picture. The labels are then measured against each other before any of them is drawn. So two axes cannot print their numbers in the same place where their edges meet, and an axis name cannot land on its own last tick.

One case is left: an axis that projects very short. Tilt the eye up and the measure axis is drawn shorter, until its numbers have nowhere to sit. The same summary at two tilts, and the second one 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 three-quarter 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 three-quarter 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 three-quarter 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)
Oceania Americas Africa Europe Asia 1957 2007 80 70 60 50 40 Continent Era Mean life expectancy The three-quarter view Oceania Americas Africa Europe Asia 1957 2007 80 40 Continent Era Mean life expectancy Tilted up, with fewer numbers

“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 labels and never changes the scale. The other way to use the space is a coarser tick step, and it is the wrong way. The step sets where the axis stops, so a bigger step raises the top of the axis and leaves the cube half empty. The numbers that survive are always a subset of the ones the left-hand plot shows, at exactly the spacing it uses, so a height read off either panel is the same height.

One more case, and it is about layers rather than marks. 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 cloud layered over a path sits in front of the whole route rather than threading through it. That is the ordinary layering rule applied in space, not a new exception, so read a layered 3-D plot as layers, not as one depth-sorted scene.