29  Composition

How do you put two different plots on one page? Faceting splits one plot into panels. Composition does the opposite: it takes plots that were never one plot and arranges them on a page. Wilkinson keeps the two apart, noting that “some tabled graphics are really two or more graphics glued together”. gog keeps them apart in the operators you have already learned. | and / place things side by side and one above the other, whatever those things are:

A facet splits one plot by a column of its data. A page arranges finished plots, and says nothing about any plot’s data. Which one you get is decided by what you write on the right of the operator, and by nothing else.

29.1 Two plots, one page

Two views of one table often belong side by side, even when they share no axis. Write each plot in full, then put | between them:

gapminder_2007: first 5 of 142 rows
country continent year life population gdp
Afghanistan Asia 2007 43.828 31889923 974.5803
Albania Europe 2007 76.423 3600523 5937.0295
Algeria Africa 2007 72.301 33333216 6223.3675
Angola Africa 2007 42.731 12420476 4797.2313
Argentina Americas 2007 75.320 40301927 12779.3796
(data(gapminder_2007) + point + x(gdp) + y(life)) |
  (data(gapminder_2007) + bar * count + x(continent) + theme(tick_angle = 45))
((data(gapminder_2007) + point + x(col.gdp) + y(col.life)) |
  (data(gapminder_2007) + bar * count + x(col.continent) + theme(tick_angle = 45)))
(data(gapminder_2007) + point + x(:gdp) + y(:life)) |
  (data(gapminder_2007) + bar * count + x(:continent) +
  theme(tick_angle = 45))
beside(plot(data(gapminder_2007), point, x(col.gdp), y(col.life)),
  plot(data(gapminder_2007), layer(bar, count), x(col.continent),
  theme({ tick_angle: 45 })))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Asia Europe Africa Americas Oceania 0 20 40 Count Continent

“Given gapminder 2007: points, x is gdp, y is life, beside bars derived by count, x is continent.”

Two entirely different measurements: a country’s income against its life expectancy, and how many countries each continent holds. They share nothing, not a scale, not an axis, not a legend, because there is nothing to share. The page divides evenly between them.

/ places the same two one above the other:

(data(gapminder_2007) + point + x(gdp) + y(life)) /
  (data(gapminder_2007) + bar * count + x(continent))
((data(gapminder_2007) + point + x(col.gdp) + y(col.life)) /
  (data(gapminder_2007) + bar * count + x(col.continent)))
(data(gapminder_2007) + point + x(:gdp) + y(:life)) /
  (data(gapminder_2007) + bar * count + x(:continent))
below(plot(data(gapminder_2007), point, x(col.gdp), y(col.life)),
  plot(data(gapminder_2007), layer(bar, count), x(col.continent)))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Asia Europe Africa Americas Oceania 0 20 40 Count Continent

“Given gapminder 2007: points, x is gdp, y is life, above bars derived by count, x is continent.”

Both operators take pages as well as plots, so a page can hold another page. (a | b) / c is a row of two plots above a third. In R, Python and Julia, / is applied before |, so a | b / c reads as a | (b / c). Add parentheses when the reading matters. JavaScript spells the operators as words, so there is no order to remember. Its chapter shows the spelling.

29.2 A shared column is a shared axis

Two composed plots are unrelated until they name the same column on the axis the arrangement lines up: x for plots one above the other, and y for plots side by side. When they do, that is not two axes that happen to look alike; it is one axis, and gog treats it as one:

The same column on the aligned axis of two composed plots is one axis: one scale, one panel extent, drawn once.

Here are two plots, both reading gdp on x. The theme(height = 150) on the first asks for 150 pixels of the page’s height:

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

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

Look at what the rule did. The histogram’s panel is exactly as wide as the scatter’s, so a bar stands over the points it counts. Both plots measure gdp over the same range, so equal distances across the two panels stand for equal amounts of gdp. And the gdp axis is written once, along the bottom, because there is one axis: the same decision faceting makes when it puts tick labels only under the bottom row of panels.

That is why the histogram touches the scatter instead of standing a tick label’s height above it. A plot that draws no axis reserves no margin for one.

29.2.1 One axis, but not one legend

