Appendix F — Coverage, both directions

The phrasebook answers “how do I spell this in gog”. This appendix answers the question underneath it: can it be spelled. Every entry in ggplot2’s reference index was read against the kernel. The result is two lists of very different lengths, and a table of differences between them.

Each table below has a ggplot2 column, and it is dated on purpose, because nothing here can check it: reference index of ggplot2 4.0.3, read 2026-07-26 (Wickham et al., 2026). Every gog claim below is either a live plot on this page or a refusal the build re-runs, and ggplot2 is deliberately not a dependency of this book, so its column is the one that can go out of date.

The short list is the one people expect to be long, so it goes first. The long list is the interesting one, because every item on it is one of four things, and three of the four were decided on purpose.

F.1 What gog draws that ggplot2 does not

Two of these are pictures ggplot2 cannot draw. The third is about the vocabulary rather than about a picture.

F.1.1 Anything in the cube

Binding z gives a plot a third position, and surface draws a height over a plane:

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) +
  color(elevation) + palette("plasma") +
  title("Maunga Whau, one row per crossing")
(data(maunga_whau) + surface + x(col.east) + y(col.north) + z(col.elevation) +
  color(col.elevation) + palette("plasma") +
  title("Maunga Whau, one row per crossing"))
data(maunga_whau) + surface + x(:east) + y(:north) + z(:elevation) +
  color(:elevation) + palette("plasma") +
  title("Maunga Whau, one row per crossing")
plot(data(maunga_whau), surface, x(col.east), y(col.north),
  z(col.elevation), color(col.elevation), palette("plasma"),
  title("Maunga Whau, one row per crossing"))
600 400 200 0 800 600 400 200 0 180 160 140 120 100 East North Elevation Maunga Whau, one row per crossing Elevation 193.0 143.5 94.00

“Given Maunga Whau: a surface, x is east, y is north, z is elevation, color by elevation.”

Six marks draw in the cube: point, path, bar, interval, box and surface. space(turn = , tilt = ) sets the viewing angle. facet and play split a cube’s rows as they split any other plot’s: | facet(g) gives one projected box per panel, + play(t) runs the sequence inside a single box. ggplot2 has no depth position and no coordinate system that would read one, and says so outright in the first chapter of its book, so none of this is expressible in it: in R these plots come from plotly, rgl or lattice, each with a grammar of its own. Here z is one more channel in the same sentence, and Space is the chapter.

F.1.2 Texture, mapped from a column

pattern maps a categorical column to a hatch on fills and a dash on strokes. Put it on the same column as color and each series is marked twice, by hue and by hatch, in one merged legend:

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) + bar * mean + x(species) + y(petal_length) +
  color(species) + pattern(species) +
  title("Hue and hatch, one legend")
(data(iris_flowers) + bar * mean + x(col.species) + y(col.petal_length) +
  color(col.species) + pattern(col.species) +
  title("Hue and hatch, one legend"))
data(iris_flowers) + bar * mean + x(:species) + y(:petal_length) +
  color(:species) + pattern(:species) + title("Hue and hatch, one legend")
plot(data(iris_flowers), layer(bar, mean), x(col.species),
  y(col.petal_length), color(col.species), pattern(col.species),
  title("Hue and hatch, one legend"))
setosa versicolor virginica 0 2 4 Hue and hatch, one legend Petal Length Species Species setosa versicolor virginica

“Given the iris flowers: bars derived by mean, x is species, y is petal length, color by species, pattern by species.”

The plot above stays legible in grayscale and under color blindness, because hue and hatch say the same thing twice. ggplot2 has linetype for strokes and nothing for the inside of a filled shape, so hatching a bar there means the third-party ggpattern.

F.1.3 The same plot, without a word for it

Not a picture ggplot2 lacks, but one this grammar draws without being taught it. geom_violin() is a mark of its own there, with stat_ydensity() beside it; here it is ribbon * density, the mark that closes on a second boundary, given the transform that estimates a distribution, over a category:

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) + ribbon * density + x(continent) + y(life) +
  y_label("Life expectancy") + title("ribbon * density: no new word required")
(data(gapminder_2007) + ribbon * density + x(col.continent) + y(col.life) +
  y_label("Life expectancy") + title("ribbon * density: no new word required"))
