20  Surface

What shape does a landscape have, sampled on a grid? surface draws a sheet through your samples. It is a sheet through three positions, x, y and z. Without the third position there is no sheet at all. That makes it the only mark that must be drawn in the cube. Every other mark draws in the plane, and several of them also draw in the cube once you bind a z.

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. Nobody can see a hill in a column of numbers. Draw the same numbers as a sheet and the hill appears. Here is Maunga Whau, a volcano in Auckland. The book’s copy keeps every second row and every second column of the original grid, so these samples are 20 m apart. Ross Ihaka digitized the elevations from a topographic map, and R’s datasets package is where the book reads them (R Core Team, 2026):

maunga_whau: first 5 of 1364 rows
east north elevation
20 20 100
20 40 102
20 60 104
20 80 105
20 100 107
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"))
600 400 200 0 800 600 400 200 0 180 160 140 120 100 East North Elevation (m) 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. z_label() names the third axis, the way x_label() names the first, and Space explains it.

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 that shape, and knowing why tells you which of your tables can become a sheet. Given two position columns and no transform, a surface reads each row as a crossing: the point where one x value meets one y value. The sheet is the faces between the crossings. The grid those faces make is the sheet’s mesh. You never declare which crossings are neighbors. gog recovers that from your two position columns: the distinct east values and the distinct north values become the mesh lines, one set in each direction. A face is drawn wherever all four corners of a cell are present.

So this reading needs a table with one row per (x, y) crossing: every x value paired with every y value.

Maunga Whau has that shape. It holds 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.

The same count shows why a scatter cannot be a surface. The gapminder 2007 table below holds 142 countries, and no two of them share a gdp value or a life value. That makes a grid 142 wide and 142 deep holding only 142 crossings, so no cell has all four corners. The sheet would be empty:

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) + 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: every `gdp` value paired with every `life` value. 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: a point with a z to draw each country, and surface * density for a field estimated from those points. A field is one value at every place on the plane. The third geometry of one field draws surface * density for the iris flowers.

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. The peak table is Maunga Whau with the crossings around the summit dropped. The sheet opens there rather than covering the gap:

peak: first 5 of 1322 rows
east north elevation
20 20 100
20 40 102
20 60 104
20 80 105
20 100 107
data(peak) + surface + x(east) + y(north) + z(elevation) +
  z_label("Elevation (m)") + title("A gap in the grid, left open")
(data(peak) + surface + x(col.east) + y(col.north) + z(col.elevation) +
  z_label("Elevation (m)") + title("A gap in the grid, left open"))
data(peak) + surface + x(:east) + y(:north) + z(:elevation) +
  z_label("Elevation (m)") + title("A gap in the grid, left open")