Having seen an axis merge, you might expect a shared legend to merge the same way. Compose two plots that both map color and you get two legends, one for each:

(data(gapminder_2007) + point + x(gdp) + y(life) + color(continent)) |
  (data(gapminder_2007) + point + x(population) + y(life) + color(continent))
((data(gapminder_2007) + point + x(col.gdp) + y(col.life) + color(col.continent)) |
  (data(gapminder_2007) + point + x(col.population) + y(col.life) + color(col.continent)))
(data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent)) |
  (data(gapminder_2007) + point + x(:population) + y(:life) +
  color(:continent))
beside(plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  color(col.continent)),
  plot(data(gapminder_2007), point, x(col.population), y(col.life),
  color(col.continent)))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Continent Asia Europe Africa Americas Oceania 0M 500M 1000M Population Continent Asia Europe Africa Americas Oceania

“Given gapminder 2007: points, x is gdp, y is life, color by continent, beside points, x is population, y is life, color by continent.”

Look at what that figure draws once, and what it draws twice. Both plots read life on y, so that axis is shared and written a single time, on the left. Both color by continent as well, and there you get two legends, saying exactly the same thing.

The asymmetry is deliberate. Axes merge because there is an exact test: the same column on the same axis means one scale is provably right for both plots. A legend has no such test. These two agree, but two composed plots are two plots, and nothing in the grammar makes them agree. The next pair may split by different columns, or map one column onto two scales. A single legend over that is wrong for one of them. Nothing in the picture shows the error, because it still looks like a legend.

gog merges no legend, because it cannot prove that one legend is right for both plots. Two identical legends side by side show two plots that agree.

When you want one legend over several panels, use faceting instead. A facet is one plot split by a column, so every panel is colored by the same scale. It draws a single legend. The choice between | and facet() is the choice between two plots and one.

29.3 The marginal plot

The histogram above the scatter is a marginal: one column’s distribution, drawn along the edge of the plot that uses it. Put a second marginal on the right and the picture is the marginal plot, which most tools offer as a named chart type:

(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.”

Three ordinary plots and two operators. The top histogram shares gdp with the scatter, so it is drawn exactly as wide as the scatter’s panel. The right-hand one shares life, so its panel is exactly as tall, and the life axis is drawn once on the left. The empty top-right corner has no atom of its own. It is the room left over once both shared axes have taken their width and height.

The marginals follow no special rule. bar * bin + y(life) is the histogram you already know, with its bars running left to right, because that is what binding the measure to y means. And the shared column need not be continuous: two plots reading the same category on x share its slots, so a box plot’s summary stands over the points it summarizes, one category at a time:

(data(gapminder_2007) + box + x(continent) + y(life) + theme(height = 150)) /
  (data(gapminder_2007) + point + x(continent) + y(gdp) + color(continent))
((data(gapminder_2007) + box + x(col.continent) + y(col.life) + theme(height = 150)) /
  (data(gapminder_2007) + point + x(col.continent) + y(col.gdp) + color(col.continent)))
(data(gapminder_2007) + box + x(:continent) + y(:life) +
  theme(height = 150)) /
  (data(gapminder_2007) + point + x(:continent) + y(:gdp) +
  color(:continent))
below(plot(data(gapminder_2007), box, x(col.continent), y(col.life),
  theme({ height: 150 })),
  plot(data(gapminder_2007), point, x(col.continent), y(col.gdp),
  color(col.continent)))
40 50 60 70 80 Life Asia Europe Africa Americas Oceania 0K 10K 20K 30K 40K 50K Gdp Continent Continent Asia Europe Africa Americas Oceania

“Given gapminder 2007: boxes, x is continent, y is life, above points, x is continent, y is gdp, color by continent.”

29.4 A clustered panel decides the order

A categorical axis needs an order, and most plots take the one the data arrived in. A panel with cluster computes a better one. cluster builds a tree of similar rows, and reading its leaves from left to right gives an order that puts similar things next to each other. When such a panel shares its axis, the shared axis takes that order.

A shared categorical axis takes the order a clustered panel computed for it.

The panels beside the clustered one stay plain, and their slots arrive in the tree’s order. gog refuses two panels that derive different orders for one shared axis, because one axis cannot hold two orders. Give both the same columns to cluster(), or cluster in one panel alone. The Cluster chapter uses this rule: a tile plot with a tree on two of its sides, each tree deciding one axis.

29.5 How thin is the marginal histogram? theme(width =, height =)

A plot states how much room it wants in pixels, and the plots that ask for nothing split what is left. A page that says nothing is 800 by 600, which is what a plot drawn alone takes. That is the whole of the sizing above: the marginal histogram, for example, asks for 130 pixels of height, the scatter asks for nothing and takes the rest.

The same two words also size a plot drawn on its own, where the room being divided is the whole image:

data(gapminder_2007) + point + x(gdp) + y(life) + theme(width = 420, height = 320)
data(gapminder_2007) + point + x(col.gdp) + y(col.life) + theme(width = 420, height = 320)
data(gapminder_2007) + point + x(:gdp) + y(:life) +
  theme(width = 420, height = 320)
plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  theme({ width: 420, height: 320 }))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp

One meaning in two places, which is Compositional Invariance: the sentence that says a plot is 130 pixels tall says it whether or not anything is composed with it. Do not confuse it with theme(ratio = ), which shapes the panel inside whatever room the plot was given and never resizes the image.

Plots that ask for more height than the page has are refused, and the message shows the arithmetic:

(data(gapminder_2007) + point + x(gdp) + y(life) + theme(height = 500)) /
  (data(gapminder_2007) + point + x(gdp) + y(life) + theme(height = 500))
((data(gapminder_2007) + point + x(col.gdp) + y(col.life) + theme(height = 500)) /
  (data(gapminder_2007) + point + x(col.gdp) + y(col.life) + theme(height = 500)))
(data(gapminder_2007) + point + x(:gdp) + y(:life) +
  theme(height = 500)) /
  (data(gapminder_2007) + point + x(:gdp) + y(:life) +
  theme(height = 500))
below(plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  theme({ height: 500 })),
  plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  theme({ height: 500 })))
Error:
! gog: the plots below each other ask for 1000px of height between them, and the page has 600. A `theme(height = )` on a composed plot is how much of the page that plot takes, so the ones that state it must leave room for the ones that do not.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

29.6 How big is the page itself?

Two plots set side by side divide the page’s width, and each keeps the whole of its height. So a height written into one of them changes nothing. Each plot sits in a cell of the page, and a cell in a side-by-side page is already as tall as the page. The height belongs to the page, so the page is where it is written:

((data(gapminder_2007) + point + x(gdp) + y(life)) |
   (data(gapminder_2007) + bar * count + x(continent))) + theme(height = 260)
(((data(gapminder_2007) + point + x(col.gdp) + y(col.life)) |
   (data(gapminder_2007) + bar * count + x(col.continent))) + theme(height = 260))
((data(gapminder_2007) + point + x(:gdp) + y(:life)) |
  (data(gapminder_2007) + bar * count + x(:continent))) +
  theme(height = 260)
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Asia Europe Africa Americas Oceania 0 20 40 Count Continent

The same two words, now written on the page instead of the plot. A plot states how much room it wants, and so does a page. Composed, the first is a cell of the second.

This matters most where a mark cannot be stretched to fill its panel. A cube is drawn with one scale in both screen directions, so its shape is never stretched. A tall, narrow cell therefore leaves it small, with empty bands above and below. The figure is the wrong shape, and the page is where you fix it.

Size is the only thing a page can say. Every other property theme() sets describes a panel: its gridlines, its frame, its background, the angle its tick labels are read at. A page has no panel, so each of those properties is refused, and the refusal names the plot it belongs to:

((data(gapminder_2007) + point + x(gdp) + y(life)) |
  (data(gapminder_2007) + bar * count + x(continent))) + theme(grid = "none")
(((data(gapminder_2007) + point + x(col.gdp) + y(col.life)) |
  (data(gapminder_2007) + bar * count + x(col.continent))) + theme(grid = "none"))
((data(gapminder_2007) + point + x(:gdp) + y(:life)) |
  (data(gapminder_2007) + bar * count + x(:continent))) +
  theme(grid = "none")