data(gapminder_2007) + ribbon * density + x(:continent) + y(:life) +
  y_label("Life expectancy") +
  title("ribbon * density: no new word required")
plot(data(gapminder_2007), layer(ribbon, density), x(col.continent),
  y(col.life), y_label("Life expectancy"),
  title("ribbon * density: no new word required"))
Asia Europe Africa Americas Oceania 40 60 80 ribbon * density: no new word required Life expectancy Continent

“Given gapminder 2007: ribbons derived by density, x is continent, y is life.”

The half violin comes with it, because area and ribbon already differ by where a region closes: area * density + x(continent) + y(life). That is what the orthogonality is for: not that the plot is possible, but that the vocabulary did not grow to make it so.

The donut is the same case arriving from a parameter rather than a geom. ggplot2 spells it coord_radial(inner.radius = ), a number given to the space; here the hole is not a property of the circle at all. It is a stretch of the radial axis with nothing standing on it, so a tree one level deep, drawn on an axis that starts below the first ring, is a donut:

spending: first 5 of 12 rows
group item detail amount
Housing Rent NA 980
Housing Utilities Energy 140
Housing Utilities Water 35
Housing Repairs NA 120
Food Groceries NA 420
data(spending) + zone * partition(group) + x(amount) +
  y(depth, limits = c(0, 2)) + color(group) + polar() +
  title("a donut is a sunburst of a one-level tree")
(data(spending) + zone * partition(col.group) + x(col.amount) +
  y(col.depth, limits = [0, 2]) + color(col.group) + polar() +
  title("a donut is a sunburst of a one-level tree"))
data(spending) + zone * partition(:group) + x(:amount) +
  y(:depth, limits = [0, 2]) + color(:group) + polar() +
  title("a donut is a sunburst of a one-level tree")
plot(data(spending), layer(zone, partition(col.group)), x(col.amount),
  y(col.depth, { limits: [0, 2] }), color(col.group), polar(),
  title("a donut is a sunburst of a one-level tree"))
1000 1500 2000 a donut is a sunburst of a one-level tree Amount Group Housing Food Transport Leisure

“Given the spending table: zones derived by partition through group, x is amount, y is depth from 0 to 2, color by group, in polar.”

Nothing there is about donuts, and the same limits that hollow this out hollow the three-level sunburst and leave an empty band under the flat icicle, which is what tells you the hole belongs to the scale rather than to the bend. The one reading that cannot state a hole is the plain pie, whose radius is a constant with no domain to widen.

The marginal plot is the same again, this time between whole plots. ggExtra adds ggMarginal() to ggplot2 and patchwork adds a layout algebra beside it; here a page is | and / between two plots, and the marginals follow from one rule about what a shared column means (Composition):

(data(gapminder_2007) + bar * bin + x(gdp) + theme(height = 130)) /
  ((data(gapminder_2007) + point + x(gdp) + y(life)) |
     (data(gapminder_2007) + bar * bin + y(life) + theme(width = 130)))
((data(gapminder_2007) + bar * bin + x(col.gdp) + theme(height = 130)) /
  ((data(gapminder_2007) + point + x(col.gdp) + y(col.life)) |
     (data(gapminder_2007) + bar * bin + y(col.life) + theme(width = 130))))
(data(gapminder_2007) + bar * bin + x(:gdp) + theme(height = 130)) /
  ((data(gapminder_2007) + point + x(:gdp) + y(:life)) |
  (data(gapminder_2007) + bar * bin + y(:life) + theme(width = 130)))
below(plot(data(gapminder_2007), layer(bar, bin), x(col.gdp),
  theme({ height: 130 })),
  beside(plot(data(gapminder_2007), point, x(col.gdp), y(col.life)),
  plot(data(gapminder_2007), layer(bar, bin), y(col.life),
  theme({ width: 130 }))))
0 20 40 60 Count 0K 20K 40K 40 50 60 70 80 Life Gdp 0 10 20 30 Count

“Given gapminder 2007: bars derived by bin, x is gdp; above points, x is gdp, y is life, beside bars derived by bin, y is life.”

That is the whole list. ggplot2 draws everything else gog draws, and usually with more settings.

F.2 What is different rather than missing

The rest is not about pictures at all. These rows are differences in how the two packages work: each one follows from what its own package was built for.