plot(data(peak), surface, x(col.east), y(col.north), z(col.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.
600 400 200 0 800 600 400 200 0 180 160 140 120 100 East North Elevation (m) A gap in the grid, left open

The note above the plot counts the crossings gog did find: 1322 of 1364, so 42 crossings are missing. Each missing crossing removes every face that needed it, so more than 42 faces are missing: 1234 are drawn here, where the full grid draws 1290. The note is advice rather than a refusal, because a grid with gaps is a legitimate thing to have and a sheet with openings is a true picture of one. What would not be legitimate is quietly spanning a gap, which claims a height nobody measured. The note also says what to check if you expected a whole sheet: each position value must repeat exactly. If one row holds 1.2 and another 1.2000001, gog makes two mesh lines where you meant one, and the sheet gets openings you did not intend.

20.3 A continuous floor

A table with a continent in one column and a year in another looks like the grids above, and a sheet over it is a reasonable thing to ask for. The two position columns make the sheet’s floor, the plane under it, and both have to be continuous. A face stretches from one sample to the next and so claims every height between them, and between one category and the next there is nothing to claim. So a category on either position is refused.

Numbers cut into cells are different, and a surface takes those, as When each cell holds one value shows. Cut cells meet edge to edge, and the edge between two cells is itself a number on the axis. A category instead owns a slot, a share of the axis that is its own, and the line between two slots stands for no value at all. So a surface refuses the slot, not the cut. The flat marks show the same difference: bars over categories have gaps between them, and the bars of a histogram touch.

The refusal, shown in What it refuses, names the mark that does take a category on both positions: the 3-D bar. A 3-D bar is in its slot and claims nothing about the space between slots. So bar * count + x(a) + y(b) + space() draws one bar over each pair of categories. A surface cannot draw that grid, because tiles with empty space between them are not a sheet.

20.4 The third geometry of one field

Which of the two iris clusters is denser, and by how much? A heatmap and a contour plot, the first two plots below, show where each cluster is. To find how dense it is, you have to read a legend or a contour level. A surface takes two transforms that tile a floor, 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:

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) + 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"))
0 2 4 6 8 4 6 8 As cells: zone Sepal Length Petal Length Density 0.163 0.0816 0

“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"))
0 2 4 6 4 5 6 7 8 As contours: path Sepal Length Petal Length Level 0.141 0.082 0.0234

“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(turn = 120) +
  title("As a sheet: surface")
(data(iris_flowers) + surface * density +
  x(col.petal_length) + y(col.sepal_length) + space(turn = 120) +
  title("As a sheet: surface"))
data(iris_flowers) + surface * density + x(:petal_length) +
  y(:sepal_length) + space(turn = 120) + title("As a sheet: surface")
plot(data(iris_flowers), layer(surface, density), x(col.petal_length),
  y(col.sepal_length), space({ turn: 120 }), title("As a sheet: surface"))
8 6 4 2 0 8 6 4 0.15 0.10 0.05 0.00 Petal Length Sepal Length Density As a sheet: surface

“Given the iris flowers: a surface derived by density, x is petal length, y is sepal length, in the cube, turned 120.”

The sheet is turned to 120 degrees because of where the two peaks are. They are on a diagonal of the floor. At the default angle the near peak is directly in front of the far one and hides it, so the sheet would show one peak where the data has two. One sheet, four angles returns to that, because every 3-D view has this problem, not only this one.

All three plots say the same thing about these two clusters: they are nearly equally dense at their centers. The cluster with long petals is the denser one, by about four percent. What each plot asks of the reader is different. In the cells you compare two shades against a legend. In the contours you count rings. On the sheet you compare two heights, and that needs no legend at all. 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 each mark puts the measurement. A zone puts it in color. A path puts it in the level each contour was cut at. A surface puts it in 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.

Mark derived by density, over two positions Where the measurement goes
zone * density into the color of each cell
path * density into the level each contour was cut at
surface * density into the height of the sheet, the third plot above

Here the transform stays the same, and each mark supplies its own geometry. That is the rule that already made the contour and the estimated heatmap one feature instead of two.

The third sentence above writes space(), and the chapter’s first sentence, for the volcano, did not. The reason matters. A bound z says there are three columns here, which nothing else can mean. A height that a transform writes says only this layer has a measure of its own. density writes that measure in the plane too, where it becomes a color, so space() is what tells gog to put it on a third axis.

20.5 When each cell holds one value

The volcano’s sheet joins its samples with sloping faces. 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 that tiles a floor. It cuts the floor into cells, and mean gives each cell the average elevation of the rows inside it. The surface is then flat across each cell, with a step at each boundary. The density sheet above is drawn the same way, from much smaller cells. 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"))
600 400 200 800 600 400 200 180 160 140 120 100 East North Elevation (m) 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 volcano changed. What changed is the claim. The sheet at the top of this chapter says the ground rises smoothly between its samples. This one gives each cell one elevation, the mean of its samples, and says nothing about how the ground varies inside the cell. Each claim is right for a different kind of data, and which one you want is a question about how yours was measured.

That question has a clear answer in an experiment or a simulation, where the conditions form a grid with exactly one number in each cell. Read as crossings, such a table gives fewer faces than it has values. Nine values make a 3 × 3 grid of crossings. A face needs four crossings at its corners, so the sheet has 2 × 2 = 4 faces, and no face stands for any one value:

cells <- data.frame(
  a = c(-2, 0, 2, -2, 0, 2, -2, 0, 2),
  b = c(-2, -2, -2, 0, 0, 0, 2, 2, 2),
  v = c(0.039, 0.029, 0.039, 0.029, 0.019, 0.029, 0.039, 0.029, 0.039)
)

data(cells) + surface + x(a) + y(b) + z(v) + title("Nine values, four faces")
cells = {
  "a": [-2, 0, 2, -2, 0, 2, -2, 0, 2],
  "b": [-2, -2, -2, 0, 0, 0, 2, 2, 2],
  "v": [0.039, 0.029, 0.039, 0.029, 0.019, 0.029, 0.039, 0.029, 0.039],
}
data(cells) + surface + x(col.a) + y(col.b) + z(col.v) + title("Nine values, four faces")
cells = (
  a = [-2, 0, 2, -2, 0, 2, -2, 0, 2],
  b = [-2, -2, -2, 0, 0, 0, 2, 2, 2],
  v = [0.039, 0.029, 0.039, 0.029, 0.019, 0.029, 0.039, 0.029, 0.039],
)
data(cells) + surface + x(:a) + y(:b) + z(:v) +
  title("Nine values, four faces")
const cells = {
  a: [-2, 0, 2, -2, 0, 2, -2, 0, 2],
  b: [-2, -2, -2, 0, 0, 0, 2, 2, 2],
  v: [0.039, 0.029, 0.039, 0.029, 0.019, 0.029, 0.039, 0.029, 0.039],
};
plot(data(cells), surface, x(col.a), y(col.b), z(col.v),
  title("Nine values, four faces"))
2 1 0 -1 -2 2 1 0 -1 -2 0.040 0.035 0.030 0.025 0.020 A B V Nine values, four faces

Nine numbers became four faces, and each face slopes between the four values at its corners. Cut the floor 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"))
1 0 -1 1 0 -1 0.040 0.035 0.030 0.025 0.020 A B V Nine values, nine plateaus V 0.039 0.029 0.019

“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. The nine values make a symmetric bowl: the four corner cells share the highest value, the four edge cells share the middle one, and the center cell holds the lowest. The ramp makes that symmetry visible. In the plain sheet above, you had to read the symmetry from the axis.

A 3-D bar uses the same sentence: bin cuts the x and y axes into cells, and mean averages the column named on z inside each one. The transforms mean the same thing on both marks, and only the geometry laid on the cell differs. Here mean does no averaging, because this table holds one row per cell. It is still needed: without it, bin counts the rows in each cell and draws that count as the height.

The step between two plateaus is drawn as a vertical face along the line the two cells share. That line has no width, so the vertical face claims 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 vertical face spans the difference between two neighbors, and a 3-D bar on every cell does not. A bar always reaches down to the baseline, so a grid of bars hides its own back rows as soon as you turn it. A vertical face is only as tall as its own step. The next plot turns both to 60 degrees, 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)
beside(plot(data(cells), layer(bar, bin(3), mean), x(col.a), y(col.b),
  z(col.v), space({ turn: 60 }), style({ color: "#76b7b2" }),
  title("Bars, turned")),
  plot(data(cells), layer(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 }))