Error:
! gog: `theme(grid = )` describes a panel, and a page is plots arranged rather than a panel of its own. On a page, `theme()` states how big the figure is — `theme(width = )` and `theme(height = )` — and nothing else. Write this into the plot it describes, before composing: `(plot + theme(grid = )) | other_plot`.

29.7 What a page is not

A title over two plots seems to belong to the page rather than to either plot, so you may write it there. A page is plots arranged, so anything belonging to one plot is written into that plot before it is composed. Apart from the size above, an atom added to a page refuses and says so:

((data(gapminder_2007) + point + x(gdp) + y(life)) |
  (data(gapminder_2007) + bar * count + x(continent))) + title("Two views")
(((data(gapminder_2007) + point + x(col.gdp) + y(col.life)) |
  (data(gapminder_2007) + bar * count + x(col.continent))) + title("Two views"))
((data(gapminder_2007) + point + x(:gdp) + y(:life)) |
  (data(gapminder_2007) + bar * count + x(:continent))) +
  title("Two views")
Error:
! gog: `title()` belongs to a plot, and the left side is a page of them. Write it into the plot it describes, before composing: `(plot + title("...")) | other_plot`.

And a page cannot be faceted, because a facet splits one plot by a column and a page is not one plot:

((data(gapminder_2007) + point + x(gdp) + y(life)) |
  (data(gapminder_2007) + bar * count + x(continent))) | facet(continent)
(((data(gapminder_2007) + point + x(col.gdp) + y(col.life)) |
  (data(gapminder_2007) + bar * count + x(col.continent))) | facet(col.continent))
((data(gapminder_2007) + point + x(:gdp) + y(:life)) |
  (data(gapminder_2007) + bar * count + x(:continent))) |
  facet(:continent)
Error:
! gog: `|` faceted a page of plots, and a facet splits *one* plot by a column. Facet the plots before composing them: `(plot | facet(g)) | other_plot`.

Facet first, then compose: (plot | facet(g)) | other_plot is a faceted plot beside another plot, and it is perfectly legal.

29.8 Where these operators come from

Neither reading of | is this grammar’s invention, and the two were borrowed from different places.

The facet reading is the older. Trellis display wrote a conditioned plot as y ~ x | g (Becker et al., 1996), and lattice writes it that way still; the bar means “given”, which is the bar written in conditional probability. That is exactly what plot | facet(continent) says.

The composition reading comes from patchwork (Pedersen, 2019). That package writes p1 | p2 for side by side and p1 / p2 for one above the other. gog spells composition the same way on purpose: a reader who knows that package already knows this chapter, and a third spelling of a settled idea would help nobody.

Composing pictures with operators is older than either. Henderson’s Functional Geometry (Henderson, 1982) describes an algebra of pictures, and its two combining operations are beside and above. Structure and Interpretation of Computer Programs (SICP) teaches the same picture language, and Haskell’s diagrams library spells the two operations ||| and ===. The JavaScript binding has no operators to overload, so it names its two functions beside() and below() after that older algebra.

One divergence is worth naming, since Wilkinson settles most questions in this project. His graph algebra also has a /, and it means nest (a/b, “a within b”), not “below”. gog’s facet operators take his crossing and do not use his nesting, so / here can mean the page.

29.9 Layering, faceting, or composition?

The three answer different questions, so your data decides which one you want:

  • Layering when the marks are different tables on the same measurement. One coordinate space, one pair of axes, and the second table annotates the first: a threshold, a band, a callout, a forecast. Data explains multi-table plots.
  • Faceting when the panels are the same measurement on different subsets. One scale, one legend, and the comparison between panels is what you gain.
  • Composition when the plots are different measurements that belong on one page. Each keeps its own space, and the only thing they may share is a column.

Layering is the one you have to verify, because it is the only one the engine cannot check for you. A second table that measures something the axes do not measure still draws when you layer it. The columns resolve, the marks are drawn, and the picture is wrong. Sales figures on an income axis are a legal sentence. So if the second table does not measure what the axes measure, compose two plots instead of layering one onto the other.

A marginal plot is composition because the histogram and the scatter measure different things: one counts countries, the other puts each country at its own position. They share gdp, and that shared column is exactly what the rule above matches.