ggplot2 gog
Where it runs R R, Python, Julia and JavaScript, one engine, the same SVG out of each
What a plot is R closures, alive in the session an engine-neutral JSON specification you can store, compare, send or generate
A sentence the package cannot draw a warning, and a plot with those rows removed (Removed 12 rows containing missing values) no plot, and a message naming the sentence it would accept
What combines with what documented per geom one rule table, every pair decided, dumped from the engine into Combinations
Picking out a subset with the mouse outside the package: the plot is a still image, and the answer is plotly or shiny brush is a word in the sentence, so the printed page shows the selection too. It dims what you did not pick and removes no rows. Selection is the chapter
Moving overlapping labels apart ggrepel, a separate package, with a geom of its own to use instead text * repel, a collision modifier beside dodge, stack and jitter, so it composes with the text mark that was already there. It draws a thin line back to the point when a label has moved far, and where no arrangement fits, it still draws every label and reports how many overlap
Spellings per plot often two (geom_bar/geom_col, geom_histogram/stat_bin) one
Saying “log” scale_x_log10(), a scale function per transformation x(gdp, scale = "log"), written on the binding
Non-positive values on a log axis dropped, with a warning refused, with the fix named
A mean under a log axis the geometric mean computed in the data’s own units
Centering a diverging ramp scale_color_gradient2(midpoint = 0), which fixes the midpoint and stretches each side to the data’s own ends limits = c(-k, k), the atom that already states a domain. The middle of a domain is a midpoint, so no parameter is added, and the ramp is linear on each side of the middle.

F.3 What ggplot2 draws that gog does not

The table below is sorted by the reason, because the reason is the useful part. A gap with a written ruling behind it is a different thing from a gap nobody has examined.

F.3.1 Refused, with the ruling on record

These will not arrive. Each has a written ruling, and the ruling names the direction to go instead. Design laws is where the laws behind them live.

In ggplot2 The ruling
sec_axis(), dup_axis() A second y-axis is two coordinate spaces drawn in one panel. Facet on the measure, or layer if the units really are shared.
annotate() at page coordinates, annotation_custom(), annotation_raster() A shape at pixel (120, 340) is a drawing command, and the specification has to mean the same thing at another size, in a facet, or on a sphere. Annotation in data space needs no new word: layer a mark over a small table.
ggproto(), user-written geoms and stats Every promise on the Combinations grid depends on the vocabulary being fixed. An atom missing from the rule table has no row on that grid, breaks No Exceptions, and cannot be written into the JSON. Compose instead.
coord_flip() Nothing to write: orientation is read off which axis carries the categories, so swapping the two bindings turns the plot. ggplot2 has since superseded it.
geom_bar() beside geom_col(); geom_histogram() beside stat_bin(geom = "bar") gog spells each of these one way. bar draws your numbers, bar * count counts them, bar * bin bins them.
geom_smooth(method = "lm") A family of fits behind one argument is exactly the enumeration this grammar refuses. A least-squares line is a different statistic from a local smoother, so it needs its own plain name.
scale_x_log10(), scale_x_sqrt() as names One parameter derives the family: base = 2, base = 10, base = exp(1).
coord_polar(theta = "y") Which axis is the angle is read off the bindings, not chosen by an argument.
qplot(), and plotting bare vectors A plot is a mapping from a table. Put the vectors in one.
geom_vline(), geom_hline() as separate marks Three marks for one idea. They are one rule, which takes a column, so a table of thresholds draws all of them at once, and which is also the rug via style(reach = "edge").
plotmath, label_parsed(), label_bquote() A TeX engine belongs to one output format, not to the grammar. Unicode covers what readers need: superscript powers on log ticks, Greek, sub- and superscripts in a label.
coord_cartesian(xlim = ) One name here covers two different operations. Narrowing the domain and running the statistics again is limits, which already counts the rows it left out aloud. Magnifying the finished picture changes how you look and not what the plot claims, so it needs no word: it is a button under every plot in the web edition, , + and fit.
plotly-style interaction: click a point, and read what it is This needs no word in the kernel, and the survey of what other tools do is what decided it. Plotly hands your application an event; Vega-Lite puts a predicate over named fields in the specification; D3 asks the author for a row identity, and uses it for animation rather than for selection. gog answers each of the three elsewhere. Reading a row belongs to the page, so the pointer does it, and in the web edition a click leaves that reading on the plot as a stamp. A predicate over a column is brush. A row identity is what an animation needs, and the grammar has no word for it (Animation).
a free-shape selection, drawn around some points with the mouse There is no lasso word in the kernel. Every other selection can be written down: brush(gdp, at = c(1200, 45000)) names a range, and the printed page then shows the plot that range asks for. A free shape can only be written as a long list of corners, which nobody writes in a sentence. The act was not refused, only the word: in the web edition you draw the shape with the mouse and the sentence still says brush.