1 0 -1 1 0 -1 0.040 0.035 0.030 0.025 0.020 A B V Bars, turned 1 0 -1 1 0 -1 0.040 0.035 0.030 0.025 0.020 A B V Plateaus, turned

“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.

theme(height = ) sets the whole arrangement’s height in pixels. It is set here because two cubes side by side each get half its width but the full height, so each panel is tall and narrow. A cube keeps its proportions, 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 the mesh, for a different reason.

What a surface refuses is a grid of slots, the cells two categories make. 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 lines shows the grid again, so a reader can see how fine the sampling was. border_color and border_size draw them. A face is a closed fill like a bar’s or a box’s, so the settable rule gives it a border. On a surface, those borders are the mesh lines:

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"))
600 400 200 0 800 600 400 200 0 180 160 140 120 100 East North Elevation (m) 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 lines add 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, and border_size its width. Leave both out for a seamless sheet. A border_size of 0 removes the stroke, and the pale seams appear.

Look at the shading on the sheet above, and on the turned one below it. The faces are shaded by their slope: a steep one is darker, a flat 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, with the same shades")
(data(maunga_whau) + surface +
  x(col.east) + y(col.north) + z(col.elevation) +
  space(turn = 130, tilt = 35) +
  z_label("Elevation (m)") + title("Turned, with the same shades"))
