| east | north | elevation |
|---|---|---|
| 20 | 20 | 100 |
| 20 | 40 | 102 |
| 20 | 60 | 104 |
| 20 | 80 | 105 |
| 20 | 100 | 107 |
20 Surface
What shape does a landscape have, sampled on a grid? surface draws a sheet through your samples. It is the only mark that can be drawn in the cube by itself. Every other mark draws in the plane, and several of them also draw in space. A surface is a sheet through three positions, and there is no such thing without the third.
It is not area with a z. An area fills the space between a curve and a baseline. A surface has no baseline. Both are fills, and that is all they share.
20.1 A sheet through the samples
A grid of elevations is a table of numbers, 1364 of them here. No reader can see a hill in a column of numbers, and drawn as a sheet the hill appears. Here is Maunga Whau, a volcano in Auckland. The book’s copy keeps every second row and column, a 20 m grid. Ross Ihaka digitized the elevations from a topographic map, and they reach this page through R’s datasets package (R Core Team, 2026):
data(maunga_whau) + surface +
x(east) + y(north) + z(elevation) +
z_label("Elevation (m)") + title("Maunga Whau, at 20 m")(data(maunga_whau) + surface +
x(col.east) + y(col.north) + z(col.elevation) +
z_label("Elevation (m)") + title("Maunga Whau, at 20 m"))data(maunga_whau) + surface + x(:east) + y(:north) + z(:elevation) +
z_label("Elevation (m)") + title("Maunga Whau, at 20 m")plot(data(maunga_whau), surface, x(col.east), y(col.north),
z(col.elevation), z_label("Elevation (m)"),
title("Maunga Whau, at 20 m"))“Given Maunga Whau: a surface, x is east, y is north, z is elevation.”
Three positions and a mark. There is no space() in that sentence and none is needed: binding z is what puts a plot in the cube, exactly as it is for a 3-D scatter. space(turn =, tilt =) still sets the angle when you want a different one.
20.2 One row per crossing
What shape must a table have before a surface can draw it? A scatter’s table does not have it, and the reason decides which of your tables can be a sheet. Given two position columns and no transform, a surface reads its rows as nodes, and the sheet is the faces between them. You never declare which nodes are neighbors. gog recovers that from your two position columns: the distinct east values become the mesh’s vertical grid lines and the distinct north values its horizontal ones. A face is drawn wherever all four corners of a cell are present.
So the table this reading wants is one row per (x, y) crossing: every x value paired with every y value.
head(maunga_whau, 4) east north elevation
1 20 20 100
2 20 40 102
3 20 60 104
4 20 80 105
nrow(maunga_whau)[1] 1364
1364 rows, 31 distinct east values and 44 distinct north values: 31 × 44 = 1364, so the grid is full, and 30 × 43 = 1290 faces get drawn. That arithmetic is the whole rule, and it is why a scatter cannot be a surface. Each of the 142 countries has its own gdp and its own life. That makes a grid 142 wide and 142 deep holding only 142 nodes, so no cell has all four corners. The sheet would be empty:
| 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) + surface + x(gdp) + y(life) + z(population)data(gapminder_2007) + surface + x(col.gdp) + y(col.life) + z(col.population)data(gapminder_2007) + surface + x(:gdp) + y(:life) + z(:population)plot(data(gapminder_2007), surface, x(col.gdp), y(col.life),
z(col.population))Error:
! gog: `surface` found no complete cell to draw a face on — 142 rows over 142 distinct `gdp` and 142 distinct `life` values, which is a scatter rather than a grid. A surface needs one row per (x, y) crossing, the shape `expand.grid()` makes. For a cloud of scattered points use `point` with `z(population)`; to estimate a field *from* them use `surface * density + x(gdp) + y(life) + space()`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
That refusal names the two sentences that do draw. The section on the third geometry of one field draws the second one.
Because a face needs all four of its corners, a gap in the grid is an opening in the sheet rather than a face drawn across it. Drop the crossings around the summit and the sheet opens there rather than covering the gap:
peak <- maunga_whau[!(maunga_whau$east > 300 & maunga_whau$east < 440 &
maunga_whau$north > 400 & maunga_whau$north < 560), ]
data(peak) + surface + x(east) + y(north) + z(elevation) +
z_label("Elevation (m)") + title("A gap in the grid, left open")gog: `surface` has 1322 of 1364 crossings filled, so the sheet is drawn with 42 open — a face needs all four of its corners. That is a true picture of a grid with gaps; if you expected a whole sheet, check that `east` and `north` repeat exactly across rows rather than to a rounded value.
The note above the plot counts the crossings gog did find: 1322 of 1364, so 42 are open. It is advice rather than a refusal, because a grid with gaps is a legitimate thing to have and a torn sheet is a true picture of one. What would not be legitimate is quietly spanning a gap, which asserts a height nobody measured. The note also tells you when the gaps were an accident. If the two position columns hold rounded values instead of exact repeats, the lattice fills with holes nobody designed.
20.3 A continuous floor
A continent in one column and a year in another looks like a grid too. A sheet over it is a natural thing to ask for. Both position columns have to be numbers. A face spans the gap between two samples and so asserts every height inside that gap, and between one category and the next there is no inside to assert anything about. So a category on either position is refused, and What it refuses names the mark that draws one.
That mark is the 3-D bar. A category owns a slot with air on either side of it, and a 3-D bar stands in its slot, claiming nothing about the space between cells. So bar * count + x(a) + y(b) + space() stands one bar over each pair of categories, and a surface cannot: tiles that float apart are not a sheet.
Numbers cut into cells are a different matter, and a surface takes those: the section on one value per cell, below. So the two marks differ over the slot, not over the cut. That is the same distinction the flat marks make between a category and a cut number, one dimension higher.
20.4 The third geometry of one field
Which of the two iris clusters is denser, and by how much? The heatmap and the contour show where each cluster is, and leave its height to a legend or a level. A surface takes two transforms, and density is the one that makes it the third way gog draws a field. The same estimate over the same two columns, three times:
| 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) + zone * density +
x(petal_length) + y(sepal_length) + title("As cells: zone")(data(iris_flowers) + zone * density +
x(col.petal_length) + y(col.sepal_length) + title("As cells: zone"))data(iris_flowers) + zone * density + x(:petal_length) +
y(:sepal_length) + title("As cells: zone")plot(data(iris_flowers), layer(zone, density), x(col.petal_length),
y(col.sepal_length), title("As cells: zone"))“Given the iris flowers: zones derived by density, x is petal length, y is sepal length.”
data(iris_flowers) + path * density +
x(petal_length) + y(sepal_length) + title("As contours: path")(data(iris_flowers) + path * density +
x(col.petal_length) + y(col.sepal_length) + title("As contours: path"))data(iris_flowers) + path * density + x(:petal_length) +
y(:sepal_length) + title("As contours: path")plot(data(iris_flowers), layer(path, density), x(col.petal_length),
y(col.sepal_length), title("As contours: path"))“Given the iris flowers: paths derived by density, x is petal length, y is sepal length.”
data(iris_flowers) + surface * density +
x(petal_length) + y(sepal_length) + space() +
title("As a sheet: surface")(data(iris_flowers) + surface * density +
x(col.petal_length) + y(col.sepal_length) + space() +
title("As a sheet: surface"))data(iris_flowers) + surface * density + x(:petal_length) +
y(:sepal_length) + space() + title("As a sheet: surface")plot(data(iris_flowers), layer(surface, density), x(col.petal_length),
y(col.sepal_length), space(), title("As a sheet: surface"))“Given the iris flowers: a surface derived by density, x is petal length, y is sepal length, in the cube.”
Look at what the third one shows that the other two cannot: how much higher the tight cluster is than the broad one. Cells and contours show where the field is, and a height shows how much. The two clusters look equally large in the first two plots and very unequal in the third. Which of the three you want depends on what you are asking, not on which mark is better.
One transform, three marks, and the estimate itself is the same in all three. What differs is where the measurement goes: each mark puts it where its own geometry measures. A zone measures by color. A path measures by the level the contour was cut at. A surface measures by height. That is why the third sentence never binds z() and still gets a third axis, labeled Density: the transform supplies the measurement, and the mark puts it on the height. A flat bar * bin does the same for y.
Here the transform stays the same and the mark chooses the geometry. That is the rule that already made the contour and the estimated heatmap one feature instead of two.
space() is in that sentence and not in the volcano’s, and the reason matters. A bound z says there are three columns here, which nothing else can mean. An invented height says only this layer has a measure of its own. density invents one in the plane too, so space() is what tells gog to put the answer on a third axis.
20.5 When each cell holds one value
Does the ground rise smoothly between your samples, or does each cell hold one value that says nothing about its neighbors? bin is the other transform, and it draws a different sheet: flat across each cell, stepping at the boundary. Here is the same volcano, read as a coarse grid rather than a fine one:
data(maunga_whau) + surface * bin(12) * mean +
x(east) + y(north) + z(elevation) +
z_label("Elevation (m)") + title("Maunga Whau, one elevation per cell")(data(maunga_whau) + surface * bin(12) * mean +
x(col.east) + y(col.north) + z(col.elevation) +
z_label("Elevation (m)") + title("Maunga Whau, one elevation per cell"))data(maunga_whau) + surface * bin(12) * mean + x(:east) + y(:north) +
z(:elevation) + z_label("Elevation (m)") +
title("Maunga Whau, one elevation per cell")plot(data(maunga_whau), layer(surface, bin(12), mean), x(col.east),
y(col.north), z(col.elevation), z_label("Elevation (m)"),
title("Maunga Whau, one elevation per cell"))“Given Maunga Whau: a surface derived by bin into 12 and mean, x is east, y is north, z is elevation.”
Nothing about the mountain changed. What changed is the claim. The sheet at the top of this chapter says the ground rises smoothly between its samples; this one says each cell has a single elevation and nothing is asserted about the ground inside it. Both are honest about different data, and which you want is a question about how yours was measured.
That question has a clear answer whenever a design produces exactly one value per cell, which is the ordinary case in an experiment or a simulation: a grid of conditions, one number in each. Read as nodes, such a table draws a sheet smaller than the grid it describes. Nine values on a 3 × 3 grid make 2 × 2 blocks of four corners, so the sheet is four faces, and it stops at the outer samples instead of covering their cells:
cells <- expand.grid(a = c(-2, 0, 2), b = c(-2, 0, 2))
cells$v <- 0.019 + 0.0025 * (cells$a^2 + cells$b^2)
data(cells) + surface + x(a) + y(b) + z(v) + title("Nine values, four faces")Nine numbers became four faces, and each face is a sheet stretched over the four corners around it. Cut the x-y plane into cells instead, and each cell gets a flat top of its own, a plateau:
data(cells) + surface * bin(3) * mean + x(a) + y(b) + z(v) +
color(v) + palette("plasma") + title("Nine values, nine plateaus")(data(cells) + surface * bin(3) * mean + x(col.a) + y(col.b) + z(col.v) +
color(col.v) + palette("plasma") + title("Nine values, nine plateaus"))data(cells) + surface * bin(3) * mean + x(:a) + y(:b) + z(:v) +
color(:v) + palette("plasma") + title("Nine values, nine plateaus")plot(data(cells), layer(surface, bin(3), mean), x(col.a), y(col.b),
z(col.v), color(col.v), palette("plasma"),
title("Nine values, nine plateaus"))“Given the cells: a surface derived by bin into 3 and mean, x is a, y is b, z is v, color by v, with the plasma palette.”
Nine plateaus, and three colors. That formula makes a symmetric bowl, so the four corner cells share one height and the four edge cells share another. The ramp is what makes that symmetry visible, where the sheet above left it to be read off the axis.
bin cuts the x and y axes into cells, and mean averages the column named on z inside each one. That is the same sentence a 3-D bar uses. The transforms mean the same thing on both marks, and only the geometry laid on the cell differs. Where a table holds one row per cell, as this one does, the mean of a single value is that value, and the transform is doing nothing but naming the column.
The step between two plateaus is drawn, as a riser standing on the line the two cells share. That line has no width, so the riser asserts nothing about any area: it is simply what a jump looks like. Without it the mark would draw disconnected tiles floating at their own heights, and a reader could not tell a step from a missing cell.
A riser spans the difference between two neighbors, and that is what separates this from standing a 3-D bar on every cell. A bar runs the whole way down to the baseline, so a field of bars hides its own back rows as soon as you turn it. A riser is only as tall as its own step. Both are turned by 60 degrees below, so you can see which one is still legible. Both are set to one color, so the only thing that differs between them is the shape:
((data(cells) + bar * bin(3) * mean + x(a) + y(b) + z(v) +
space(turn = 60) + style(color = "#76b7b2") + title("Bars, turned")) |
(data(cells) + surface * bin(3) * mean + x(a) + y(b) + z(v) +
space(turn = 60) + style(color = "#76b7b2") + title("Plateaus, turned"))) +
theme(height = 430)(((data(cells) + bar * bin(3) * mean + x(col.a) + y(col.b) + z(col.v) +
space(turn = 60) + style(color = "#76b7b2") + title("Bars, turned")) |
(data(cells) + surface * bin(3) * mean + x(col.a) + y(col.b) + z(col.v) +
space(turn = 60) + style(color = "#76b7b2") + title("Plateaus, turned"))) +
theme(height = 430))((data(cells) + bar * bin(3) * mean + x(:a) + y(:b) + z(:v) +
space(turn = 60) + style(color = "#76b7b2") + title("Bars, turned")) |
(data(cells) + surface * bin(3) * mean + x(:a) + y(:b) + z(:v) +
space(turn = 60) + style(color = "#76b7b2") +
title("Plateaus, turned"))) + theme(height = 430)“Given the cells: bars derived by bin into 3 and mean, x is a, y is b, z is v, beside a surface derived by bin into 3 and mean, x is a, y is b, z is v.”
| composes them (Composition), so the two are one figure and turn together. That is what makes this a fair comparison, rather than two pictures the reader has to match angle by angle.
The theme(height = ) above sets the whole figure’s height in pixels, and two cubes side by side are why it is set. They share the page’s width, and each one keeps the full height. A cube is fitted with one scale on both axes, so it never stretches to fill a tall panel. Without the setting, blank space would be left above and below each cube.
Empty cells need no special rule and get none. bin leaves them out, because a cell with no rows is not a measurement of zero. A plateau claims only its own cell, so a cell with nothing in it is simply a cell with no top. It is the same opening a missing crossing makes in a node mesh, for a different reason.
What a surface refuses is a grid of slots. count and proportion count rows into the cells two categories make, and categories have gaps between them. What it refuses shows that refusal.
20.6 The mesh, and what the shading means
A smooth sheet hides where its samples were, the way a line hides its rows. Drawing the mesh puts the grid back, so a reader can see how fine the sampling was. border_color and border_size draw the mesh lines. A face is a closed fill like a bar’s or a box’s, so the settable rule gives it a border. On a mesh those borders draw the grid itself:
data(maunga_whau) + surface +
x(east) + y(north) + z(elevation) +
style(border_color = "white", border_size = 0.4) +
z_label("Elevation (m)") + title("The mesh, drawn")(data(maunga_whau) + surface +
x(col.east) + y(col.north) + z(col.elevation) +
style(border_color = "white", border_size = 0.4) +
z_label("Elevation (m)") + title("The mesh, drawn"))data(maunga_whau) + surface + x(:east) + y(:north) + z(:elevation) +
style(border_color = "white", border_size = 0.4) +
z_label("Elevation (m)") + title("The mesh, drawn")plot(data(maunga_whau), surface, x(col.east), y(col.north),
z(col.elevation), style({ border_color: "white", border_size: 0.4 }),
z_label("Elevation (m)"), title("The mesh, drawn"))“Given Maunga Whau: a surface, x is east, y is north, z is elevation, with border color white and border size 0.4.”
The mesh adds no shapes to the picture, because every face is already stroked in its own shade. That thin stroke covers the pale seam that would otherwise show between neighboring faces. border_color replaces the stroke’s color. Set border_size = 0 for a seamless sheet.
Notice what the shading is doing in both plots, and what it is not. The faces are shaded by their slope: a steep one is darker, a level one keeps its full color. That is a fact about the terrain, not about where a light is, so turning the scene rearranges the faces on the page and repaints none of them:
data(maunga_whau) + surface +
x(east) + y(north) + z(elevation) +
space(turn = 130, tilt = 35) +
z_label("Elevation (m)") + title("Turned, not relit")(data(maunga_whau) + surface +
x(col.east) + y(col.north) + z(col.elevation) +
space(turn = 130, tilt = 35) +
z_label("Elevation (m)") + title("Turned, not relit"))data(maunga_whau) + surface + x(:east) + y(:north) + z(:elevation) +
space(turn = 130, tilt = 35) + z_label("Elevation (m)") +
title("Turned, not relit")plot(data(maunga_whau), surface, x(col.east), y(col.north),
z(col.elevation), space({ turn: 130, tilt: 35 }),
z_label("Elevation (m)"), title("Turned, not relit"))“Given Maunga Whau: a surface, x is east, y is north, z is elevation, turned 130, tilted 35.”
Compare the crater walls here with the first plot. Same faces, same shades, seen from the other side. A lamp fixed to the page would have repainted every one of them, and a color that changed while the data did not would mislead a reader with no way to notice.
20.7 Color by height, per face
How high is the summit, and how deep is the crater? color maps on a surface, and a measured column colors it face by face along a ramp. palette() chooses which ramp. "viridis" suits a topographic sheet, because it gets lighter the whole way, so you can judge the height from the color even where a face is turned away:
data(maunga_whau) + surface +
x(east) + y(north) + z(elevation) + color(elevation) +
palette("viridis") +
z_label("Elevation (m)") + title("Height twice: as a position and as a ramp")(data(maunga_whau) + surface +
x(col.east) + y(col.north) + z(col.elevation) + color(col.elevation) +
palette("viridis") +
z_label("Elevation (m)") + title("Height twice: as a position and as a ramp"))data(maunga_whau) + surface + x(:east) + y(:north) + z(:elevation) +
color(:elevation) + palette("viridis") + z_label("Elevation (m)") +
title("Height twice: as a position and as a ramp")plot(data(maunga_whau), surface, x(col.east), y(col.north),
z(col.elevation), color(col.elevation), palette("viridis"),
z_label("Elevation (m)"),
title("Height twice: as a position and as a ramp"))Compare that with the first plot in this chapter. The summit and the crater rim are in that plot too. With slope shading alone you have to work them out from which faces are dark. The ramp states the heights, and the legend decodes them.
Mapping the height to color when it is already the height is redundant on purpose, and it is what a reader of a topographic map expects, so the ramp is worth drawing. It also marks a real boundary. An area refuses a measured color, because a region has one interior and coloring an interior by a measure is a gradient fill, which is a different and much larger job. A mesh has faces, and a face is already small enough to hold one value. So the rule is not about regions and strokes. It is whether a mark has parts small enough to each hold one value, and a stroke has segments where a surface has faces.
A face has four corners and one color. What it reads is the field at its center, which is the mean of those four, so the ramp describes the mesh you have and not only the field you sampled from. Sample the same bowl twice and see where the shape appears:
bowl <- function(n) {
g <- expand.grid(a = seq(-2, 2, length.out = n), b = seq(-2, 2, length.out = n))
g$v <- 0.019 + 0.0025 * (g$a^2 + g$b^2)
g
}
coarse <- bowl(3)
fine <- bowl(7)
((data(coarse, name = "coarse") + surface + x(a) + y(b) + z(v) +
color(v) + palette("plasma") + title("Sampled 3 by 3: four faces")) |
(data(fine, name = "fine") + surface + x(a) + y(b) + z(v) +
color(v) + palette("plasma") + title("Sampled 7 by 7: thirty-six faces"))) +
theme(height = 320)“Given coarse: a surface, x is a, y is b, z is v, color by v, with the plasma palette, beside the same given fine.”
Two tables on one page, so each is named. A layer finds its columns in the nearest table by that name, and two tables with the same name would leave the second one unreachable (Data).
The left sheet is one flat color, and that is honest rather than broken. Every face has one high corner, one low corner and two middle ones, so all four average to the same number. The mesh is too coarse to hold the shape, and the ramp says so instead of inventing a gradient across it. The right sheet has the same field under it and enough faces to resolve it, so the bowl appears.
This is the reading that makes the legend trustworthy. A color is the height at one named place on the sheet, so two faces of one color really are at one height, and a sheet of one color really is flat on average.
The slope shading is still underneath the ramp, and the two combine rather than cancel: a face takes its color from the ramp and is then darkened by how steep it is. Viridis stays readable because only the low ground reaches its dark colors, and on a terrain the low ground is also the flattest. The two darkenings therefore do not add together, and the darkest face on this sheet is lighter than a fully-shaded viridis floor would have been.
An estimated height takes a ramp the same way, and you name it the same way. density writes its estimate into a column called density, so that is the word color takes, exactly as it does on a zone or a path:
data(iris_flowers) + surface * density +
x(petal_length) + y(sepal_length) + space() + color(density) +
palette("viridis") + title("The estimate, as a height and as a ramp")(data(iris_flowers) + surface * density +
x(col.petal_length) + y(col.sepal_length) + space() + color(col.density) +
palette("viridis") + title("The estimate, as a height and as a ramp"))data(iris_flowers) + surface * density + x(:petal_length) +
y(:sepal_length) + space() + color(:density) + palette("viridis") +
title("The estimate, as a height and as a ramp")plot(data(iris_flowers), layer(surface, density), x(col.petal_length),
y(col.sepal_length), space(), color(col.density), palette("viridis"),
title("The estimate, as a height and as a ramp"))Nothing there is a special case. The transform makes a column, the column has a name, and a name can be bound. Whether the height came from your table or from an estimate changes where the number came from. It does not change what you may say about it.
Whether you want the color as well as the height is a separate question, and the plain sheet earlier in this chapter is the one that makes the argument about height. Use the ramp when the low ground matters: slope shading darkens a steep face and leaves a level one alone, so a broad, shallow mound and the flat floor around it are the same color, and the ramp is what separates them.
A categorical color splits the mark instead, one sheet per group. gog sorts every face of both sheets by depth together, so the two sheets cross rather than one sitting wholly in front:
band <- maunga_whau
band$side <- ifelse(band$north > 440, "north slope", "south slope")
data(band) + surface + x(east) + y(north) + z(elevation) + color(side) +
z_label("Elevation (m)") + title("Two sheets, sorted together")20.8 Opacity by a measure, per face
Part of a sheet is often not the subject. The flat ground around a hill only hides what is behind it, and fading those faces leaves the hill. opacity maps on a surface, and it maps for the reason color does. A face is small enough to hold one value, so it can hold an opacity too. Every face takes its own opacity, and a face fades where its measure is small:
data(maunga_whau) + surface +
x(east) + y(north) + z(elevation) + opacity(elevation) +
z_label("Elevation (m)") + title("Faint where the ground is low")(data(maunga_whau) + surface +
x(col.east) + y(col.north) + z(col.elevation) + opacity(col.elevation) +
z_label("Elevation (m)") + title("Faint where the ground is low"))data(maunga_whau) + surface + x(:east) + y(:north) + z(:elevation) +
opacity(:elevation) + z_label("Elevation (m)") +
title("Faint where the ground is low")plot(data(maunga_whau), surface, x(col.east), y(col.north),
z(col.elevation), opacity(col.elevation), z_label("Elevation (m)"),
title("Faint where the ground is low"))“Given Maunga Whau: a surface, x is east, y is north, z is elevation, opacity by elevation.”
Over the low ground the fill fades to almost nothing and each face keeps its outline, so that part of the sheet shows as mesh lines alone. A ramp cannot do that; an opacity can. A color changes what a face says. An opacity changes whether the face hides what is behind it. The legend beside the plot reads the same way color’s does, with the smallest, middle and largest values of the column.
No face disappears completely. A mapped opacity runs from 0.15 to 0.95, so the faintest face here is still drawn, which is the rule every mapped opacity follows (Channels).
A face reads its opacity where it reads its color, at its own center, which is the mean of its four corners. A plateau and a riser each own one cell, so each reads its own row. Either way one face has one reading, whichever channel asks for it.
In the plot above, the height is said twice: once as a position and once as an opacity. A second column is the more useful case: the shape stays in z, and another measure decides which faces fade.
relief <- maunga_whau
h <- matrix(relief$elevation, nrow = length(unique(relief$north)))
up <- rbind(h[-1, ], h[nrow(h), ])
side <- cbind(h[, -1], h[, ncol(h)])
relief$steepness <- as.vector(sqrt((up - h)^2 + (side - h)^2))
data(relief) + surface + x(east) + y(north) + z(elevation) + opacity(steepness) +
z_label("Elevation (m)") + title("Solid where the ground is steep")steepness combines the two elevation changes at each crossing, one toward the next east value and one toward the next north value. The steep sides of the hill stay solid, and the flat ground around them fades away. The sheet is the same shape as the one above it, and a different part of it is solid.
An area refuses a mapped opacity for the reason it refuses a measured color. A region has one interior and one fill, so a row there is a corner of the outline rather than a part that can carry a value. A mesh has faces, so it has parts. Both channels ask the same question of a mark, so a mark that refuses one refuses the other.
One value for the whole sheet is still style(opacity = ). Set it when you want to see one sheet through another, and map it when you want each face to fade by its own value.
20.9 One sheet, four angles
A still 3-D plot hides something at every angle, and which thing it hides is the angle’s choice. So it is worth turning the same sheet rather than trusting one view of it. Each of these changes exactly one of turn/tilt from the first:
((data(maunga_whau) + surface + x(east) + y(north) + z(elevation) +
color(elevation) + palette("viridis") + space(turn = 30, tilt = 25) +
z_label("Elevation (m)") + title("The reference three-quarter view")) |
(data(maunga_whau) + surface + x(east) + y(north) + z(elevation) +
color(elevation) + palette("viridis") + space(turn = 140, tilt = 25) +
z_label("Elevation (m)") + title("Turned: the same sheet from the west"))) /
((data(maunga_whau) + surface + x(east) + y(north) + z(elevation) +
color(elevation) + palette("viridis") + space(turn = 30, tilt = 70) +
z_label("Elevation (m)") + title("Tilted up: the crater appears")) |
(data(maunga_whau) + surface + x(east) + y(north) + z(elevation) +
color(elevation) + palette("viridis") + space(turn = 30, tilt = 5) +
z_label("Elevation (m)") + title("Tilted down: the skyline")))(((data(maunga_whau) + surface + x(col.east) + y(col.north) + z(col.elevation) +
color(col.elevation) + palette("viridis") + space(turn = 30, tilt = 25) +
z_label("Elevation (m)") + title("The reference three-quarter view")) |
(data(maunga_whau) + surface + x(col.east) + y(col.north) + z(col.elevation) +
color(col.elevation) + palette("viridis") + space(turn = 140, tilt = 25) +
z_label("Elevation (m)") + title("Turned: the same sheet from the west"))) /
((data(maunga_whau) + surface + x(col.east) + y(col.north) + z(col.elevation) +
color(col.elevation) + palette("viridis") + space(turn = 30, tilt = 70) +
z_label("Elevation (m)") + title("Tilted up: the crater appears")) |
(data(maunga_whau) + surface + x(col.east) + y(col.north) + z(col.elevation) +
color(col.elevation) + palette("viridis") + space(turn = 30, tilt = 5) +
z_label("Elevation (m)") + title("Tilted down: the skyline"))))((data(maunga_whau) + surface + x(:east) + y(:north) + z(:elevation) +
color(:elevation) + palette("viridis") + space(turn = 30, tilt = 25) +
z_label("Elevation (m)") + title("The reference three-quarter view")) |
(data(maunga_whau) + surface + x(:east) + y(:north) + z(:elevation) +
color(:elevation) + palette("viridis") + space(turn = 140, tilt = 25) +
z_label("Elevation (m)") +
title("Turned: the same sheet from the west"))) /
((data(maunga_whau) + surface + x(:east) + y(:north) + z(:elevation) +
color(:elevation) + palette("viridis") + space(turn = 30, tilt = 70) +
z_label("Elevation (m)") + title("Tilted up: the crater appears")) |
(data(maunga_whau) + surface + x(:east) + y(:north) + z(:elevation) +
color(:elevation) + palette("viridis") + space(turn = 30, tilt = 5) +
z_label("Elevation (m)") + title("Tilted down: the skyline")))below(beside(plot(data(maunga_whau), surface, x(col.east), y(col.north),
z(col.elevation), color(col.elevation), palette("viridis"),
space({ turn: 30, tilt: 25 }), z_label("Elevation (m)"),
title("The reference three-quarter view")),
plot(data(maunga_whau), surface, x(col.east), y(col.north),
z(col.elevation), color(col.elevation), palette("viridis"),
space({ turn: 140, tilt: 25 }), z_label("Elevation (m)"),
title("Turned: the same sheet from the west"))),
beside(plot(data(maunga_whau), surface, x(col.east), y(col.north),
z(col.elevation), color(col.elevation), palette("viridis"),
space({ turn: 30, tilt: 70 }), z_label("Elevation (m)"),
title("Tilted up: the crater appears")),
plot(data(maunga_whau), surface, x(col.east), y(col.north),
z(col.elevation), color(col.elevation), palette("viridis"),
space({ turn: 30, tilt: 5 }), z_label("Elevation (m)"),
title("Tilted down: the skyline"))))“Given Maunga Whau: a surface, x is east, y is north, z is elevation, color by elevation, with the viridis palette, turned 30, tilted 25, beside the same turned 140; above the same tilted 70, beside the same tilted 5.”
Read them as a set and you can see what each angle hides. The crater is the most interesting thing about this hill, and the first two plots do not show it. The summit stands in front of the hollow, so no amount of turning at a low tilt reveals it. Tilt up and the hollow opens, because you are now looking down on the hill from above. The same tilt flattens the heights, until the sheet is a colored map of itself. Tilt down instead and you get the opposite: the side view is exact, the view from above is gone, and the sheet shows as an outline against the sky. One or the other, never both.
That is the argument for a plot you can turn, made rather than asserted. Here you need both the view from above and the view from the side, and no single still view shows this hill whole. So turn the four with the mouse. Lift your eye until the crater opens, and watch the heights flatten as it does. The four still views make the argument; dragging them is how you check it.
All four turn together, because | and / composed them into one figure, and a composition turns as one. Each panel keeps the angle its own sentence asked for, and the drag adds the same change to every one. They turn as a set, and reset returns each to where it began. To turn one alone, write it alone.
That is composition rather than faceting, and the two share the same pair of operators. A facet names a column (Faceting), and a viewing angle is not a column: it is a property of the coordinate space, the way a polar() start angle is. gog does build small multiples of cubes, and these four panels are not one (Space has that example).
One limit shows in these four plots, and it belongs to the cube rather than to the mark. Notice the vertical axis in the tilted-up panel. Its numbers sit tight against each other, because that axis is projecting at about a third of its length and they are still all being drawn. They do clear each other, and tilting further makes gog drop some numbers rather than overlap them. Numbers packed close together are simply what a foreshortened axis looks like. Space says which numbers a crowded axis drops, and why the scale itself never changes.
20.10 What a surface is for
Anything that is a height over a plane. Terrain, and the sea floor. A response surface from a designed experiment. A model’s fit measured over two of its parameters. A model’s predictions over a grid of two predictors. And, through density, the shape of a two-dimensional distribution.
What it is not for is data that merely has three numbers per row. That is a 3-D scatter, and the refusal above names it for you. The question to ask is whether the space between two of your samples means anything. On a grid it does; in a cloud it does not.
20.11 What you can set
No column says whether the mesh lines show, or how far you can see through a sheet. A setting decides that without reading one, and each mark takes its own. These are a surface’s, generated from the engine’s own legality table, so this page cannot differ from what style() accepts:
| Setting | Value |
|---|---|
style(color = ) |
any CSS color name or hex |
style(opacity = ) |
0 to 1 |
style(border_color = ) |
any CSS color name or hex |
style(border_size = ) |
pixels |
A surface is a fill, so it takes a fill’s settings, with two absences the next section explains: no size, and no pattern.
The grid of every mark and every setting shows which other marks share this list. What a mark maps rather than sets is its row on the companion grid.
20.12 What it refuses
The flat marks take no z, so a surface with only x and y is the first sentence to try. A surface with no height is the same mistake as a surface in the plane, so it is one refusal rather than two, and it names both routes into the cube along with the mark that draws a field in the plane:
data(maunga_whau) + surface + x(east) + y(north)data(maunga_whau) + surface + x(col.east) + y(col.north)data(maunga_whau) + surface + x(:east) + y(:north)plot(data(maunga_whau), surface, x(col.east), y(col.north))Error:
! gog: a `surface` is a sheet through three positions, so it needs the cube. Bind the height — `surface + x(a) + y(b) + z(h)` — or let a transform invent it: `surface * density + x(a) + y(b) + space()`. For the same field drawn in the plane, `zone` paints it as cells and `path` traces its contours.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
A category on either position is the second refusal, for the reason A continuous floor gave. A face spans the gap between two samples, and between two categories there is no gap to span:
| 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) + surface + x(continent) + y(year) + z(life)data(gm_eras) + surface + x(col.continent) + y(col.year) + z(col.life)data(gm_eras) + surface + x(:continent) + y(:year) + z(:life)plot(data(gm_eras), surface, x(col.continent), y(col.year), z(col.life))Error:
! gog: `x(continent)` maps a categorical (text) column, but `x` on `surface` needs a continuous (numeric) column. A face spans the gap between two samples, and between two categories there is nothing to span. For a mesh over categories use `bar * count + x(<a>) + y(<b>) + space()` — a column stands in its own cell and claims nothing in between.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
The message names the mark that stands in its slot instead, the 3-D bar. The transforms that tally into slots are refused by the same rule. count and proportion count rows into the cells two categories make:
data(gm_eras) + surface * count + x(continent) + y(era) + space()data(gm_eras) + surface * count + x(col.continent) + y(col.era) + space()data(gm_eras) + surface * count + x(:continent) + y(:era) + space()plot(data(gm_eras), layer(surface, count), x(col.continent), y(col.era),
space())Error:
! gog: a `surface` is a sheet over a floor whose cells tile without gaps, and `count` does not give it one — `smooth` fits a curve along a domain a cell has none of, `range`/`confidence`/`bounds` give a low and a high where a cell holds one height, and `count`/`proportion` tally into the cells two *categories* make, which a surface refuses because slots leave air between them and disconnected tiles are not a sheet. A surface takes the two transforms that do tile: `bin` cuts the floor into adjacent cells and lays a flat lid on each — `surface * bin * mean + x(<a>) + y(<b>) + z(<column>)` reduces the column you name inside every cell — and `density` estimates a value at every node, which the sheet then interpolates between: `surface * density + x(<a>) + y(<b>) + space()`. Over categories, `bar` is the mark, where the column under each tile says which cell it belongs to.
gog: `x(continent)` maps a categorical (text) column, but `x` on `surface` needs a continuous (numeric) column. A face spans the gap between two samples, and between two categories there is nothing to span. For a mesh over categories use `bar * count + x(<a>) + y(<b>) + space()` — a column stands in its own cell and claims nothing in between.
gog: `y(era)` maps a categorical (text) column, but `y` on `surface` needs a continuous (numeric) column. A face spans the gap between two samples, and between two categories there is nothing to span. For a mesh over categories use `bar * count + x(<a>) + y(<b>) + space()` — a column stands in its own cell and claims nothing in between.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
That refusal lists the two transforms that do tile a floor, bin and density.
density(levels = ) cuts a field into level sets, and a level set is a region in the plane. A surface has already given the third axis to the measurement, so a band drawn on it could only be a color, which is zone’s reading of that same request:
data(iris_flowers) + surface * density(levels = 5) +
x(petal_length) + y(sepal_length) + space()(data(iris_flowers) + surface * density(levels = 5) +
x(col.petal_length) + y(col.sepal_length) + space())data(iris_flowers) + surface * density(levels = 5) + x(:petal_length) +
y(:sepal_length) + space()plot(data(iris_flowers), layer(surface, density({ levels: 5 })),
x(col.petal_length), y(col.sepal_length), space())Error:
! gog: `density(levels = )` cuts a field into level sets, which are regions in the plane — a `surface` draws the field itself, with the estimate as its height, so it has no axis left to put a band on. Drop `levels` for the sheet, or `zone * density(levels = )` to fill the bands and `path * density(levels = )` to trace their boundaries. For a sheet in steps, cut the floor rather than the height: `surface * bin * mean + x(<a>) + y(<b>) + z(<column>)` lays a flat plateau on every cell.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
A stepped sheet can be cut on either of two axes, and only one of the two draws. Cutting the floor into cells is surface * bin, the section on one value per cell above, and it draws. Cutting the height into bands is what levels asks for, and that one is refused. It would cut the measurement rather than the grid, so the plateaus would be contours of equal height rather than one per cell.
bandwidth is refused for the reason it is refused on every two-dimensional field: it is a length in one column’s own units, and a field spreads over two columns measuring different quantities, so one number cannot be a width in both. Use density(adjust = ), which is dimensionless and scales both.
There is no size on a surface. Its extent is the grid it is drawn on, so a size would be a second and contradictory answer to how big it is. area refuses a size for the same reason. And pattern is refused, which is the one place a fill does not take a texture:
data(maunga_whau) + surface + x(east) + y(north) + z(elevation) +
style(pattern = "hatch")(data(maunga_whau) + surface + x(col.east) + y(col.north) + z(col.elevation) +
style(pattern = "hatch"))data(maunga_whau) + surface + x(:east) + y(:north) + z(:elevation) +
style(pattern = "hatch")plot(data(maunga_whau), surface, x(col.east), y(col.north),
z(col.elevation), style({ pattern: "hatch" }))Error:
! gog: `style(pattern = )` textures a fill, and a `surface` is a mesh of fills — but a hatch tile is a texture in *screen* space, and a projected mesh foreshortens every face differently, so one tile would read as a different density on every face. That is a texture that changes with the viewing angle instead of with the data. A surface says its shape with slope shading; `style(border_color = )` draws its mesh lines, and `color` ramps it.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
A hatch tile is a texture in screen space, and every face of a projected mesh is foreshortened differently, so one tile size would read as a different density on every face. The texture would then follow the viewing angle rather than the data, which is the one thing this mark’s shading is built to avoid.