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