data(maunga_whau) + surface + x(:east) + y(:north) + z(:elevation) +
  space(turn = 130, tilt = 35) + z_label("Elevation (m)") +
  title("Turned, with the same shades")
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, with the same shades"))
600 400 200 0 800 600 400 200 0 180 160 140 120 100 East North Elevation (m) Turned, with the same shades

“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 light that stayed in one place on the screen would have changed the shade of every face. 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

Slope shading says how steep a face is, not how high it is. How high is the summit, and how deep is the crater? color maps on a surface, and a continuous column colors it face by face along a ramp. palette() chooses which ramp. "viridis" suits a topographic sheet, because it gets steadily lighter from its lowest color to its highest:

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"))
600 400 200 0 800 600 400 200 0 180 160 140 120 100 East North Elevation (m) Height twice: as a position and as a ramp Elevation 193 143.5 94

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 infer them from which faces are dark. The ramp states the heights, and the legend decodes them.

Mapping the height to color when z already shows it is redundant on purpose. It is what a reader of a topographic map expects. It also shows why a surface can take a continuous color when an area cannot. An area has one interior, and coloring it by a measure would make a gradient fill, which is a different and much larger job. A surface has faces, and a face is small enough to hold one value.

A face has four corners and one color. Its color is the mean of its four corner values, which stands for the field at its center. So the ramp describes the mesh you have, not only the field you sampled from. Here is one bowl-shaped field, sampled coarsely and then finely. Its value v is lowest at the center, where a and b are both 0, and rises the same way toward every edge. The coarse table samples it on a 3 by 3 grid, and the fine table on a 7 by 7 grid:

coarse: first 5 of 9 rows
a b v
-2 -2 0.039
0 -2 0.029
2 -2 0.039
-2 0 0.029
0 0 0.019
fine: first 5 of 49 rows
a b v
-2.0000000 -2 0.0390000
-1.3333333 -2 0.0334444
-0.6666667 -2 0.0301111
0.0000000 -2 0.0290000
0.6666667 -2 0.0301111
((data(coarse) + surface + x(a) + y(b) + z(v) +
    color(v) + palette("plasma") + title("Sampled 3 by 3: four faces")) |
   (data(fine) + surface + x(a) + y(b) + z(v) +
      color(v) + palette("plasma") + title("Sampled 7 by 7: thirty-six faces"))) +
  theme(height = 320)
(((data(coarse) + surface + x(col.a) + y(col.b) + z(col.v) +
    color(col.v) + palette("plasma") + title("Sampled 3 by 3: four faces")) |
   (data(fine) + surface + x(col.a) + y(col.b) + z(col.v) +
      color(col.v) + palette("plasma") + title("Sampled 7 by 7: thirty-six faces"))) +
  theme(height = 320))
((data(coarse) + surface + x(:a) + y(:b) + z(:v) + color(:v) +
  palette("plasma") + title("Sampled 3 by 3: four faces")) |
  (data(fine) + surface + x(:a) + y(:b) + z(:v) + color(:v) +
  palette("plasma") + title("Sampled 7 by 7: thirty-six faces"))) +
  theme(height = 320)
beside(plot(data(coarse), surface, x(col.a), y(col.b), z(col.v),
  color(col.v), palette("plasma"), title("Sampled 3 by 3: four faces")),
  plot(data(fine), surface, x(col.a), y(col.b), z(col.v), color(col.v),
  palette("plasma"), title("Sampled 7 by 7: thirty-six faces")),
  theme({ height: 320 }))
