28  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 in the same words, noting that “some tabled graphics are really two or more graphics glued together”, and 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 is semantic: one plot, one coordinate space, split by a variable and sharing everything. A page is presentational: each plot keeps its own space, its own marks, its own measurements. Which one you get is decided by what you write on the right of the operator, and nothing else needs saying.

28.1 Two plots, one page

(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

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

/ stacks the same two:

(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

Both operators take pages as well as plots, so a page nests: (a | b) / c is two plots in a row above one across the bottom. In R, and in Python and Julia, / binds tighter than |, so a | b / c reads as a | (b / c). Parenthesize when the reading matters.

28.2 A shared column is a shared axis

Two composed plots are unrelated until they name the same column on the same axis. 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 same axis in two composed plots is one axis: one scale, one panel extent, drawn once.

Here are two plots, both reading gdp on x:

(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

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 the bar edges and the point positions mean the same distance. 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 last part is why the histogram sits flush against the scatter rather than a tick label’s height above it. An axis a plot does not draw costs it no margin.

28.2.1 One axis, but not one key

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

Look at what that figure does twice and once. 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 keys, saying exactly the same thing.

The asymmetry is deliberate. An axis merges on a test that is exact: the same column on the same channel means one scale is provably right for both plots. A key 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 cut one column on two scales, and a single key over that is wrong for one of them. It is wrong quietly, because it still looks like a key.

gog will not merge a key it cannot promise, so it merges none. Identical keys side by side are the honest picture of two plots that happen to agree.

When you want one key over several panels, use faceting instead. A facet is one plot split by a column, so its panels share their colors by construction. It draws a single legend. The choice between | and facet() is the choice between two plots and one.

28.3 The marginal plot

Put a second marginal on the right and the picture is the marginal plot, which usually arrives as a chart type with a name of its own:

(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

Three ordinary plots and two operators. The top histogram shares gdp with the scatter, so it is aligned and squeezed to the scatter’s panel; the right-hand one shares life, so it is aligned to the scatter’s rows and the life axis is drawn once, on the left. The empty top-right corner is not a spacer anyone asked for and there is no atom for one: it is the room the shared extent left over.

Nothing about the marginals is special-cased. bar * bin + y(life) is the histogram you already know, lying down because that is what binding the measurement 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 cloud it summarizes, category for category:

(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

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

A plot states how much room it wants in pixels, and the plots that ask for nothing split what is left. That is the whole of the sizing above: the marginal histogram asks for 130 pixels of height, the scatter asks for nothing and takes the rest.

The same words size a plot drawn on its own, where the room in question is the 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.

Asking for more page than there is refuses, with the arithmetic in the message:

render_svg(
  (data(gapminder_2007) + point + x(gdp) + y(life) + theme(height = 500)) /
    (data(gapminder_2007) + point + x(gdp) + y(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.

28.5 And 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 says nothing at all: the cell is already as tall as the page. The page is the figure that has that height, and the page is what states it:

((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, one scope up. A plot states how much room it wants, and so does a page. Composed, the first is a cell of the second. A page that says nothing is 800 by 600, which is what a plot drawn alone takes.

This matters most where a mark cannot be stretched to fill its panel. A cube is fitted with one scale on both axes, so it is never sheared. 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 that is said.

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 those refuse, and name the plot they belong to:

render_svg(((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`.

28.6 What a page is not

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:

render_svg(((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:

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

28.7 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 reads “given”, straight out of conditional-probability notation. That is exactly what plot | facet(continent) says.

The composition reading is the one you will recognize if you use patchwork (Pedersen, 2019), which pairs p1 | p2 for side by side with p1 / p2 for one above the other. gog spells composition the same way on purpose: a reader who knows that package already knows this page, and a third spelling of an established idea would cost everyone something and buy nobody anything.

Composing pictures with operators is older than either. Henderson’s Functional Geometry (Henderson, 1982) set out an algebra of pictures whose combining operations are beside and above, which is the picture language SICP went on to teach and which Haskell’s diagrams library spells ||| and ===. That lineage is why the JavaScript binding, having no operators to overload, calls its two words beside() and below(): the names were already there.

One divergence is worth naming, since Wilkinson is this project’s usual tiebreaker. His graph algebra also has a /, and it means nest (a/b, “a within b”), not “below”. gog’s facet operators claim his crossing and deliberately do not claim his nesting, so / here is free to mean the page.

28.8 Layering, faceting, or composition?

The three answer different questions, and the choice is not a matter of taste:

  • 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 owns multi-table plots.
  • Faceting when the panels are the same measurement on different subsets. One scale, one legend, and the comparison between panels is the point.
  • Composition when the plots are different measurements that belong on one page. Each keeps its own space, and what they have in common, if anything, is the variable they share.

Layering is the one you have to verify, because it is the only one the engine cannot check for you. A table whose numbers mean something else still draws when you layer it. The columns resolve, the marks land, 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 places them. They share gdp, and that shared column is exactly what the rule above picks up.