The refusal a ggplot2 reader is most likely to meet is the first one, so here it is, live. Two series in different units, the second asking for its own scale:

quarterly: first 5 of 19 rows
year sales
2005 95
2006 104
2007 118
2008 112
2009 88
data(quarterly) + x(year) + y(sales) + line +
  point + y(sales, scale = "log")
(data(quarterly) + x(col.year) + y(col.sales) + line +
  point + y(col.sales, scale = "log"))
data(quarterly) + x(:year) + y(:sales) + line + point +
  y(:sales, scale = "log")
plot(data(quarterly), x(col.year), y(col.sales), line, point,
  y(col.sales, { scale: "log" }))
Error:
! gog: this layer's `y(sales, …)` gives it its own scale, and the layers of one plot share one y axis — two scales on it would be two coordinate spaces in one panel, where every mark reads against an axis the expression never named. Set the scale once for the plot, before the marks: `y(<column>, scale = "log")`. A layer may name its own *column* for the shared axis — `y(sales)` on its own is fine — but not its own scale. If the two series are genuinely in different units, facet on the measure instead.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

Every refusal in gog has that shape: it says what the rule is, and what to write instead.

F.3.2 The host language’s job

These are computations, not pictures. gog draws what you hand it, and the fitted value, the theoretical quantile and the reshaped model belong in R, Python, Julia or JavaScript, before the plot, where you already have better tools for them.

In ggplot2 Where it goes
stat_ecdf() compute the cumulative shares, then step
stat_qq(), stat_qq_line() compute the theoretical quantiles, then point and line
stat_ellipse() compute the ellipse, then path
stat_quantile() fit the quantile regression, then line
stat_function(), geom_function() evaluate the function onto a grid, then line
stat_unique() de-duplicate the table
cut_interval(), cut_number(), cut_width() cut the column, or use bin
mean_cl_boot() bootstrap a lower and an upper bound, then ribbon * bounds(lower, upper). Two helpers documented beside it are gog atoms instead: mean_sdl() is deviation, and median_hilow() is range(0.025, 0.975) with point * median
fortify(), autoplot(), map_data() get the model or the map boundaries in the host, then hand over a table
a correlation matrix cor() where the data lives, reshape it long, then zone + x(var1) + y(var2) + color(r)

The boundary is not as clean as that table makes it look, and pretending otherwise would be dishonest. gog does compute: bin cuts, density estimates, smooth fits a local regression, confidence builds an interval, deviation measures spread, and quantile cuts a distribution. The test is whether the computation is the picture. A histogram has no bars until something bins. A quantile regression is a separate answer that is then drawn as a line. Where the test lands in a new case is not settled.

F.3.3 Missing, with the direction already settled

gog does not draw these. Each one belongs to a family the grammar already has, and nothing about them is disputed.

In ggplot2 Here
labs(subtitle =, caption =) more of the label family; title() is the one that exists
theme(legend.position =) theme() is where this would be written. Every legend sits to the right of the plot area, and no setting moves it
geom_smooth(se = TRUE) the band around a smoother. confidence is the pair transform for a mean; a smoother has no matching one
geom_sf(), geom_map(), coord_sf(), borders() the map space draws, so a mark at a place on a projected earth is an ordinary sentence, and zone with group(country) fills each region from a boundary the data carries, holes included (Map). gog reads no spatial file. A boundary arrives as an ordinary table of points, which is the shape map_data() returns
geom_dotplot() a mesh that follows the data’s clusters, and a second rule for choosing bin edges
scale_x_binned(), equal-count bins the other cutpoint rule, the same parameter

F.3.4 Not decided either way

None of these has a ruling in either direction, so this table is the whole record of them.