2 1 0 -1 2 1 0 -1 -2 0.040 0.035 0.030 0.025 0.020 A B V Sampled 3 by 3: four faces V 0.039 0.029 0.019 2 1 0 -1 2 1 0 -1 -2 0.040 0.035 0.030 0.025 0.020 A B V Sampled 7 by 7: thirty-six faces V 0.039 0.029 0.019

“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 share this page, and data() keeps each table’s name, coarse or fine. gog finds each layer’s table by that name, so it refuses two different tables under one name (Data).

The left sheet is one uniform color, and that is correct rather than broken. Every face has one high corner, one low corner and two middle ones, so all four faces get the same average. 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 stated place, the center of a face, so two faces of one color really are at one height there, and a sheet of one color has all its face centers at one height.

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 takes its dark colors, and low ground is usually flat. A flat face is barely darkened by its slope, so few faces are darkened by both the ramp and the slope.

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 density is the name you give color, exactly as 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"))
8 6 4 2 0 8 6 4 0.15 0.10 0.05 0.00 Petal Length Sepal Length Density The estimate, as a height and as a ramp Density 0.163 0.0816 0

Nothing there is a special case. The transform writes a column, the column has a name, and a name can be bound. Whether the height came from your table or from an estimate is only a difference in source. It does not change what you may say about it.

Whether you want the color as well as the height is a separate question. The plain sheet in A sheet through the samples already shows the height by position alone. Use the ramp when the low ground matters. Slope shading darkens a steep face but not a flat one, so a broad, low mound and the flat ground around it are drawn almost the same color. The ramp is what separates them.

Sometimes faces belong to named parts of the ground rather than to heights. A categorical color gives each face the color of its category. Unlike on a line, it does not split the rows: the sheet stays one sheet, and only group makes separate sheets. The band table is Maunga Whau with one more column, side: north half where north is greater than 440, and south half for the rest:

band: first 5 of 1364 rows
east north elevation side
20 20 100 south half
20 40 102 south half
20 60 104 south half
20 80 105 south half
20 100 107 south half
data(band) + surface + x(east) + y(north) + z(elevation) + color(side) +
  z_label("Elevation (m)") + title("Two halves, one sheet")
(data(band) + surface + x(col.east) + y(col.north) + z(col.elevation) + color(col.side) +
  z_label("Elevation (m)") + title("Two halves, one sheet"))
data(band) + surface + x(:east) + y(:north) + z(:elevation) +
  color(:side) + z_label("Elevation (m)") + title("Two halves, one sheet")
plot(data(band), surface, x(col.east), y(col.north), z(col.elevation),
  color(col.side), z_label("Elevation (m)"),
  title("Two halves, one sheet"))
600 400 200 0 800 600 400 200 0 180 160 140 120 100 East North Elevation (m) Two halves, one sheet Side south half north half

The two colors meet between the crossings at 440 and 460 meters north, and the sheet has no gap there. A face whose corners hold both categories takes the category of its corner with the smallest east and north, so the faces between 440 and 460 belong to the south half. To draw a separate sheet for each category, use group, as the next section shows.

20.8 Two sheets over one floor

What if one table holds the heights of two sheets over the same floor? The crop_response table is invented for this book. It gives a wheat yield, in metric tons per hectare, for two varieties of wheat, a hardy one and a high-yield one. Nitrogen fertilizer runs from 0 to 200 kilograms per hectare and water from 100 to 500 millimeters, six amounts of each.

crop_response: first 5 of 72 rows
nitrogen water variety yield
0 100 Hardy 3.20
0 100 High-yield 1.43
40 100 Hardy 3.98
40 100 High-yield 2.65
80 100 Hardy 4.37

Each crossing holds two rows, one for each variety. A sheet has one height at each crossing, so gog refuses to draw the table as one sheet. The message names the column that splits the rows:

data(crop_response) + surface + x(nitrogen) + y(water) + z(yield)
data(crop_response) + surface + x(col.nitrogen) + y(col.water) + z(col["yield"])
data(crop_response) + surface + x(:nitrogen) + y(:water) + z(:yield)
plot(data(crop_response), surface, x(col.nitrogen), y(col.water),
  z(col.yield))
Error:
! gog: `surface` found 36 rows at a (`nitrogen`, `water`) crossing that another row already holds. A sheet draws one height at each crossing, so they would not be drawn. If they are separate sheets, `group(variety)` draws one for each `variety`. To draw one height for each cell instead, `surface * bin * mean` averages the rows that fall in it.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

group(variety) draws one sheet for each variety. group splits without coloring, so color(variety) gives each sheet its own color:

data(crop_response) + surface + x(nitrogen) + y(water) + z(yield) +
  group(variety) + color(variety) +
  z_label("Yield (t/ha)") + title("Two varieties over one floor")
(data(crop_response) + surface + x(col.nitrogen) + y(col.water) + z(col["yield"]) +
  group(col.variety) + color(col.variety) +
  z_label("Yield (t/ha)") + title("Two varieties over one floor"))
data(crop_response) + surface + x(:nitrogen) + y(:water) + z(:yield) +
  group(:variety) + color(:variety) + z_label("Yield (t/ha)") +
  title("Two varieties over one floor")
plot(data(crop_response), surface, x(col.nitrogen), y(col.water),
  z(col.yield), group(col.variety), color(col.variety),
  z_label("Yield (t/ha)"), title("Two varieties over one floor"))
200 150 100 50 0 500 400 300 200 100 6 4 2 Nitrogen Water Yield (t/ha) Two varieties over one floor Variety Hardy High-yield

“Given the crop response: surfaces, x is nitrogen, y is water, z is yield, grouped by variety, color by variety.”

The two sheets cross. The hardy variety yields more where nitrogen or water is low, and the high-yield variety yields more where both are high. gog draws the faces of both sheets in one depth sort, farthest first, as it draws a path’s segments. It does not draw one sheet and then the other. Each face takes its own place in that order, so the picture shows each sheet passing through the other where they cross.

20.9 Opacity by a measure, per face

A color decides what a face says. An opacity decides whether you can see through it. 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 makes the hill the solid part of the picture. 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"))
600 400 200 0 800 600 400 200 0 180 160 140 120 100 East North Elevation (m) Faint where the ground is low Elevation 94 143.5 193

“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. 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 the way it reads its color, from the mean of its four corners. A plateau reads its own cell’s row. A vertical face is between two cells and reads the taller one’s row, the way a bar’s side takes the bar’s color. Either way one face has one reading, whichever channel asks for it.

The sentence behind the first plot in this section binds elevation twice: once to z and once to opacity. A second column is the more useful case: the shape stays in z, and another measure decides which faces fade. The relief table is Maunga Whau with one more column, steepness: how much the elevation changes toward the next crossing east and the next crossing north, combined:

relief: first 5 of 1364 rows
east north elevation steepness
20 20 100 2.236068
20 40 102 2.236068
20 60 104 1.414214
20 80 105 2.236068
20 100 107 2.236068
data(relief) + surface + x(east) + y(north) + z(elevation) + opacity(steepness) +
  z_label("Elevation (m)") + title("Solid where the ground is steep")
(data(relief) + surface + x(col.east) + y(col.north) + z(col.elevation) + opacity(col.steepness) +
  z_label("Elevation (m)") + title("Solid where the ground is steep"))
data(relief) + surface + x(:east) + y(:north) + z(:elevation) +
  opacity(:steepness) + z_label("Elevation (m)") +
  title("Solid where the ground is steep")
plot(data(relief), surface, x(col.east), y(col.north), z(col.elevation),
  opacity(col.steepness), z_label("Elevation (m)"),
  title("Solid where the ground is steep"))
600 400 200 0 800 600 400 200 0 180 160 140 120 100 East North Elevation (m) Solid where the ground is steep Steepness 0 9.86 19.72

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 same reason it refuses a continuous 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 surface has faces, and each face can carry both.

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.10 One sheet, four angles