In ggplot2 What is missing here
scale_*_continuous(breaks =, labels =) tick positions and tick text. tick_count asks for a number of ticks and the engine chooses them, so “tick at 0, 50 and 100” and relabeling a category cannot be written.
scale_*_manual(values = c(setosa = "red")) binding a color to a level by name, shown below
scale_y_reverse() a reversed continuous axis. order(desc = TRUE) reverses a categorical one.
scale_x_sqrt(), coord_trans() a power scale. The log10-style naming is refused; the capability never was.
guides(color = "none"), show.legend = FALSE turning a legend off. Every mapped channel earns one, always.
linewidth as an aesthetic a column mapped to stroke width. size can be set on a stroke for the whole layer, not mapped per row.
labs(alt = ), get_alt_text() alt text on the rendered plot
geom_label() text in a box, rather than bare text
geom_curve() a curved connector between two points; path joins them straight
after_stat(), after_scale(), stage(), and geom_count() with them sending a transform’s output to a channel rather than to a position. point * count + size(count) is refused, because the count a transform invented is not a column the sentence can name, so sizing a point by how many rows landed on it has no spelling. One question, and it is the widest one on this list.
geom_contour() over a supplied z iso-lines of a field you computed. path * density traces a field gog estimated.
geom_text(angle = ), and ggplot2’s polar text generally a rotated label. text places a name at a point in every space, upright, so a sunburst cannot run its labels along their arcs and small wedges collide. The gap is general to polar rather than belonging to that one plot.
theme(axis.text = element_blank()) and friends asking for a plot with no axis text. theme(grid = ) removes gridlines and tick_count = 0 is refused, so silence has to follow from what an axis carries rather than being asked for. A pie is recognized from its bindings. A packing has no axes at all. A partition’s radial axis carries the depth the transform invented, so it draws no ticks, no rings and no name. Three recognitions do one general job. What is left over is the reader who wants a real quantity’s axis silent: the measure around a sunburst takes x_label("") and keeps its numbers.

The color one is worth seeing, because it is not a missing setting so much as a missing kind of statement. Three species, three colors:

data(iris_flowers) + point + x(petal_length) + y(sepal_length) +
  color(species) + palette(c("tomato", "steelblue", "seagreen")) +
  title("Rows in the order the table has them")
(data(iris_flowers) + point + x(col.petal_length) + y(col.sepal_length) +
  color(col.species) + palette(["tomato", "steelblue", "seagreen"]) +
  title("Rows in the order the table has them"))
data(iris_flowers) + point + x(:petal_length) + y(:sepal_length) +
  color(:species) + palette(["tomato", "steelblue", "seagreen"]) +
  title("Rows in the order the table has them")
plot(data(iris_flowers), point, x(col.petal_length), y(col.sepal_length),
  color(col.species), palette(["tomato", "steelblue", "seagreen"]),
  title("Rows in the order the table has them"))
2 4 6 5 6 7 8 Rows in the order the table has them Sepal Length Petal Length Species setosa versicolor virginica

“Given the iris flowers: points, x is petal length, y is sepal length, color by species, with a palette of tomato, steelblue and seagreen.”

Now the same sentence and the same palette, over the same rows sorted the other way. Watch the legend:

by_species <- iris_flowers[base::order(iris_flowers$species, decreasing = TRUE), ]
data(by_species) + point + x(petal_length) + y(sepal_length) +
  color(species) + palette(c("tomato", "steelblue", "seagreen")) +
  title("The same three colors, the rows reversed")
2 4 6 5 6 7 8 The same three colors, the rows reversed Sepal Length Petal Length Species virginica versicolor setosa

Nothing is wrong here. A palette gives out its colors in the categories’ order, and that order is the axis’s order. For a column of text, the axis order is the order in which the values first appear. A declared order is honored when you supply one. It is consistent and it is documented. There is no way to say setosa is tomato. So a category cannot keep its color across two plots whose category order differs, and a report that puts two such plots side by side usually wants exactly that.

F.4 If your plot is not here

The sweep was of ggplot2’s reference index, so a plot that lives in one of its extensions may be missing from both columns. Networks are the largest such family, and gog draws them: Network is the node-link diagram, and Flow is the alluvial. One family has no drawing here: a filled band around a hollow, which is what a ring-shaped density needs. path * density traces that field correctly; zone * density, which fills the bands between the same lines, cannot.

For a plot you can name, Chart names is the dictionary and says which ones are drawn. For a question rather than a name, start at the cookbook.