A still 3-D plot hides something at every angle, and which thing it hides depends on the angle you pick. So turn the same sheet rather than trust 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 default 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 view from the side")))
(((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 default 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 view from the side"))))
((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 default 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 view from the side")))
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 default 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 view from the side"))))
600 400 200 0 800 400 0 180 160 140 120 100 East North Elevation (m) The default view Elevation 193 143.5 94 600 200 0 800 400 0 180 160 140 120 100 East North Elevation (m) Turned: the same sheet from the west Elevation 193 143.5 94 600 400 200 0 800 400 0 180 140 100 East North Elevation (m) Tilted up: the crater appears Elevation 193 143.5 94 600 200 0 800 600 400 200 0 180 160 140 120 100 East North Elevation (m) Tilted down: the view from the side Elevation 193 143.5 94

“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 rim nearest you is in front of the crater, so turning alone does not show it while the tilt is low. Tilt up and you can see into the crater, because you are now looking down from above. The same tilt flattens the heights, until the sheet looks like a flat colored map. Tilt down instead and you get the opposite: the heights read clearly, the view from above is gone, and the sheet shows as an outline. One or the other, never both.

These four plots show why you would want to turn a plot. 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 pointer. Raise the tilt until the crater opens, and watch the heights flatten as it does. The four still views show the problem; dragging them lets you check it yourself.

All four turn together, because | and / composed them into one figure. Each panel keeps the angle its own sentence asked for, and the drag adds the same change to every one. 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, one cube for each value of a column, but these four panels are not small multiples. Space has that example.

One limit shows in these four plots, and it belongs to the cube rather than to the mark. Notice the Elevation axis in the tilted-up panel. You are looking down along it, so it is drawn at about a third of its full length. gog labels three heights there where the reference view labels five, because it drops numbers rather than let them overlap. Space says which numbers a crowded axis drops, and why the scale itself never changes.

20.11 What a surface is for

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 in One row per crossing names it for you. The question to ask is whether the space between two of your samples means anything. On a grid it does; among scattered points it does not.

20.12 What you can set

No column says whether the mesh lines show, or how far you can see through a sheet. A setting decides both without reading any column, and each mark takes its own settings. These are a surface’s, with the values each 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 closed fill, so it takes what a bar and a box take: a color, an opacity, and a border. pattern is the one fill setting it refuses, and like a bar it has no size. The next section says why for both.

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.13 What it refuses

Every flat mark is written with only x and y as positions, so it is easy to write a surface that way by mistake. Leaving out the height and leaving the plot in the plane are one mistake, not two, so there is one refusal. It names both ways into the cube, and it names the two marks that draw a field in the plane instead:

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 stretches from one sample to the next, and between two categories there is nothing for it to cross:

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) + 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. Over two categories, stand a column in each cell instead: `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 draws one bar in each category’s 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. `count` tallies into the cells two categories make, and slots leave air between them, so the tiles would not join into a sheet. `bin` and `density` tile a floor, and `sum`, `mean`, `median`, `max`, `min` and `quantile` reduce a column inside the cells `bin` cuts: `surface * bin * mean + x(<a>) + y(<b>) + z(<column>)`, while `surface * density + x(<a>) + y(<b>) + space()` estimates the field. 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. Over two categories, stand a column in each cell instead: `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. Over two categories, stand a column in each cell instead: `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. zone already draws those bands in the plane, as colored regions, so the sentence is refused:

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, which When each cell holds one value 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 that measure different quantities. 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 a surface is the one fill mark that refuses pattern:

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 line spacing 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.

20.14 In short

A surface stretches between neighboring samples, so it needs a floor whose cells tile. A scatter’s table does not give it one. A face needs all four of its corners, so a missing crossing leaves an opening rather than a guessed height.

20.15 What comes next

A surface needs two continuous positions under it. Edge is the mark for tables with no positions at all: rows naming two things and saying they are connected.