25  Transforms

How do you plot a number your table does not hold? A transform computes it for you and hands the result to a mark. It runs after the table is read and before anything is drawn, and the mark stays what it is: bar * bin still draws bars.

The family has other names. ggplot2 says stat, and the chapter Wilkinson devotes to it says statistics (Wilkinson, 2005); SQL says aggregate, dplyr says summarize, a spreadsheet says pivot. Each of those covers about half of what gog groups here. mean, count and density really are statistics, and they replace rows with a summary. But bin cuts an axis into cells. bounds reshapes two columns you already have and computes nothing at all. stack, dodge, jitter and repel are about collisions between marks rather than about the values behind them. Calling all of that a statistic would misname most of it, so the word here is the most general one that is still accurate: a transform is anything that changes what reaches the mark.

Transforms are applied with the * operator:

mark * transform

The result is the same mark drawing derived values, not a new atom to memorize.

Transform What it does
bin cuts a continuous axis into equal-width cells, and counts the rows in each
smooth fits a LOESS curve (locally weighted regression) through the (x, y) pairs
straight fits the least-squares line through the (x, y) pairs
count counts the rows at each distinct x
density estimates the distribution of x with a Gaussian kernel
proportion divides the measurement by its total, so the shares sum to 1
sum totals y within each x
mean averages y within each x
median the middle y within each x
max the largest y within each x
min the smallest y within each x
quantile the y at one probability within each x, quantile(0.9)
cumulative adds each position’s measurement to the ones before it: the running total
range a band of y per group, as a pair; the whole group, or two quantiles you name
confidence the mean’s confidence interval per group: low, high and center
deviation the spread of y per group: the mean plus and minus a standard deviation
smooth_band the confidence band of smooth’s curve: low, high and center along it
straight_band the confidence band of straight’s line: low, high and center along it

The middle six (sum, mean, median, max, min, quantile) form the aggregation family. All share the same pattern: group by x, reduce y to one value. range, confidence and deviation group the same way but write a low and a high, the extents an interval spans. smooth_band and straight_band write the same pair along a fitted curve and a fitted line. These five and bounds, which comes later in this chapter, are the pair transforms.

Some transforms write their result into a new column, such as count or name, that your table never held. A sentence can name that column, and Columns a transform writes lists every one.

25.1 One variable, or two?

Grouping the transforms by what they compute is not the only useful way to sort them. Seventeen of them also fall into two families by how many columns they read, and reading one column or two is what decides whether you write y(). The other ten of the kernel’s twenty-seven transforms come later in this chapter. bounds reshapes rather than computes. partition, flow, layout and cluster each compute a whole picture, such as a mosaic or a network. dodge, stack, jitter and repel move marks that would otherwise overlap. cumulative adds up a measurement that another transform or a column supplies.

Counting transforms (bin, count, density) supply their own measurement: no column of your table holds it. Given a single variable, they show how its rows are spread. bin and count tally the rows in each bin or category. density estimates the same spread as a smooth curve, whose height is a density rather than a number of rows. proportion joins them when no other transform is written with it, because then the only measurement it has to rescale is the tally it computes itself. You name only the variable whose shape you are asking about, and the transform writes the y column and labels its axis (“Count”, “Proportion”, “Density”). bar * bin + x(life) and line * density + x(life) are the same grammatical shape: one name in, a new axis out. Leaving out y() here is not a mistake. There is exactly one sensible y, the count or the density, and the engine supplies it. If you name a column of the table on that axis, as in bar * count + x(continent) + y(life), gog refuses it, because the transform would never read that column. gog refuses any name except the transform’s own (The name on a measure axis).

Reading transforms (smooth, straight, sum, mean, median, max, min, quantile, range, confidence, deviation, smooth_band, straight_band) read two columns and summarize the second as it varies with the first. They fit or aggregate an existing y, so you must bind one: without y(gold), bar * mean + x(country) has no way to guess which column to average. Leave y() off and the engine does not guess: only you know which column was meant, so it stops and names what is missing (add y(<column>)). range, confidence, deviation, smooth_band and straight_band are the five that read a y like the others but write more than one: range a low and a high, the other four a low, high and center.

So whether you write y() is not an arbitrary rule to memorize for each transform. The family a transform belongs to decides it. This is one instance of Explicit Over Implicit: the short form is allowed exactly when it cannot be misread, and refused when it could.

Both families replace your rows with a summary of them, and this chapter calls any transform that does so a statistic. A statistic runs within each group when a channel that splits the rows into groups is bound: color, group, pattern or shape. bar * bin + color(species) draws one histogram per species, not one combined bar that has discarded the split (see overlaid histograms). The same rule gives a density curve, a count, or a mean per group. Each output row keeps the group it came from. The mark colors it by that group, and the legend lists the same groups.

25.2 bin: histogram

The first question to ask of one continuous column is how its values are spread. Where are they dense, where are they sparse, and is there one peak or two?

bin is the first counting transform. It cuts x into equal-width bins and counts the rows in each. Combine it with bar to draw a histogram:

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) + bar * bin + x(life) +
  x_label("Life expectancy (years)") +
  title("Histogram of life expectancy, 2007")
(data(gapminder_2007) + bar * bin + x(col.life) +
  x_label("Life expectancy (years)") +
  title("Histogram of life expectancy, 2007"))
data(gapminder_2007) + bar * bin + x(:life) +
  x_label("Life expectancy (years)") +
  title("Histogram of life expectancy, 2007")
plot(data(gapminder_2007), layer(bar, bin), x(col.life),
  x_label("Life expectancy (years)"),
  title("Histogram of life expectancy, 2007"))
50 60 70 80 0 10 20 30 Histogram of life expectancy, 2007 Count Life expectancy (years)

“Given gapminder 2007: bars derived by bin, x is life.”

bin is not the bar’s own transform. Four other marks cut the same bins, and only the shape changes:

Mark derived by bin Shape
point * bin one dot at the top of each bin
line * bin the frequency polygon: straight lines through the bin centers
area * bin the same polygon, filled to the baseline
step * bin the staircase outline
bar * bin filled bars, the histogram above

zone * bin cuts two axes instead of one, which is the heatmap, and surface * bin raises the same cells into the cube.

Bin count defaults to Sturges’ rule (Sturges, 1926): k = ⌈log₂(n)⌉ + 1, where n is the number of rows and k the number of bins. In words, each time the number of rows doubles, the histogram gets one more bin. Notice there is no y() in the expression: bin writes the count column itself, and the y axis is labeled “Count” automatically.

25.2.1 Choosing the bin count

A histogram’s shape depends on how it is cut, and the default cut may hide a gap or split a peak. You will often want a finer cut, or bins whose width means something in the data’s units.

Sturges’ rule is a sensible default, not a mandate. Pass a number to set the bin count, or name a width to fix the bin width in the data’s own units:

data(gapminder_2007) + bar * bin(30) + x(life) +
  x_label("Life expectancy (years)") + title("Thirty bins: a finer histogram")
(data(gapminder_2007) + bar * bin(30) + x(col.life) +
  x_label("Life expectancy (years)") + title("Thirty bins: a finer histogram"))
data(gapminder_2007) + bar * bin(30) + x(:life) +
  x_label("Life expectancy (years)") +
  title("Thirty bins: a finer histogram")
plot(data(gapminder_2007), layer(bar, bin(30)), x(col.life),
  x_label("Life expectancy (years)"),
  title("Thirty bins: a finer histogram"))
40 50 60 70 80 0 5 10 15 Thirty bins: a finer histogram Count Life expectancy (years)

“Given gapminder 2007: bars derived by bin into 30, x is life.”

data(gapminder_2007) + bar * bin(width = 5) + x(life) +
  x_label("Life expectancy (years)") + title("Bins five years wide")
(data(gapminder_2007) + bar * bin(width = 5) + x(col.life) +
  x_label("Life expectancy (years)") + title("Bins five years wide"))
data(gapminder_2007) + bar * bin(width = 5) + x(:life) +
  x_label("Life expectancy (years)") + title("Bins five years wide")
plot(data(gapminder_2007), layer(bar, bin({ width: 5 })), x(col.life),
  x_label("Life expectancy (years)"), title("Bins five years wide"))
40 50 60 70 80 0 10 20 30 40 Bins five years wide Count Life expectancy (years)

“Given gapminder 2007: bars derived by bin at width 5, x is life.”

The positional number is the count: bin(30) is thirty bins. A width is the less common request, so it is said out loud, bin(width = 5); the two are mutually exclusive. gog refuses bin(30, width = 5) rather than guess which one you meant. For the same reason a bare number always means a count: on this data, five bins and bins five years wide are very different plots.

A width also decides where the bins start: every edge is a multiple of the width. The five-year bins above therefore run from 35 to 40, from 40 to 45, and so on, not from the smallest value, 39.6. Where the bin edges fall explains why, and how ggplot2 places its edges instead.

25.2.2 Choosing a width

When you give a count of bins, you decide only how much detail to show: bin(20) draws fewer, wider bars than bin(30) on any column. When you give a width, you decide more, because a width is measured in the column’s own units. Use a width when each bin should cover a span that means something, such as five years of life expectancy or half a centimeter of petal length. Because every edge is a multiple of the width, two histograms cut at the same width share their edges, even when their data cover different ranges. You can then compare them bin by bin.

To choose a width, first decide roughly how many bars you want. Divide the column’s range, its largest value minus its smallest, by that number. Then take a nearby round number in the column’s units, such as 0.2, 0.5, 1, 2, 5 or 10. Life expectancy runs from 39.6 to 82.6 years, a range of 43. Ten bars would each be 4.3 years wide, and the nearest round number is 5.

One more rule prevents tall bars and empty bins that come from the cut rather than from the data. The width should be a whole multiple of the distance between two neighboring values the column can hold. quakes_fiji holds a thousand earthquakes near Fiji, and it records each magnitude to one decimal place, so its possible values are 4.0, 4.1, 4.2 and so on, 0.1 apart:

quakes_fiji: first 5 of 1000 rows
east north elevation magnitude slab
181.62 -20.42 -562 4.8 540-630 km
181.03 -20.62 -650 4.2 630-720 km
184.10 -26.00 -42 5.4 0-90 km
181.66 -17.97 -626 4.1 540-630 km
181.96 -20.42 -649 4.0 630-720 km

A width of 0.25 is not a whole multiple of 0.1. Its bins alternate between three possible values and two, so a bin with three collects more earthquakes for that reason alone:

data(quakes_fiji) + bar * bin(width = 0.25) + x(magnitude) +
  x_label("Magnitude") + title("Width 0.25: bins of three and two values")
(data(quakes_fiji) + bar * bin(width = 0.25) + x(col.magnitude) +
  x_label("Magnitude") + title("Width 0.25: bins of three and two values"))
data(quakes_fiji) + bar * bin(width = 0.25) + x(:magnitude) +
  x_label("Magnitude") + title("Width 0.25: bins of three and two values")
plot(data(quakes_fiji), layer(bar, bin({ width: 0.25 })),
  x(col.magnitude), x_label("Magnitude"),
  title("Width 0.25: bins of three and two values"))
4.5 5.0 5.5 6.0 0 100 200 300 Width 0.25: bins of three and two values Count Magnitude

The bar from 4.5 to 4.75 is far taller than the bars on either side. It holds 4.5, 4.6 and 4.7, while each neighbor holds two: 4.3 and 4.4 before it, 4.8 and 4.9 after it. A width of 0.2 puts two possible values in every bin, and the shape is smooth:

data(quakes_fiji) + bar * bin(width = 0.2) + x(magnitude) +
  x_label("Magnitude") + title("Width 0.2: two values in every bin")
(data(quakes_fiji) + bar * bin(width = 0.2) + x(col.magnitude) +
  x_label("Magnitude") + title("Width 0.2: two values in every bin"))
data(quakes_fiji) + bar * bin(width = 0.2) + x(:magnitude) +
  x_label("Magnitude") + title("Width 0.2: two values in every bin")
plot(data(quakes_fiji), layer(bar, bin({ width: 0.2 })), x(col.magnitude),
  x_label("Magnitude"), title("Width 0.2: two values in every bin"))
4 5 6 0 100 200 Width 0.2: two values in every bin Count Magnitude

The same rule applies when you give a number of bins, because the number sets the width. Suppose a column holds whole numbers from 9 to 35, so only 27 values are possible. Thirty bins are each 0.87 wide, and at least three must stay empty. For one bar per value, use bar * count, which counts the rows at each value instead of binning them.

Two kinds of axis treat a width differently. A time axis refuses a width, as What a time axis refuses shows, so give the number of bins there. On a log axis the bins are cut on the logarithms, so a width counts powers of the base. In the default base 10, bin(width = 1) makes bins from 100 to 1,000 and from 1,000 to 10,000. In base 2, each bin ends at twice its start.

25.2.3 Why bar * bin, not a histogram() shortcut?

Because bin does the same computation regardless of the mark, and the mark decides how the bins are drawn. bar * bin fills bars. line * bin connects the bin centers into a frequency polygon, and step * bin traces the histogram as a staircase outline. Add stack and a fourth reading appears, the dot plot further down this chapter: one dot per observation rather than one mark per bin. Adding a histogram() shortcut would violate Law 2 (No Exceptions) by special-casing a particular mark+transform pair.

25.2.4 The same bin, cutting two axes

All twelve gapminder years on one scatter make 1704 points, and where they overlap you cannot see how dense the points are. The answer is to count rows in cells of the plane, not only along one axis.

The mark decides something else too: how many axes get cut. Each mark above leaves one axis free to measure the count along, so bin cuts the other one. A zone measures nothing by length, and it needs an extent on both axes to be a rectangle. So the same word cuts both, and the count goes to color, the only channel left holding a number:

gm_all: first 5 of 1704 rows
country continent year life population gdp
Afghanistan Asia 1952 28.801 8425333 779.4453
Afghanistan Asia 1957 30.332 9240934 820.8530
Afghanistan Asia 1962 31.997 10267083 853.1007
Afghanistan Asia 1967 34.020 11537966 836.1971
Afghanistan Asia 1972 36.088 13079460 739.9811
data(gm_all) + zone * bin(24) + x(gdp, scale = "log") + y(life) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("One bin, two axes: the heatmap")
(data(gm_all) + zone * bin(24) + x(col.gdp, scale = "log") + y(col.life) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("One bin, two axes: the heatmap"))
data(gm_all) + zone * bin(24) + x(:gdp, scale = "log") + y(:life) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("One bin, two axes: the heatmap")
plot(data(gm_all), layer(zone, bin(24)), x(col.gdp, { scale: "log" }),
  y(col.life), x_label("GDP per person"), y_label("Life expectancy"),
  title("One bin, two axes: the heatmap"))
1K 10K 100K 40 60 80 One bin, two axes: the heatmap Life expectancy GDP per person Count 40 20.5 1

“Given all the gapminder years: zones derived by bin into 24, x is gdp on a log scale, y is life.”

That is a heatmap, with no atom added to the kernel.

25.2.5 One of each: the mixed mesh

Five continents make five histograms, and comparing five panels is harder than comparing five rows of one picture.

The rule that a zone cuts both axes needs one qualification. What bin cuts is every axis that has a width to cut, and a categorical axis has none, because it arrived already cut: a category is a cell. Give a zone one of each and the transform does the only coherent thing, which is to cut the continuous axis and not the categorical one:

data(gm_all) + zone * bin(20) + x(life) + y(continent) +
  palette("viridis") + title("One axis cut, one already cut")
(data(gm_all) + zone * bin(20) + x(col.life) + y(col.continent) +
  palette("viridis") + title("One axis cut, one already cut"))
data(gm_all) + zone * bin(20) + x(:life) + y(:continent) +
  palette("viridis") + title("One axis cut, one already cut")
plot(data(gm_all), layer(zone, bin(20)), x(col.life), y(col.continent),
  palette("viridis"), title("One axis cut, one already cut"))
40 60 80 Oceania Americas Africa Europe Asia One axis cut, one already cut Continent Life Count 91 46 1

“Given all the gapminder years: zones derived by bin into 20, x is life, y is continent, with the viridis palette.”

Each continent gets a row of cells, and every row is cut on the same edges, so you can compare the continents by reading straight down. It is a distribution per category shown as shade rather than as height, which is why five of them fit where one histogram would go. The Zone chapter explains this plot, the heatmap and the tile plot below with one rule.

25.2.6 Which mesh: tiling

bin also decides the shape of the cells it cuts, because a different mesh puts different rows in different cells and so changes the counts. Rectangles are the default. Hexagons stagger alternate rows, so a reader does not mistake the straight rows of the mesh for a pattern in the data (Carr et al., 1987):

data(gm_all) + zone * bin(20, tiling = "hex") + x(gdp, scale = "log") + y(life) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Carr's staggered mesh: tiling = hex")
(data(gm_all) + zone * bin(20, tiling = "hex") + x(col.gdp, scale = "log") + y(col.life) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Carr's staggered mesh: tiling = hex"))
data(gm_all) + zone * bin(20, tiling = "hex") + x(:gdp, scale = "log") +
  y(:life) + x_label("GDP per person") + y_label("Life expectancy") +
  title("Carr's staggered mesh: tiling = hex")
plot(data(gm_all), layer(zone, bin(20, { tiling: "hex" })),
  x(col.gdp, { scale: "log" }), y(col.life), x_label("GDP per person"),
  y_label("Life expectancy"),
  title("Carr's staggered mesh: tiling = hex"))
1K 10K 100K 40 60 80 Carr's staggered mesh: tiling = hex Life expectancy GDP per person Count 43 22 1

“Given all the gapminder years: zones derived by bin into 20 on hexagons, x is gdp on a log scale, y is life.”

A tiling means nothing to a bin that cuts one axis, since there the cells are intervals and an interval has no shape, so bar * bin(tiling = ) is refused pointing at zone. The Zone chapter works through both meshes, including why an empty cell is left unpainted.

25.3 count: frequency aggregation

The first question to ask of a categorical column is how many rows fall in each category. A table with one row per country does not hold that number anywhere.

count counts the number of rows for each unique value of x, and it works on both categorical and continuous columns. It is the categorical counterpart to bin: bin groups a continuous column; count groups a categorical one.

# How many countries are in each continent?
data(gapminder_2007) + bar * count + x(continent) +
  title("Countries per continent, 2007")
(data(gapminder_2007) + bar * count + x(col.continent) +
  title("Countries per continent, 2007"))
data(gapminder_2007) + bar * count + x(:continent) +
  title("Countries per continent, 2007")
plot(data(gapminder_2007), layer(bar, count), x(col.continent),
  title("Countries per continent, 2007"))
Asia Europe Africa Americas Oceania 0 20 40 Countries per continent, 2007 Count Continent

“Given gapminder 2007: bars derived by count, x is continent.”

count is not the bar’s own transform. Five other marks tally the same rows, and only the shape changes:

Mark derived by count Shape
point * count one dot at each count
line * count the counts joined into a profile
area * count the same profile, filled to the baseline
step * count each count held flat across its slot
zone * count one cell per pair of categories, colored by its count
bar * count one bar per category, the plot above

Compare these bars with the histogram at the start of the chapter. Histogram bars touch, because the bins are cut from one continuous axis and no value falls between them. Bar chart bars are drawn with a gap between them, because the categories are separate.

Like bin, count writes its own y column and labels the axis “Count”. To title that axis yourself, use y_label().

25.3.1 The same count, tallying two axes

With two categorical columns the question becomes how often each pair occurs, such as how many winds came from the west in winter.

bin cuts one axis on a bar and two on a zone, and count works the same way, for the same reason. A bar has a length to show the tally along, so it groups by one axis; a zone measures nothing by length, so it tallies rows into the cell where two categories cross and the count goes to color:

winds: first 5 of 264 rows
direction bearing speed season
N 19.612260 10.1 Winter
N 357.049499 11.0 Winter
N 349.709737 13.8 Winter
N 2.602499 7.4 Winter
N 338.210715 3.7 Summer
data(winds) + zone * count + x(direction) + y(season) +
  title("One count, two axes: the tile plot")
(data(winds) + zone * count + x(col.direction) + y(col.season) +
  title("One count, two axes: the tile plot"))
data(winds) + zone * count + x(:direction) + y(:season) +
  title("One count, two axes: the tile plot")
plot(data(winds), layer(zone, count), x(col.direction), y(col.season),
  title("One count, two axes: the tile plot"))
N NE E SE S SW W NW Winter Summer One count, two axes: the tile plot Season Direction Count 38 22 6

That is the tile plot (a confusion matrix is the same sentence with different columns), and like the heatmap, no atom was added to the kernel.

Together, bin and count state the whole rule: bin cuts, count tallies. A continuous axis has to be cut into cells before there is anything to count in; a categorical axis arrives already cut, because a category is a cell. So on a zone each is refused where the other belongs, and each refusal names the other.

Taking the two axes one at a time gives three plots, not two. Cut both axes and it is the heatmap; cut neither and it is the tile plot; cut one of two and it is the mixed mesh above. A bin is refused only where there is nothing left for it to cut at all.

25.4 proportion: relative frequency

Fifty-two of the 142 countries are in Africa, and that fact reads differently as a count than as a share of the whole.

proportion divides a measurement by its total and writes shares in [0, 1] that sum to 1. In statistics they are called relative frequencies. Used on its own it has nothing to divide yet, so it tallies the rows first exactly as count does:

# What share of countries belongs to each continent?
data(gapminder_2007) + bar * proportion + x(continent) +
  title("Continent share, 2007")
(data(gapminder_2007) + bar * proportion + x(col.continent) +
  title("Continent share, 2007"))
data(gapminder_2007) + bar * proportion + x(:continent) +
  title("Continent share, 2007")
plot(data(gapminder_2007), layer(bar, proportion), x(col.continent),
  title("Continent share, 2007"))
Asia Europe Africa Americas Oceania 0.0 0.1 0.2 0.3 Continent share, 2007 Proportion Continent

“Given gapminder 2007: bars derived by proportion, x is continent.”

The same six marks read a share as they read a count:

Mark derived by proportion Shape
point * proportion one dot at each share
line * proportion the shares joined into a profile
area * proportion the same profile, filled to the baseline
step * proportion each share held flat across its slot
zone * proportion one cell per pair of categories, colored by its share
bar * proportion one bar per category, the plot above

Use proportion when you care about relative composition rather than raw counts, for example “what fraction of respondents chose each option?” count and proportion differ only by a constant factor, which is the total. So order works the same on both: order(count) after count and order(proportion) after proportion give the same order. Here the bars are horizontal, with the largest share at the top:

data(gapminder_2007) + bar * proportion + y(continent) +
  order(proportion, desc = TRUE) +
  title("Continent share, largest first")
(data(gapminder_2007) + bar * proportion + y(col.continent) +
  order(col.proportion, desc = True) +
  title("Continent share, largest first"))
data(gapminder_2007) + bar * proportion + y(:continent) +
  order(:proportion, desc = true) +
  title("Continent share, largest first")
plot(data(gapminder_2007), layer(bar, proportion), y(col.continent),
  order(col.proportion, { desc: true }),
  title("Continent share, largest first"))
0.0 0.1 0.2 0.3 Oceania Americas Europe Asia Africa Continent share, largest first Continent Proportion

“Given gapminder 2007: bars derived by proportion, y is continent, ordered by proportion, largest first.”

To sort by the shares, name the column the transform writes, proportion. A column of numbers from your table, such as gdp, is refused in order() after proportion, as Your table’s columns after a transform explains.

proportion also works on two axes, as count does, so zone * proportion reads a tile plot as shares (see Zone).

25.4.1 The total is the whole plot, always

Once a second column splits the bars, each segment could be a share of its own bar or of the whole plot.

The denominator is every row the plot draws, in every context. That is the one thing the word is allowed to mean. Split the bars by a second column and they still sum to 1 between them, so each bar reads as this pair’s share of everything:

data(winds) + bar * proportion + x(direction) + color(season) +
  title("Every bar is a share of all 264 observations")
(data(winds) + bar * proportion + x(col.direction) + color(col.season) +
  title("Every bar is a share of all 264 observations"))
data(winds) + bar * proportion + x(:direction) + color(:season) +
  title("Every bar is a share of all 264 observations")
plot(data(winds), layer(bar, proportion), x(col.direction),
  color(col.season),
  title("Every bar is a share of all 264 observations"))
N NE E SE S SW W NW 0.00 0.05 0.10 0.15 Every bar is a share of all 264 observations Proportion Direction Season Summer Winter

“Given the winds: bars derived by proportion, x is direction, color by season.”

Add up the sixteen segments there and you get 1, not 2. What you cannot get that way is the conditional reading: “of the summer winds, what share came from the west”. That reading is the facet. It cuts the data into panels before any of this runs, so each panel normalizes inside itself:

data(winds) + bar * proportion + x(direction) | facet(season)
data(winds) + bar * proportion + x(col.direction) | facet(col.season)
data(winds) + bar * proportion + x(:direction) | facet(:season)
plot(data(winds), layer(bar, proportion), x(col.direction),
  across(col.season))
N NE E SE S SW W NW 0.0 0.1 0.2 0.3 N NE E SE S SW W NW Summer Winter Proportion Direction

“Given the winds: bars derived by proportion, x is direction, split into panel columns by season.”

Compare the two plots. In the first, summer’s bars and winter’s bars share one total. In the second, each season has its own.

25.4.2 Normalizing something other than a tally

Two histograms from tables of different sizes cannot be compared by their counts, and a continent’s population means more as a share of the world’s.

Because proportion divides whatever measurement it finds, it composes with any transform that leaves one number per cell. Put it after a bin and the histogram’s own counts come back as fractions, the relative-frequency histogram, on exactly the mesh a plain bar * bin cuts:

data(gm_all) + bar * bin(20) * proportion + x(life) +
  title("Shares, not counts")
(data(gm_all) + bar * bin(20) * proportion + x(col.life) +
  title("Shares, not counts"))
data(gm_all) + bar * bin(20) * proportion + x(:life) +
  title("Shares, not counts")
plot(data(gm_all), layer(bar, bin(20), proportion), x(col.life),
  title("Shares, not counts"))
40 60 80 0.00 0.05 0.10 Shares, not counts Proportion Life

“Given all the gapminder years: bars derived by bin into 20 and proportion, x is life.”

Put it after a summary and each slot reads as its share of the grand total. y(population) still names the column that sum adds up. The axis shows shares, so it is titled Proportion:

data(gapminder_2007) + bar * sum * proportion + x(continent) + y(population) +
  title("Each continent's share of world population, 2007")
(data(gapminder_2007) + bar * sum * proportion + x(col.continent) + y(col.population) +
  title("Each continent's share of world population, 2007"))
data(gapminder_2007) + bar * sum * proportion + x(:continent) +
  y(:population) +
  title("Each continent's share of world population, 2007")
plot(data(gapminder_2007), layer(bar, sum, proportion), x(col.continent),
  y(col.population),
  title("Each continent's share of world population, 2007"))
Asia Europe Africa Americas Oceania 0.0 0.2 0.4 0.6 Each continent's share of world population, 2007 Proportion Continent

“Given gapminder 2007: bars derived by sum and proportion, x is continent, y is population.”

proportion writes the shares to a column called proportion, as it does after bin. A text layer with the same two transforms can print each share on its bar:

data(gapminder_2007) + bar * sum * proportion + x(continent) + y(population) +
  text * sum * proportion + label(proportion, decimals = 2) +
  style(nudge = "up") + title("Each continent's share, printed on its bar")
(data(gapminder_2007) + bar * sum * proportion + x(col.continent) + y(col.population) +
  text * sum * proportion + label(col.proportion, decimals = 2) +
  style(nudge = "up") + title("Each continent's share, printed on its bar"))
data(gapminder_2007) + bar * sum * proportion + x(:continent) +
  y(:population) + text * sum * proportion +
  label(:proportion, decimals = 2) + style(nudge = "up") +
  title("Each continent's share, printed on its bar")
plot(data(gapminder_2007), layer(bar, sum, proportion), x(col.continent),
  y(col.population), layer(text, sum, proportion),
  label(col.proportion, { decimals: 2 }), style({ nudge: "up" }),
  title("Each continent's share, printed on its bar"))
0.61 0.09 0.15 0.14 0.00 Asia Europe Africa Americas Oceania 0.0 0.2 0.4 0.6 Each continent's share, printed on its bar Proportion Continent

“Given gapminder 2007: bars derived by sum and proportion, x is continent, y is population, and also text derived by sum and proportion, label by proportion with 2 decimal places, with nudge up.”

Asia’s bar is labeled 0.61, about three fifths of the world’s population in 2007. Oceania’s share is 0.0039, so at two decimal places its label reads 0.00.

The text layer’s rows hold two columns. proportion holds each continent’s share, and population still holds the column that sum adds up. So label(population, short = TRUE) prints each continent’s population on its bar, with M for millions and B for billions (a thousand millions), as in Text:

data(gapminder_2007) + bar * sum * proportion + x(continent) + y(population) +
  text * sum * proportion + label(population, short = TRUE) +
  style(nudge = "up") + title("Each continent's share, labeled with its population")
(data(gapminder_2007) + bar * sum * proportion + x(col.continent) + y(col.population) +
  text * sum * proportion + label(col.population, short = True) +
  style(nudge = "up") + title("Each continent's share, labeled with its population"))
data(gapminder_2007) + bar * sum * proportion + x(:continent) +
  y(:population) + text * sum * proportion +
  label(:population, short = true) + style(nudge = "up") +
  title("Each continent's share, labeled with its population")
plot(data(gapminder_2007), layer(bar, sum, proportion), x(col.continent),
  y(col.population), layer(text, sum, proportion),
  label(col.population, { short: true }), style({ nudge: "up" }),
  title("Each continent's share, labeled with its population"))
3.81B 586M 930M 899M 24.5M Asia Europe Africa Americas Oceania 0.0 0.2 0.4 0.6 Each continent's share, labeled with its population Proportion Continent

“Given gapminder 2007: bars derived by sum and proportion, x is continent, y is population, and also text derived by sum and proportion, label by population written short, with nudge up.”

Asia’s bar reaches 0.61, and its label reads 3.81B, the population of Asia in 2007.

proportion refuses a transform that leaves it nothing to divide by. A density already integrates to 1, and its cells are sample points rather than parts of a whole. A smooth gives a value at each x, so the sum of those values has no meaning. A pair transform writes a low and a high, which leaves two numbers per cell, and a span is not part of any total.

data(gm_all) + line * density * proportion + x(life)
data(gm_all) + line * density * proportion + x(col.life)
data(gm_all) + line * density * proportion + x(:life)
plot(data(gm_all), layer(line, density, proportion), x(col.life))
Error:
! gog: `line * density * proportion` has nothing to normalize — a density already integrates to 1, and its cells are points where the estimate was sampled rather than parts of a whole, so the sum of its heights is not a quantity anything is a share of. For the estimated shape, `line * density + x(<number>)`; for shares of the rows themselves, cut them into cells first: `bar * bin * proportion + x(<number>)` is the same curve's histogram, read as fractions.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

25.5 density: kernel density estimate

A histogram’s outline changes with every choice of bin, and its steps belong to the bins rather than to the data. A smooth curve describes the same distribution without the steps.

density is the third counting transform: it estimates the same distribution that bin counts, but as a smooth curve. The curve is the probability density function of x, estimated with a Gaussian kernel and evaluated at 256 evenly spaced points. By default the bandwidth (how wide each kernel is, and so how smooth the curve) is chosen automatically by Silverman’s rule (Silverman, 1986), which uses the interquartile range so that a few extreme values do not widen it.

Combine it with line to draw a smooth density curve:

data(gapminder_2007) + line * density + x(life) +
  x_label("Life expectancy (years)") +
  title("Kernel density: life expectancy, 2007")
(data(gapminder_2007) + line * density + x(col.life) +
  x_label("Life expectancy (years)") +
  title("Kernel density: life expectancy, 2007"))
data(gapminder_2007) + line * density + x(:life) +
  x_label("Life expectancy (years)") +
  title("Kernel density: life expectancy, 2007")
plot(data(gapminder_2007), layer(line, density), x(col.life),
  x_label("Life expectancy (years)"),
  title("Kernel density: life expectancy, 2007"))
40 60 80 0.00 0.01 0.02 0.03 0.04 Kernel density: life expectancy, 2007 Density Life expectancy (years)

“Given gapminder 2007: a line derived by density, x is life.”

density is not the line’s own transform. Five other marks estimate the same curve, and only the shape changes:

Mark derived by density Shape
area * density the curve filled to the baseline
step * density the curve as a staircase
point * density one dot at each point the curve was estimated
bar * density one thin bar at each of those points
ribbon * density the curve mirrored, which is the violin
line * density the curve as a stroke, the plot above

Bind two positions instead of one and density cuts both axes. zone then draws the field as cells, path as contour rings, and surface as a sheet.

With one position, as with the other counting transforms, you leave out y(): the transform writes the density column and labels its axis for you.

25.5.1 Choosing the bandwidth

The automatic curve can be smoother than the data supports, hiding a second peak, or rougher, showing bumps that are only noise.

The automatic bandwidth is a sensible default, not a mandate, and it is the parameter you will change most often, as the bin count is for bin. It is set the same two ways. A positional number is an adjust multiplier on the automatic bandwidth: density(2) is twice as smooth, density(0.5) half as smooth and so more jagged.

data(gapminder_2007) + line * density(2) + x(life) +
  x_label("Life expectancy (years)") + title("density(2): twice as smooth")
(data(gapminder_2007) + line * density(2) + x(col.life) +
  x_label("Life expectancy (years)") + title("density(2): twice as smooth"))
data(gapminder_2007) + line * density(2) + x(:life) +
  x_label("Life expectancy (years)") +
  title("density(2): twice as smooth")
plot(data(gapminder_2007), layer(line, density(2)), x(col.life),
  x_label("Life expectancy (years)"),
  title("density(2): twice as smooth"))
20 40 60 80 100 0.00 0.01 0.02 0.03 density(2): twice as smooth Density Life expectancy (years)

“Given gapminder 2007: a line derived by density adjusted by 2, x is life.”

To fix the bandwidth in the data’s own units instead, name it. Here bandwidth = 1 sets each kernel one year wide, narrower than the default, so the curve keeps more of the distribution’s fine structure:

data(gapminder_2007) + line * density(bandwidth = 1) + x(life) +
  x_label("Life expectancy (years)") + title("bandwidth = 1 year: an absolute width")
(data(gapminder_2007) + line * density(bandwidth = 1) + x(col.life) +
  x_label("Life expectancy (years)") + title("bandwidth = 1 year: an absolute width"))
data(gapminder_2007) + line * density(bandwidth = 1) + x(:life) +
  x_label("Life expectancy (years)") +
  title("bandwidth = 1 year: an absolute width")
plot(data(gapminder_2007), layer(line, density({ bandwidth: 1 })),
  x(col.life), x_label("Life expectancy (years)"),
  title("bandwidth = 1 year: an absolute width"))
40 50 60 70 80 0.00 0.02 0.04 0.06 bandwidth = 1 year: an absolute width Density Life expectancy (years)

“Given gapminder 2007: a line derived by density at bandwidth 1, x is life.”

This works exactly as bin does. The positional number is the adjust multiplier: the common request, since you rarely know the bandwidth you want in advance and only need the automatic one a little smoother or rougher. An absolute width is the less common request, so it is said out loud, density(bandwidth = 1); the two are mutually exclusive, and density(2, bandwidth = 1) refuses with direction rather than guessing which you meant.

25.5.2 A lowest or highest possible value

A density curve continues past the smallest and the largest value, until it is nearly zero at both ends. For most columns that is what you want. Some columns have a lowest possible value, though: no country’s income is below zero. On gdp, the curve then goes below zero, to incomes that no country has:

data(gapminder_2007) + line * density + x(gdp) +
  x_label("GDP per person") + title("No lower limit: the curve goes below zero")
(data(gapminder_2007) + line * density + x(col.gdp) +
  x_label("GDP per person") + title("No lower limit: the curve goes below zero"))
data(gapminder_2007) + line * density + x(:gdp) +
  x_label("GDP per person") +
  title("No lower limit: the curve goes below zero")
plot(data(gapminder_2007), layer(line, density), x(col.gdp),
  x_label("GDP per person"),
  title("No lower limit: the curve goes below zero"))
0 20K 40K 60K 0.00000 0.00002 0.00004 No lower limit: the curve goes below zero Density GDP per person

“Given gapminder 2007: a line derived by density, x is gdp.”

Write 0 as the axis’s lower limit and leave the upper one missing, limits = c(0, NA) (limits). The curve then starts at 0, and the estimate is corrected there. density places a bell-shaped kernel on each row. The part of a kernel that would fall below zero is mirrored back above it, so near zero the curve is higher than in the plot above:

data(gapminder_2007) + line * density + x(gdp, limits = c(0, NA)) +
  x_label("GDP per person") + title("Lower limit 0: the curve starts there")
(data(gapminder_2007) + line * density + x(col.gdp, limits = [0, None]) +
  x_label("GDP per person") + title("Lower limit 0: the curve starts there"))
data(gapminder_2007) + line * density + x(:gdp, limits = [0, missing]) +
  x_label("GDP per person") +
  title("Lower limit 0: the curve starts there")
plot(data(gapminder_2007), layer(line, density),
  x(col.gdp, { limits: [0, null] }), x_label("GDP per person"),
  title("Lower limit 0: the curve starts there"))
0 20K 40K 60K 0.00000 0.00002 0.00004 0.00006 0.00008 Lower limit 0: the curve starts there Density GDP per person

“Given gapminder 2007: a line derived by density, x is gdp from 0.”

The curve is now highest at 0, not near 3,000 dollars. Many of the poorest countries lie within one bandwidth of zero, about 4,100 dollars, so their mirrored parts add the most height there.

An upper limit works the same way: limits = c(NA, 1) on a column of shares stops the curve at 1. Only a limit you write does this. When two composed plots share an axis, the range gog chooses for it does not (The marginal plot).

25.5.3 One transform, four readings

Once you have a curve for one column, you will want the same estimate over two columns, or one curve per category, side by side. Each of those is still density.

density estimates a distribution, and how it is drawn depends on what you bind and on the mark. There are four readings, and they are one transform rather than four transforms sharing a name.

Bind one continuous column and it is a curve over that column, with its height on the other axis, which is everything above. Bind two continuous columns to a mark with no measure axis and it is a field over the plane: zone * density paints it cell by cell, and surface * density raises it into the cube. Cut that field into steps of equal density and it becomes a set of levels: path * density traces their edges as contour lines, and zone * density(levels = 8) fills the bands between them. Bind a category and a continuous column and both axes are taken, so the estimate is drawn across the category’s slot, one distribution per group, which is the violin:

data(gapminder_2007) + ribbon * density + x(continent) + y(life) +
  y_label("Life expectancy") + title("ribbon * density: the slot reading")
(data(gapminder_2007) + ribbon * density + x(col.continent) + y(col.life) +
  y_label("Life expectancy") + title("ribbon * density: the slot reading"))
data(gapminder_2007) + ribbon * density + x(:continent) + y(:life) +
  y_label("Life expectancy") + title("ribbon * density: the slot reading")
plot(data(gapminder_2007), layer(ribbon, density), x(col.continent),
  y(col.life), y_label("Life expectancy"),
  title("ribbon * density: the slot reading"))
Asia Europe Africa Americas Oceania 40 60 80 ribbon * density: the slot reading Life expectancy Continent

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

This plot draws five estimates, one per continent, and all five are smoothed by the same bandwidth. By default it is the average of the bandwidths Silverman’s rule gives each continent on its own. If each continent kept its own bandwidth, Oceania’s two countries would get one about a ninth of Africa’s, and the two shapes would differ partly because of that. With one bandwidth, a difference in shape comes from the data and not from the smoothing. The same sharing applies whenever one layer draws several estimates: curves split by color, the panels of a facet whose axes are shared, and the frames of an animation made with play. adjust multiplies the shared bandwidth, and a stated bandwidth replaces it.

Split the curve by color, and each continent gets its own curve, smoothed by the same shared bandwidth:

data(gapminder_2007) + line * density + x(life) + color(continent) +
  x_label("Life expectancy") + title("line * density + color: one curve per continent")
(data(gapminder_2007) + line * density + x(col.life) + color(col.continent) +
  x_label("Life expectancy") + title("line * density + color: one curve per continent"))
data(gapminder_2007) + line * density + x(:life) + color(:continent) +
  x_label("Life expectancy") +
  title("line * density + color: one curve per continent")
plot(data(gapminder_2007), layer(line, density), x(col.life),
  color(col.continent), x_label("Life expectancy"),
  title("line * density + color: one curve per continent"))
40 60 80 0.00 0.05 0.10 0.15 line * density + color: one curve per continent Density Life expectancy Continent Asia Europe Africa Americas Oceania

“Given gapminder 2007: lines derived by density, x is life, color by continent.”

Each curve encloses the same area, so a continent whose countries are close together draws a tall, narrow curve. Oceania’s two countries are one year apart, so its curve is the tallest. With its own, much smaller bandwidth, it would be nearly three times as tall, and the other four curves would be pressed down against the axis.

Each of density’s parameters belongs to one of the four readings, and gog refuses a parameter in a reading where it has no meaning, rather than ignoring it. bandwidth is a length in one column’s units, so it means nothing to a field, whose two axes measure different quantities. levels counts lines of equal density, and a curve has none to count. The slot reading has two parameters of its own. compare says what a violin’s width means from one slot to the next. reach says how far the shape extends, measured in slots, and past half a slot the shapes reach into their neighbors: the ridgeline. Neither means anything to a plot with no slots:

data(gapminder_2007) + line * density(compare = "count") + x(life)
data(gapminder_2007) + line * density(compare = "count") + x(col.life)
data(gapminder_2007) + line * density(compare = "count") + x(:life)
plot(data(gapminder_2007), layer(line, density({ compare: "count" })),
  x(col.life))
Error:
! gog: `density(compare = )` says what a violin's width means from one slot to the next, and `line * density` here has no slots — it estimates one curve along one axis. Bind a category to give it slots to compare: `ribbon * density(compare = "count") + x(<category>) + y(<number>)`. Otherwise drop `compare`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

25.6 What bin, density and smooth refuse

A histogram of continent is an easy thing to ask for, and gog refuses it. smooth, which fits a trend line, has its own section later in this chapter; it is included here because it is refused for the same reason as bin and density. The refusal is easier to act on once you know what these three transforms share.

One variable, or two? sorted the transforms by how many columns they read. A second property divides them a different way: these three all describe how values are spread along an axis, so the axis they read has to carry a number. A category is one slot rather than a stretch of line, so there is nothing to cut into intervals, nothing for a curve to spread over, and nothing to fit through.

The violin above looks like an exception, but it is not one. There the category is not the axis being estimated along: y(life) is, and the category only says which rows each estimate is made from. Take the number away and there is nothing left to spread over, so the refusal returns. That is why the first two examples below bind a category and nothing else.

Each refusal names the atom that asks the same question of categories. Binning one is really counting it:

data(gapminder_2007) + bar * bin + x(continent)
data(gapminder_2007) + bar * bin + x(col.continent)
data(gapminder_2007) + bar * bin + x(:continent)
plot(data(gapminder_2007), layer(bar, bin), x(col.continent))
Error:
! gog: `bin` cuts a continuous axis into intervals, and `x(continent)` is categorical — a category is one slot, with no width to cut. To tally rows per category, `count` is the transform that does it: `bar * count`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

A density over categories is really a share of the total:

data(gapminder_2007) + bar * density + x(continent)
data(gapminder_2007) + bar * density + x(col.continent)
data(gapminder_2007) + bar * density + x(:continent)
plot(data(gapminder_2007), layer(bar, density), x(col.continent))
Error:
! gog: `density` estimates a continuous distribution, and `x(continent)` is categorical — there is no number line for the curve to spread along. For the share of rows in each category, that is `bar * proportion`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

And a curve fitted across categories is really a summary within each one:

data(gapminder_2007) + point * smooth + x(continent) + y(life)
data(gapminder_2007) + point * smooth + x(col.continent) + y(col.life)
data(gapminder_2007) + point * smooth + x(:continent) + y(:life)
plot(data(gapminder_2007), layer(point, smooth), x(col.continent),
  y(col.life))
Error:
! gog: `smooth` fits a curve of `y` against `x`, and `x(continent)` is categorical — a fit needs a number line to run along. For a typical value per category, `bar * mean` (or `point * mean`) says it directly.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

Which axis must be continuous is taken from the bindings rather than fixed to x. The horizontal histogram is bar * bin + y(life): it cuts y, because y is the axis it was given, and the count it writes goes on x. smooth is the strict one of the three, since it fits y against x and so needs a number on both.

25.6.1 A fit needs rows to fit through

It is easy to ask for a trend through very few rows: a table filtered to its latest years, or a group with two members.

Having numbers is not the same as having enough of them. smooth fits a curve, and two points are a straight line while one is a point, so it asks for at least three. The two_readings table holds France’s rows for 2002 and 2007, the last two years of the gapminder data:

two_readings: all 2 rows
country continent year life population gdp
France Europe 2002 79.590 59925035 28926.03
France Europe 2007 80.657 61083916 30470.02
data(two_readings) + line * smooth + x(year) + y(life)
data(two_readings) + line * smooth + x(col.year) + y(col.life)
data(two_readings) + line * smooth + x(:year) + y(:life)
plot(data(two_readings), layer(line, smooth), x(col.year), y(col.life))
Error:
! gog: `smooth` fits a curve through the rows and needs at least 3 of them. This data has 2. Plot what you have with `point` and no transform, which draws the rows as rows rather than as a curve through them.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The count that matters is the count inside each group, not in the table. A statistic runs within every group you split by, and faceting splits the rows before any of that. So a table with plenty of rows can still be asked for a fit through two. The recent table holds every country’s rows for 2002 and 2007:

recent: first 5 of 284 rows
country continent year life population gdp
Afghanistan Asia 2002 42.129 25268405 726.7341
Afghanistan Asia 2007 43.828 31889923 974.5803
Albania Europe 2002 75.651 3508512 4604.2117
Albania Europe 2007 76.423 3600523 5937.0295
Algeria Africa 2002 70.994 31287142 5288.0404
data(recent) + line * smooth + x(year) + y(life) + group(country) |
  facet(continent)
(data(recent) + line * smooth + x(col.year) + y(col.life) + group(col.country) |
  facet(col.continent))
data(recent) + line * smooth + x(:year) + y(:life) + group(:country) |
  facet(:continent)
plot(data(recent), layer(line, smooth), x(col.year), y(col.life),
  group(col.country), across(col.continent))
Error:
! gog: `smooth` fits a curve through the rows and needs at least 3 of them. Split by `continent` or `country`, the smallest group has 2. Drop the split so the fit reads the rows together, or plot what you have with `point` and no transform, which draws the rows as rows rather than as a curve through them.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

There are 284 rows there and gog still refuses, because each country in each panel is its own fit and each has only two rows. The message names both splits, so you know which one to remove. Drop group(country) and each panel fits one curve through a continent’s rows, and every continent has more than the three rows a fit needs.

The refusal matters because of what the plot would show without it. Below three rows the curve cannot be computed, so what would be drawn is the rows themselves, connected in order. A two-point group would draw a straight segment beside a hundred-point curve, with nothing to say that one is a fit and the other is the data. An unfinished plot that looks finished is worse than a refusal.

25.7 Chaining transforms

Some questions need two transforms. To show life expectancy across income bands, cut income into bands and take a mean of life inside each one.

Two transforms on one mark have to divide the work, and there are only four jobs to divide. A transform can say where the cells are, it can say what is in them, it can say what scale the answer is read on, and it can say where the marks are drawn. bounds names where each band starts and ends. count tallies into the cells your categories already own, and says nothing about where those cells are. mean and its five siblings reduce a column you name, and say nothing about where the cells are either. proportion does the third job and only the third: it divides by a total, which is why it can come after almost anything. dodge, stack and jitter do the fourth, moving marks that would otherwise land on top of one another. repel also moves labels off one another, but it does no job: it moves a label after every job has run, so it contradicts no other transform. Several of these transforms, mean, bounds and the four that move marks among them, have their own sections later in this chapter. Here they appear only as examples of how two transforms combine.

Two transforms compose when they do different jobs, and contradict when they do the same one. A cell holds one extent, one measurement, one scale and one place for its marks. A second transform doing a job the first already did therefore leaves no reading that keeps both. The engine refuses, rather than drawing one and quietly discarding the other. Combinations has the whole grid, generated from the same rule.

Because each job is filled once, the order you write them in does not change the picture. Still, write them in the order they are computed, so the sentence reads in the same order as the work.

bin does two of the four jobs, and it is the only transform that can drop one of them. Cutting an axis is what makes it a bin; the tally it hands back is a by-product of the cut. So when you compose it with another statistic, bin keeps the cut and the other statistic keeps the measurement. The plot uses the gm_all table, shown in The same bin, cutting two axes:

data(gm_all) + bar * bin(20) * mean + x(gdp, scale = "log") + y(life) +
  title("Mean life expectancy by income band")
(data(gm_all) + bar * bin(20) * mean + x(col.gdp, scale = "log") + y(col.life) +
  title("Mean life expectancy by income band"))
data(gm_all) + bar * bin(20) * mean + x(:gdp, scale = "log") + y(:life) +
  title("Mean life expectancy by income band")
plot(data(gm_all), layer(bar, bin(20), mean),
  x(col.gdp, { scale: "log" }), y(col.life),
  title("Mean life expectancy by income band"))
1K 10K 100K 0 20 40 60 80 Mean life expectancy by income band Life Gdp

“Given all the gapminder years: bars derived by bin into 20 and mean, x is gdp on a log scale, y is life.”

Read the sentence as three separate claims. bin(20) cuts income into twenty bands. mean reduces life within each one. bar draws the result as a length from zero. The bars touch because the bands tile the axis, exactly as a histogram’s do, and the y axis is now labeled for the column that was actually reduced.

Any mark that accepts both transforms accepts the pair. Here the same cut is drawn as points, with median in place of mean:

data(gm_all) + point * bin(20) * median + x(gdp, scale = "log") + y(life) +
  title("Median life expectancy by income band")
(data(gm_all) + point * bin(20) * median + x(col.gdp, scale = "log") + y(col.life) +
  title("Median life expectancy by income band"))
data(gm_all) + point * bin(20) * median + x(:gdp, scale = "log") +
  y(:life) + title("Median life expectancy by income band")
plot(data(gm_all), layer(point, bin(20), median),
  x(col.gdp, { scale: "log" }), y(col.life),
  title("Median life expectancy by income band"))
1K 10K 100K 50 60 70 80 Median life expectancy by income band Life Gdp

“Given all the gapminder years: points derived by bin into 20 and median, x is gdp on a log scale, y is life.”

25.7.1 What cannot be chained, and why

Having seen bin * mean work, you will want to know which other pairs do.

The other two counting transforms cannot drop their measurement, because measuring is all they do. Take it away from count and nothing is left of it:

data(gm_all) + bar * count * mean + x(continent) + y(life)
data(gm_all) + bar * count * mean + x(col.continent) + y(col.life)
data(gm_all) + bar * count * mean + x(:continent) + y(:life)
plot(data(gm_all), layer(bar, count, mean), x(col.continent), y(col.life))
Error:
! gog: `bar * count * mean` measures each cell twice — `count` supplies only a measurement: its cells are the slots the positions already own, so with `mean` measuring them too the cell is measured twice and `count` has nothing left to contribute. Keep whichever you meant: `bar * count` to measure what `count` computes, or `bar * mean` to reduce the column you name. To cut a continuous axis into cells and reduce a column inside each, `bin` is the transform that cuts without keeping the measurement: `bar * bin * mean + x(<number>)`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

density fails a different test. Its cells are the points where the estimate was sampled, and the estimate exists between your observations rather than inside cells that hold rows, so there is nothing inside one for a statistic to reduce:

data(gm_all) + bar * density * mean + x(gdp) + y(life)
data(gm_all) + bar * density * mean + x(col.gdp) + y(col.life)
data(gm_all) + bar * density * mean + x(:gdp) + y(:life)
plot(data(gm_all), layer(bar, density, mean), x(col.gdp), y(col.life))
Error:
! gog: `bar * density * mean` measures each cell twice — a `density` cell is a point where the estimate was sampled, not a bucket holding rows — the estimate exists *between* your observations — so there is nothing inside one for `mean` to reduce. Keep whichever you meant: `bar * density` to measure what `density` computes, or `bar * mean` to reduce the column you name. To cut a continuous axis into cells and reduce a column inside each, `bin` is the transform that cuts without keeping the measurement: `bar * bin * mean + x(<number>)`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

smooth is refused against all three, including bin, for a reason none of them share. It fits a curve through the rows and already averages locally while it fits, so cutting them into cells first asks the same question a second time:

data(gm_all) + bar * bin * smooth + x(gdp) + y(life)
data(gm_all) + bar * bin * smooth + x(col.gdp) + y(col.life)
data(gm_all) + bar * bin * smooth + x(:gdp) + y(:life)
plot(data(gm_all), layer(bar, bin, smooth), x(col.gdp), y(col.life))
Error:
! gog: `bar * bin * smooth` asks one question twice — `smooth` fits a curve through the rows and already averages locally as it goes, so cutting them into cells first changes nothing it was not doing. Keep whichever you meant: `bar * smooth + x(<a>) + y(<b>)` for the fitted curve, or `bar * bin` for the shape `bin` measures. For a summary per cell rather than a fitted curve, name the statistic: `bar * bin * mean + x(<a>) + y(<b>)`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

And two transforms that each write their own measurement are the same contradiction. bin gives up its tally only to a transform that reduces a column you named, and count reads none, so a cell would be measured twice:

data(gm_all) + bar * bin * count + x(life)
data(gm_all) + bar * bin * count + x(col.life)
data(gm_all) + bar * bin * count + x(:life)
plot(data(gm_all), layer(bar, bin, count), x(col.life))
Error:
! gog: `bar * bin * count` measures each cell twice — `bin` and `count` each invent their own measurement from the rows, and neither was handed a column to give way to, so there is no reading that keeps both. Keep whichever you meant: `bar * bin` or `bar * count`. To cut an axis into cells and measure something else inside them, the second transform has to be one you hand a column: `bar * bin * mean + x(<number>) + y(<column>)`. To read either as shares of the whole rather than as counts, `proportion` rescales whichever you keep: `bar * bin * proportion`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

Each refusal names both transforms you could drop. Each also names a third option, bar * bin * mean, because bin is the one transform that cuts without keeping its measurement.

The same rule applies to transforms that write a low and a high, and to the transforms that move marks. Two transforms that both write a low and a high measure the cell twice. So range and confidence cannot be used together. A minimum and a maximum are not a 95% confidence interval, and one of the two would have to be discarded:

data(gm_all) + interval * range * confidence + x(continent) + y(life)
data(gm_all) + interval * range * confidence + x(col.continent) + y(col.life)
data(gm_all) + interval * range * confidence + x(:continent) + y(:life)
plot(data(gm_all), layer(interval, range, confidence), x(col.continent),
  y(col.life))
Error:
! gog: `interval * range * confidence` measures each cell twice — `range` and `confidence` both reduce the column you named, and a cell holds one answer, so drawing both would mean drawing one of them and discarding the other. Keep whichever you meant: `interval * range` or `interval * confidence`. To show both, draw them as two layers: `interval * range + interval * confidence`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The same is true when one transform writes a single value and the other writes two:

data(gm_all) + line * sum * range + x(year) + y(population)
data(gm_all) + line * sum * range + x(col.year) + y(col.population)
data(gm_all) + line * sum * range + x(:year) + y(:population)
plot(data(gm_all), layer(line, sum, range), x(col.year),
  y(col.population))
Error:
! gog: `line * sum * range` measures each cell twice — `sum` and `range` both reduce the column you named, and a cell holds one answer, so drawing both would mean drawing one of them and discarding the other. Keep whichever you meant: `line * sum` or `line * range`. To show both, draw them as two layers: `line * sum + line * range`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

Two transforms that move marks are the same contradiction on the fourth job. A bar cannot be set beside its neighbor and piled on top of it at once, because a bar can be in only one place:

data(gm_all) + bar * count * dodge * stack + x(continent) + color(continent)
data(gm_all) + bar * count * dodge * stack + x(col.continent) + color(col.continent)
data(gm_all) + bar * count * dodge * stack + x(:continent) +
  color(:continent)
plot(data(gm_all), layer(bar, count, dodge, stack), x(col.continent),
  color(col.continent))
Error:
! gog: `bar * dodge * stack` arranges the same marks twice — `dodge` and `stack` each decide where colliding groups go, and a mark sits in one place, so one of the two would be discarded. Keep whichever you meant: `bar * dodge` or `bar * stack`. To show both readings, draw them as two plots side by side rather than as one layer.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

A transform can also be unnecessary rather than contradictory, and that is a different answer. proportion divides a tally by its total, so it already counts the rows, which leaves count beside it with nothing to add. The plot is still exactly the plot you asked for, so the engine draws it:

data(gm_all) + bar * count * proportion + x(continent) +
  title("Share of countries by continent")
(data(gm_all) + bar * count * proportion + x(col.continent) +
  title("Share of countries by continent"))
data(gm_all) + bar * count * proportion + x(:continent) +
  title("Share of countries by continent")
plot(data(gm_all), layer(bar, count, proportion), x(col.continent),
  title("Share of countries by continent"))
gog: `bar * count * proportion` draws the same plot as `bar * proportion` — a share is a share *of* a tally, so `proportion` already counts the rows and `count` adds nothing. Drop it, or keep `bar * count` on its own to read the tallies themselves rather than their shares.
Asia Europe Africa Americas Oceania 0.0 0.1 0.2 0.3 Share of countries by continent Proportion Continent

“Given all the gapminder years: bars derived by count and proportion, x is continent.”

The note printed with the plot names the shorter sentence, bar * proportion, that draws the same picture.

That is the difference between the two answers. When the two transforms cannot both be used, the engine refuses rather than discard one. When both can stay and one of them simply changes nothing, it draws and says so. Neither answer is silence, which is the rule underneath both: a word you wrote is never read and then ignored.

25.8 smooth: LOESS trend line

The counting transforms read one column and write a new measurement. The reading transforms summarize a measurement you already have: each one reads a column you name, usually on y, and answers with a summary of it. smooth is the first of them.

Income and life expectancy rise together across the scatter, and one curve states that relationship more plainly than 142 separate points.

smooth fits a locally weighted linear regression (LOESS) through the (x, y) points and evaluates it at 100 evenly spaced x positions (Cleveland, 1979). Each local regression is fitted once, with no second fit that gives outliers less weight. The curve is the engine’s own, so it is identical in all four languages.

Combine smooth with line to overlay a trend line on a scatterplot. The plot uses the gapminder_2007 table, shown in the bin section:

data(gapminder_2007) + x(gdp) + y(life) +
  point + color(continent) +
  line * smooth +
  title("GDP vs life expectancy, with a LOESS smooth")
(data(gapminder_2007) + x(col.gdp) + y(col.life) +
  point + color(col.continent) +
  line * smooth +
  title("GDP vs life expectancy, with a LOESS smooth"))
data(gapminder_2007) + x(:gdp) + y(:life) + point + color(:continent) +
  line * smooth + title("GDP vs life expectancy, with a LOESS smooth")
plot(data(gapminder_2007), x(col.gdp), y(col.life), point,
  color(col.continent), layer(line, smooth),
  title("GDP vs life expectancy, with a LOESS smooth"))
0 10K 20K 30K 40K 50K 40 50 60 70 80 GDP vs life expectancy, with a LOESS smooth Life Gdp Continent Asia Europe Africa Americas Oceania

“Given gapminder 2007: x is gdp, y is life, points colored by continent, and also a line derived by smooth.”

smooth is not the line’s own transform. Four other marks fit the same curve, and only the shape changes:

Mark derived by smooth Shape
point * smooth one dot at each fitted value
area * smooth the curve filled to the baseline
bar * smooth one thin bar at each fitted value
step * smooth the curve as a staircase
line * smooth the fitted curve above

Span is fixed at 0.75: each local regression uses the nearest 75% of the data. A larger span would give a smoother curve, but smooth takes no argument, so the span cannot be changed. smooth needs continuous columns on x and y, and at least three rows in each group. Both refusals are earlier in this chapter, under What bin, density and smooth refuse. It writes its fitted values under the same x and y names, so the axes keep their labels.

ggplot2’s geom_smooth() uses the same span, 0.75, but differs in two ways. First, its default fit is a local quadratic: a small parabola around each x position, not a straight line. So the two curves can differ where the relationship is curved, most often at their left and right ends. Second, when any group has 1,000 rows or more, geom_smooth() fits a generalized additive model instead of LOESS. In that case, the two curves come from different methods, and the difference can be larger.

25.8.1 smooth_band: the band around the curve

A curve through the points says where the trend lies. A band around it says how precisely the trend is known. confidence adds a low and a high around a mean; smooth_band adds them around smooth’s curve. At the 100 x positions smooth uses, smooth_band writes the curve’s confidence band as a low and a high, with the fitted value as the center. A ribbon fills the band, and line * smooth draws the curve inside it:

data(gapminder_2007) + x(gdp) + y(life) +
  point +
  ribbon * smooth_band + style(opacity = 0.3) +
  line * smooth +
  title("The trend, and how precisely it is known")
(data(gapminder_2007) + x(col.gdp) + y(col.life) +
  point +
  ribbon * smooth_band + style(opacity = 0.3) +
  line * smooth +
  title("The trend, and how precisely it is known"))
data(gapminder_2007) + x(:gdp) + y(:life) + point + ribbon * smooth_band +
  style(opacity = 0.3) + line * smooth +
  title("The trend, and how precisely it is known")
plot(data(gapminder_2007), x(col.gdp), y(col.life), point,
  layer(ribbon, smooth_band), style({ opacity: 0.3 }),
  layer(line, smooth), title("The trend, and how precisely it is known"))
0 10K 20K 30K 40K 50K 40 50 60 70 80 90 The trend, and how precisely it is known Life Gdp

“Given gapminder 2007: x is gdp, y is life, points, and also a ribbon derived by smooth_band, and also a line derived by smooth.”

The band widens at the right, where only a few rich countries lie. Where fewer rows lie near an x position, the trend there is known less precisely. The band shows the uncertainty of the curve, not the spread of the countries, so three countries in four lie outside it. Its center is smooth’s curve exactly. The confidence level is 0.95 unless you name another, and smooth_band(0.99) draws a wider band.

Like smooth, smooth_band needs two continuous columns, but it needs five rows in each group, not three. The band’s width is computed from how far the rows lie from the curve. With three or four rows, the curve passes through every row, so no width can be computed. line * smooth_band draws the band’s two edges instead of filling it.

25.9 straight: the least-squares line

smooth lets its curve bend wherever the points bend. Often you want a simpler answer: one straight line, and how steeply it rises. straight fits the least-squares line: the straight line that makes the sum of the squared vertical distances from the points as small as possible. It is the simple linear regression line, the one R’s lm(y ~ x) fits. gog computes the line itself, so it is the same in all four languages. It draws the line at the same 100 x positions as smooth, which the band below needs, because the band’s edges curve.

Life expectancy does not rise along a straight line with GDP. It rises quickly among the poorest countries and slowly among the richest. Here smooth, in red, and straight, in blue, are fitted to the same countries on a linear axis of dollars:

data(gapminder_2007) + x(gdp) + y(life) +
  point + style(color = "lightgray") +
  line * smooth + style(color = "tomato") +
  line * straight + style(color = "steelblue") +
  title("A straight line on dollars misses the bend")
(data(gapminder_2007) + x(col.gdp) + y(col.life) +
  point + style(color = "lightgray") +
  line * smooth + style(color = "tomato") +
  line * straight + style(color = "steelblue") +
  title("A straight line on dollars misses the bend"))
data(gapminder_2007) + x(:gdp) + y(:life) + point +
  style(color = "lightgray") + line * smooth + style(color = "tomato") +
  line * straight + style(color = "steelblue") +
  title("A straight line on dollars misses the bend")
plot(data(gapminder_2007), x(col.gdp), y(col.life), point,
  style({ color: "lightgray" }), layer(line, smooth),
  style({ color: "tomato" }), layer(line, straight),
  style({ color: "steelblue" }),
  title("A straight line on dollars misses the bend"))
0 10K 20K 30K 40K 50K 40 60 80 A straight line on dollars misses the bend Life Gdp

“Given gapminder 2007: x is gdp, y is life, points, and also a line derived by smooth, and also a line derived by straight.”

The curve bends near $10,000 and the straight line cannot. Among the 66 countries under $5,000, the line lies above six in ten. At the richest country it reaches 91 years, about eight years more than the longest life expectancy in the table. So on a linear axis of dollars, a straight line is a poor summary.

Against the logarithm of GDP, the rise is close to a straight line, so the next plot puts x on a log scale. On a log x axis, straight fits the line to the logarithm of x, so the line is straight on the page:

data(gapminder_2007) + x(gdp, scale = "log") + y(life) +
  point + color(continent) +
  line * straight +
  title("Life expectancy against GDP on a log scale")
(data(gapminder_2007) + x(col.gdp, scale = "log") + y(col.life) +
  point + color(col.continent) +
  line * straight +
  title("Life expectancy against GDP on a log scale"))
data(gapminder_2007) + x(:gdp, scale = "log") + y(:life) + point +
  color(:continent) + line * straight +
  title("Life expectancy against GDP on a log scale")
plot(data(gapminder_2007), x(col.gdp, { scale: "log" }), y(col.life),
  point, color(col.continent), layer(line, straight),
  title("Life expectancy against GDP on a log scale"))
1K 10K 40 50 60 70 80 Life expectancy against GDP on a log scale Life Gdp Continent Asia Europe Africa Americas Oceania

“Given gapminder 2007: x is gdp on a log scale, y is life, points colored by continent, and also a line derived by straight.”

The line rises about 16.6 years for each tenfold rise in GDP per person, for example from $1,000 to $10,000. Like smooth, straight fits one line for each group when a channel splits the rows. Here color is written before the marks, so it reaches the line as well as the points:

data(gapminder_2007) + x(gdp, scale = "log") + y(life) + color(continent) +
  point +
  line * straight +
  title("One line for each continent")
(data(gapminder_2007) + x(col.gdp, scale = "log") + y(col.life) + color(col.continent) +
  point +
  line * straight +
  title("One line for each continent"))
data(gapminder_2007) + x(:gdp, scale = "log") + y(:life) +
  color(:continent) + point + line * straight +
  title("One line for each continent")
plot(data(gapminder_2007), x(col.gdp, { scale: "log" }), y(col.life),
  color(col.continent), point, layer(line, straight),
  title("One line for each continent"))
1K 10K 40 50 60 70 80 One line for each continent Life Gdp Continent Asia Europe Africa Americas Oceania

“Given gapminder 2007: x is gdp on a log scale, y is life, color by continent, points, and also lines derived by straight.”

Within each continent the lines are flatter: between about 8 and 12 more years for each tenfold rise, compared with 16.6 across all countries. The line across all countries is steeper partly because the continents differ: on the richer continents, people also live longer on average. Oceania has only two countries, so its line passes through both of them.

straight needs continuous columns on x and y, as smooth does. In each group it needs at least two rows, with two different values of x. The refusals for a category on either axis are the ones under What bin, density and smooth refuse. It writes its fitted values under the same x and y names, so the axes keep their labels.

straight has one refusal that smooth does not have. A transform measures y in the column’s own units (The order of a scale and a transform). On a log y axis, a line that is straight in those units would be drawn as a curve. So gog refuses straight on a log y axis:

data(gapminder_2007) + x(gdp) + y(life, scale = "log") +
  point + line * straight
(data(gapminder_2007) + x(col.gdp) + y(col.life, scale = "log") +
  point + line * straight)
data(gapminder_2007) + x(:gdp) + y(:life, scale = "log") + point +
  line * straight
plot(data(gapminder_2007), x(col.gdp), y(col.life, { scale: "log" }),
  point, layer(line, straight))
Error:
! gog: `line * straight` fits a straight line to `life` in its own units, and a log axis would draw that line as a curve. To fit a line to the logarithm of `life`, take the logarithm in your table and plot it on a linear axis. To keep the log axis, `line * smooth` fits a curve, which may bend.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

To fit a line to the logarithm of a column, take the logarithm in your table and plot that column on a linear axis. To keep the log axis, use smooth, whose curve may bend. A log x axis is not refused, because there the line is fitted to the logarithm of x, as the second plot in this section showed.

25.9.1 straight_band: the band around the line

straight_band adds a band around straight’s line, as smooth_band does around smooth’s curve. At each of the line’s 100 x positions, it writes the confidence interval of the line as a low and a high, with the fitted value as the center. The R function predict() gives the same interval for a model from lm(), with interval = "confidence". A ribbon fills the band:

data(gapminder_2007) + x(gdp, scale = "log") + y(life) +
  point +
  ribbon * straight_band + style(opacity = 0.3) +
  line * straight +
  title("The line, and how precisely it is known")
(data(gapminder_2007) + x(col.gdp, scale = "log") + y(col.life) +
  point +
  ribbon * straight_band + style(opacity = 0.3) +
  line * straight +
  title("The line, and how precisely it is known"))
data(gapminder_2007) + x(:gdp, scale = "log") + y(:life) + point +
  ribbon * straight_band + style(opacity = 0.3) + line * straight +
  title("The line, and how precisely it is known")
plot(data(gapminder_2007), x(col.gdp, { scale: "log" }), y(col.life),
  point, layer(ribbon, straight_band), style({ opacity: 0.3 }),
  layer(line, straight), title("The line, and how precisely it is known"))
1K 10K 40 50 60 70 80 The line, and how precisely it is known Life Gdp

“Given gapminder 2007: x is gdp on a log scale, y is life, points, and also a ribbon derived by straight_band, and also a line derived by straight.”

The band is narrowest at the mean of the x values. On this log axis that is a GDP of about $5,500, where the band is 2.4 years wide. It widens toward both ends: 5.7 years at the poorest country and 4.5 years at the richest. As with smooth_band, the band shows how precisely the line is known, not the spread of the countries, so about three countries in four lie outside it. The confidence level is 0.95 unless you name another, and straight_band(0.99) draws a wider band. The band needs three rows in each group, one more than the line.

25.10 Aggregation family: sum, mean, median, max, min, quantile

The six summaries are the simplest members of the reading family. A table can hold several rows for the same x value. gapminder_2007 has one row per country, so each continent holds many rows. bar alone then draws one bar per row, and the bars for one continent are drawn over each other in one slot.

The aggregation family groups rows by x and reduces y to a single value per group. All six follow exactly the same grammar:

bar * sum + x(continent) + y(population)
bar * mean + x(continent) + y(population)
bar * median + x(continent) + y(population)
bar * max + x(continent) + y(population)
bar * min + x(continent) + y(population)
bar * quantile(0.9) + x(continent) + y(population)

Here are all six from the same column, in the same continent order, so you can see that each one answers a different question about it.

sum, total y per group, the most common aggregation for counts and amounts:

data(gapminder_2007) + bar * sum + x(continent) + y(population) +
  y_label("Total population") + title("Sum per continent: bar * sum")
(data(gapminder_2007) + bar * sum + x(col.continent) + y(col.population) +
  y_label("Total population") + title("Sum per continent: bar * sum"))
data(gapminder_2007) + bar * sum + x(:continent) + y(:population) +
  y_label("Total population") + title("Sum per continent: bar * sum")
plot(data(gapminder_2007), layer(bar, sum), x(col.continent),
  y(col.population), y_label("Total population"),
  title("Sum per continent: bar * sum"))
Asia Europe Africa Americas Oceania 0 1B 2B 3B 4B Sum per continent: bar * sum Total population Continent

“Given gapminder 2007: bars derived by sum, x is continent, y is population.”

Asia’s bar is taller than the other four together: most of the world’s people live in Asia.

sum is not the bar’s own transform, and the whole family behaves alike:

Mark derived by sum Shape
point * sum one dot at each total
line * sum the totals joined into a profile
area * sum the same profile, filled to the baseline
step * sum each total held flat across its slot
zone * sum one cell per pair of categories, colored by its total
bar * sum one bar per category, the plot above

You can put mean, median, min, max or quantile in place of sum in every row of that table. The statistic decides the number, and the mark decides the shape.

mean, the average y per group:

data(gapminder_2007) + bar * mean + x(continent) + y(population) +
  y_label("Mean population") + title("Mean per continent: bar * mean")
(data(gapminder_2007) + bar * mean + x(col.continent) + y(col.population) +
  y_label("Mean population") + title("Mean per continent: bar * mean"))
data(gapminder_2007) + bar * mean + x(:continent) + y(:population) +
  y_label("Mean population") + title("Mean per continent: bar * mean")
plot(data(gapminder_2007), layer(bar, mean), x(col.continent),
  y(col.population), y_label("Mean population"),
  title("Mean per continent: bar * mean"))
Asia Europe Africa Americas Oceania 0 50M 100M Mean per continent: bar * mean Mean population Continent

“Given gapminder 2007: bars derived by mean, x is continent, y is population.”

The average Asian country has about 115 million people, far more than the average country on any other continent.

median, the middle value of y per group:

data(gapminder_2007) + bar * median + x(continent) + y(population) +
  y_label("Median population") + title("Median per continent: bar * median")
(data(gapminder_2007) + bar * median + x(col.continent) + y(col.population) +
  y_label("Median population") + title("Median per continent: bar * median"))
data(gapminder_2007) + bar * median + x(:continent) + y(:population) +
  y_label("Median population") +
  title("Median per continent: bar * median")
plot(data(gapminder_2007), layer(bar, median), x(col.continent),
  y(col.population), y_label("Median population"),
  title("Median per continent: bar * median"))
Asia Europe Africa Americas Oceania 0 10M 20M Median per continent: bar * median Median population Continent

“Given gapminder 2007: bars derived by median, x is continent, y is population.”

The median gives a different answer. The middle country in Asia has about 25 million people, and every continent’s median lies between about 9 and 25 million. A few very large countries pull the means up: China and India in Asia, and the United States in the Americas. How large those few countries are makes no difference to the median. When the mean and the median of a group are this far apart, the values are skewed: a few are much larger than the rest. Then the median is the better answer to the question of how large a typical country is.

max, the largest y in each group:

data(gapminder_2007) + bar * max + x(continent) + y(population) +
  y_label("Largest population") + title("Max per continent: bar * max")
(data(gapminder_2007) + bar * max + x(col.continent) + y(col.population) +
  y_label("Largest population") + title("Max per continent: bar * max"))
data(gapminder_2007) + bar * max + x(:continent) + y(:population) +
  y_label("Largest population") + title("Max per continent: bar * max")
plot(data(gapminder_2007), layer(bar, max), x(col.continent),
  y(col.population), y_label("Largest population"),
  title("Max per continent: bar * max"))
Asia Europe Africa Americas Oceania 0 500M 1000M Max per continent: bar * max Largest population Continent

“Given gapminder 2007: bars derived by max, x is continent, y is population.”

Each bar is one country: China in Asia, the United States in the Americas, Nigeria in Africa. China alone has more people than all of Africa.

min, the smallest y in each group:

data(gapminder_2007) + bar * min + x(continent) + y(population) +
  y_label("Smallest population") + title("Min per continent: bar * min")
(data(gapminder_2007) + bar * min + x(col.continent) + y(col.population) +
  y_label("Smallest population") + title("Min per continent: bar * min"))
data(gapminder_2007) + bar * min + x(:continent) + y(:population) +
  y_label("Smallest population") + title("Min per continent: bar * min")
plot(data(gapminder_2007), layer(bar, min), x(col.continent),
  y(col.population), y_label("Smallest population"),
  title("Min per continent: bar * min"))
Asia Europe Africa Americas Oceania 0 1M 2M 3M 4M Min per continent: bar * min Smallest population Continent

“Given gapminder 2007: bars derived by min, x is continent, y is population.”

Africa, Asia and Europe each have a country of under one million people. The bar for Oceania is the tallest here because this table holds only two countries there, and the smaller of the two, New Zealand, has about 4 million.

quantile, the value that a given share of each group falls below:

data(gapminder_2007) + bar * quantile(0.9) + x(continent) + y(population) +
  y_label("0.9 quantile of population") +
  title("The 0.9 quantile per continent: bar * quantile(0.9)")
(data(gapminder_2007) + bar * quantile(0.9) + x(col.continent) + y(col.population) +
  y_label("0.9 quantile of population") +
  title("The 0.9 quantile per continent: bar * quantile(0.9)"))
data(gapminder_2007) + bar * quantile(0.9) + x(:continent) +
  y(:population) + y_label("0.9 quantile of population") +
  title("The 0.9 quantile per continent: bar * quantile(0.9)")
plot(data(gapminder_2007), layer(bar, quantile(0.9)), x(col.continent),
  y(col.population), y_label("0.9 quantile of population"),
  title("The 0.9 quantile per continent: bar * quantile(0.9)"))
Asia Europe Africa Americas Oceania 0 50M 100M 150M The 0.9 quantile per continent: bar * quantile(0.9) 0.9 quantile of population Continent

“Given gapminder 2007: bars derived by quantile at 0.9, x is continent, y is population.”

On a continent with many countries, about nine in ten are smaller than the top of its bar. In Asia the top of the bar is at about 165 million, so a country has to be larger than that to be among the largest 10 percent of Asian countries. Oceania has only two countries, so its bar falls between their two sizes. quantile has a section of its own below.

All six aggregations work the same whether x is continuous or categorical. For a categorical x, groups keep the column’s declared order if it has one, and otherwise the order in which they first appear, unless you set one with order. For a continuous x, groups are sorted ascending.

Transform What it computes per group
sum total of y values
mean arithmetic average of y
median middle value of y
max largest y value
min smallest y value
quantile(p) the y at probability p

25.10.1 The same six, grouping by a pair

Mean wind speed by direction is one bar chart. Mean wind speed by direction and season is a grid of cells, with one mean in each.

On a zone, count tallies two axes instead of one, and these six do the same, for the same reason. A bar has a length to show the answer along, so it groups by one position and measures along the other. A zone measures nothing by length. Both its positions are free to be grouping columns, and the answer goes to color. The plot uses the winds table, shown in The same count, tallying two axes:

data(winds) + zone * mean + x(direction) + y(season) + color(speed) +
  title("The same mean, grouped by a pair")
(data(winds) + zone * mean + x(col.direction) + y(col.season) + color(col.speed) +
  title("The same mean, grouped by a pair"))
data(winds) + zone * mean + x(:direction) + y(:season) + color(:speed) +
  title("The same mean, grouped by a pair")
plot(data(winds), layer(zone, mean), x(col.direction), y(col.season),
  color(col.speed), title("The same mean, grouped by a pair"))
N NE E SE S SW W NW Winter Summer The same mean, grouped by a pair Season Direction Speed 21.32 14.59 7.87

The cells for west are the darkest in both rows: winds from the west average about 21 in summer and in winter alike. Each direction changes little from one season to the other, so on this table the direction a wind comes from says more about its speed than the season does.

The channel that names the column to reduce is also the channel that shows the result. That is not a special case for the zone: a summary reduces in place everywhere, and on bar * mean + x(country) + y(gold) it is y that both names gold and receives the average. A zone measures by color, so there color both names the column and shows the result.

So the whole rule fits in one sentence: a summary groups by every position the mark does not measure with, and reduces the column named on the one it does.

mark positions measures with groups by reduces
bar x, y y x y
bar in space() x, y, z z x, y z
surface x, y, z z x, y z
zone x, y color x, y color

The middle two rows are identical, and that is the rule working rather than a coincidence: a 3-D bar and a surface have the same three positions and measure along the same one, so they group and reduce alike. Only the shape drawn on each (x, y) cell differs: an upright bar, or a flat plateau.

What separates these six from count, bin and density is not the number of axes, since all nine can group by one axis or two. It is whether a column was named for them. Those three supply their own measurement. They need no column named, and they write the answer into a new column. These six are given a column instead, so the sentence has to say which one, and the answer replaces it. proportion after another transform belongs to neither group: it divides the measurement that transform wrote. On its own it counts the rows first, as count does.

25.10.2 Why these names?

The six names follow one rule. sum, mean, median, max, min and quantile each name an operation on y, not a data type and not a chart name. The * operator makes this explicit: bar * sum reads as “a bar that sums”. Every aggregation is the word a statistics course already uses (Law 3: Plain Names).

25.11 quantile: one point in the distribution

sum, mean, median, max and min each answer a fixed question. quantile takes the question as its argument: quantile(0.9) is the value below which nine tenths of the group falls, which is the 90th percentile.

Many reported numbers have exactly that form. A service level is the 95th percentile of response time. A growth chart is a set of percentiles by age. A pay band is usually quoted at the 10th, 50th and 90th percentiles. Here are three of them across every country, year by year. The plot uses the gm_all table, shown in The same bin, cutting two axes:

data(gm_all) + x(year) + y(life) +
  line * quantile(0.9) + style(color = "steelblue") +
  line * median + style(color = "gray") +
  line * quantile(0.1) + style(color = "tomato") +
  y_label("Life expectancy") +
  title("The 90th percentile, the median, and the 10th")
(data(gm_all) + x(col.year) + y(col.life) +
  line * quantile(0.9) + style(color = "steelblue") +
  line * median + style(color = "gray") +
  line * quantile(0.1) + style(color = "tomato") +
  y_label("Life expectancy") +
  title("The 90th percentile, the median, and the 10th"))
data(gm_all) + x(:year) + y(:life) + line * quantile(0.9) +
  style(color = "steelblue") + line * median + style(color = "gray") +
  line * quantile(0.1) + style(color = "tomato") +
  y_label("Life expectancy") +
  title("The 90th percentile, the median, and the 10th")
plot(data(gm_all), x(col.year), y(col.life), layer(line, quantile(0.9)),
  style({ color: "steelblue" }), layer(line, median),
  style({ color: "gray" }), layer(line, quantile(0.1)),
  style({ color: "tomato" }), y_label("Life expectancy"),
  title("The 90th percentile, the median, and the 10th"))
1960 1980 2000 40 50 60 70 80 The 90th percentile, the median, and the 10th Life expectancy Year

“Given all the gapminder years: x is year, y is life, a line derived by quantile at 0.9, colored steelblue, and also a line derived by median, colored gray, and also a line derived by quantile at 0.1, colored tomato.”

The three lines are three sentences over one table, and they say different things about the same 142 countries. The middle rose steadily. The top rose more slowly, because it was already high. The bottom rose and then declined for a decade, which neither of the others shows.

Each of those three lines named its own probability: 0.9, 0.5 written as median, and 0.1. Leave the probability out and nothing can run, because quantile has no default the way bin and density do. Every other transform whose parameter is a number works bare, and this one is refused:

data(gm_all) + line * quantile + x(year) + y(life)
data(gm_all) + line * quantile + x(col.year) + y(col.life)
data(gm_all) + line * quantile + x(:year) + y(:life)
plot(data(gm_all), layer(line, quantile), x(col.year), y(col.life))
Error:
! gog: `quantile` needs the probability it reduces to, e.g. `quantile(0.9)` for the 90th percentile. There is no default, because the only sensible one is the middle and that already has a plain name: `median`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The only sensible default is the middle, and the middle already has a plain name. That is also why three particular values get a warning rather than a refusal:

data(gm_all) + line * quantile(0.5) + x(year) + y(life) +
  y_label("Life expectancy") + title("The median, written as a quantile")
(data(gm_all) + line * quantile(0.5) + x(col.year) + y(col.life) +
  y_label("Life expectancy") + title("The median, written as a quantile"))
data(gm_all) + line * quantile(0.5) + x(:year) + y(:life) +
  y_label("Life expectancy") + title("The median, written as a quantile")
plot(data(gm_all), layer(line, quantile(0.5)), x(col.year), y(col.life),
  y_label("Life expectancy"), title("The median, written as a quantile"))
gog: `quantile(0.5)` is the median — write `median` for the plain name. Drawn as asked.
1960 1980 2000 50 60 70 The median, written as a quantile Life expectancy Year

The plot draws, and gog says that median is the plain name for it. quantile(0) and quantile(1) say the same about min and max. They are not refused, because a program that draws every decile in turn would then fail at 0.5 for no good reason. They are not silent either, because Orthogonality says no atom is redundant.

25.12 range: a low/high pair

A mean per species says where each group is and nothing about how far its values reach.

The aggregation family writes one measured value per group. range is the first to write two: the minimum and the maximum of y within each group, emitted as a low then a high. That pair is exactly what an interval spans:

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) + interval * range + x(species) + y(petal_length) +
  x_label("Species") + y_label("Petal length (cm)") +
  title("Petal length range per species")
(data(iris_flowers) + interval * range + x(col.species) + y(col.petal_length) +
  x_label("Species") + y_label("Petal length (cm)") +
  title("Petal length range per species"))
data(iris_flowers) + interval * range + x(:species) + y(:petal_length) +
  x_label("Species") + y_label("Petal length (cm)") +
  title("Petal length range per species")
plot(data(iris_flowers), layer(interval, range), x(col.species),
  y(col.petal_length), x_label("Species"), y_label("Petal length (cm)"),
  title("Petal length range per species"))
setosa versicolor virginica 2 4 6 Petal length range per species Petal length (cm) Species

“Given the iris flowers: intervals derived by range, x is species, y is petal length.”

range is not the interval’s own transform. Three other marks read the same low and high value, and only the shape changes:

Mark derived by range Shape
ribbon * range the band between the two values, filled
line * range the two values as two separate strokes
step * range the same two boundaries, as staircases
interval * range one whisker per group, the plot above

You can put confidence, deviation or bounds in place of range in every row of that table, and bounds adds zone, which shades the region between its two columns.

range reads a y and groups by x exactly like the aggregation family: it needs a y() and works the same whether x is continuous or categorical. What differs is the output: two rows per group instead of one. This low/high-rows shape is reused elsewhere. The box mark encodes its own summary the same way, with the two whisker ends as the pair of rows and the quartiles alongside. The ribbon mark fills between the same pair as a continuous band. See Interval for the whisker, and for why its extents come from a transform rather than from two extra channels the kernel would never otherwise have.

25.12.1 A band between two quantiles

The minimum and the maximum each come from a single row, so one unusual row moves the whole span. To show the middle of a group instead of its extremes, give range two quantiles:

data(iris_flowers) + interval * range(0.25, 0.75) + x(species) + y(petal_length) +
  x_label("Species") + y_label("Petal length (cm)") +
  title("Middle half of petal length per species")
(data(iris_flowers) + interval * range(0.25, 0.75) + x(col.species) + y(col.petal_length) +
  x_label("Species") + y_label("Petal length (cm)") +
  title("Middle half of petal length per species"))
data(iris_flowers) + interval * range(0.25, 0.75) + x(:species) +
  y(:petal_length) + x_label("Species") + y_label("Petal length (cm)") +
  title("Middle half of petal length per species")
plot(data(iris_flowers), layer(interval, range(0.25, 0.75)),
  x(col.species), y(col.petal_length), x_label("Species"),
  y_label("Petal length (cm)"),
  title("Middle half of petal length per species"))
setosa versicolor virginica 2 3 4 5 6 Middle half of petal length per species Petal length (cm) Species

“Given the iris flowers: intervals derived by range from 0.25 to 0.75, x is species, y is petal length.”

The two numbers are quantile probabilities, so range(0.25, 0.75) spans the first quartile to the third. That span is the interquartile range, and it is the same one a box draws as its body, computed by the same rule. Any other pair works the same way: range(0.1, 0.9) keeps the middle 80%, and range(0.05, 0.95) the middle 90%.

Bare range is range(0, 1), because the quantile at 0 is the minimum and the quantile at 1 is the maximum. So interval * range is unchanged, and the two arguments only make its default visible. Naming one end leaves the other at its extreme, so range(high = 0.9) runs from the smallest value to the 90th percentile.

Both ends are probabilities. A number below 0 or above 1 is refused, not corrected:

data(iris_flowers) + interval * range(0.25, 75) + x(species) + y(petal_length)
data(iris_flowers) + interval * range(0.25, 75) + x(col.species) + y(col.petal_length)
data(iris_flowers) + interval * range(0.25, 75) + x(:species) +
  y(:petal_length)
plot(data(iris_flowers), layer(interval, range(0.25, 75)), x(col.species),
  y(col.petal_length))
Error:
! gog: `range(high = 75)` is not a probability — the band's ends are quantiles, so each is between 0 and 1. `range(0.25, 0.75)` is the middle half, `range(0.1, 0.9)` the middle 80 percent, and bare `range` the whole group.

For a low and a high you computed yourself, in the data’s own units, use bounds instead.

25.13 confidence: the interval of the mean

range shows the full spread of a group. Often the question is the uncertainty of the mean instead, which is what an error bar shows. confidence computes the mean’s confidence interval per group (mean ± t·se, with se the standard error: the t-interval), emitting a low, a high, and a center. That center is what turns the whisker into a pointrange, a whisker with a dot at its center:

data(iris_flowers) + interval * confidence(0.95) + x(species) + y(petal_length) +
  x_label("Species") + y_label("Petal length (cm)") +
  title("95% CI of mean petal length")
(data(iris_flowers) + interval * confidence(0.95) + x(col.species) + y(col.petal_length) +
  x_label("Species") + y_label("Petal length (cm)") +
  title("95% CI of mean petal length"))
data(iris_flowers) + interval * confidence(0.95) + x(:species) +
  y(:petal_length) + x_label("Species") + y_label("Petal length (cm)") +
  title("95% CI of mean petal length")
plot(data(iris_flowers), layer(interval, confidence(0.95)),
  x(col.species), y(col.petal_length), x_label("Species"),
  y_label("Petal length (cm)"), title("95% CI of mean petal length"))
setosa versicolor virginica 2 3 4 5 95% CI of mean petal length Petal length (cm) Species

“Given the iris flowers: intervals derived by confidence at 95%, x is species, y is petal length.”

The level is a parameter, exactly like bin(30) and density(2): confidence alone is 95%, confidence(0.99) is wider. It uses the t-distribution (not a fixed 1.96), so a small group’s interval is genuinely wider. A group of one has no spread, and its interval collapses to a point.

The center dot appears because the statistic has a center; range has none, so it draws a bare whisker. That is the rule for every pair transform: the transform decides whether there is a center, and the mark draws what it is given. So the difference between an error bar and a pointrange is which statistic you ask for, not another mark to remember.

25.14 deviation: the spread of the data

confidence says how precisely a mean is known. deviation says how far the values behind it spread. That is a different question, drawn as the same picture: deviation emits the same low, high and center, so it also draws a pointrange:

data(iris_flowers) + interval * deviation + x(species) + y(petal_length) +
  x_label("Species") + y_label("Petal length (cm)") +
  title("Mean petal length, with one standard deviation")
(data(iris_flowers) + interval * deviation + x(col.species) + y(col.petal_length) +
  x_label("Species") + y_label("Petal length (cm)") +
  title("Mean petal length, with one standard deviation"))
data(iris_flowers) + interval * deviation + x(:species) +
  y(:petal_length) + x_label("Species") + y_label("Petal length (cm)") +
  title("Mean petal length, with one standard deviation")
plot(data(iris_flowers), layer(interval, deviation), x(col.species),
  y(col.petal_length), x_label("Species"), y_label("Petal length (cm)"),
  title("Mean petal length, with one standard deviation"))
setosa versicolor virginica 2 3 4 5 6 Mean petal length, with one standard deviation Petal length (cm) Species

“Given the iris flowers: intervals derived by deviation, x is species, y is petal length.”

The number of standard deviations is a parameter, like confidence’s level: deviation alone reaches one standard deviation on each side of the mean, and deviation(2) reaches two, so that band is four standard deviations wide. The standard deviation is the sample one, dividing by n − 1, so a group of one has no spread to show and collapses to a point exactly as confidence does.

Neither transform makes the other redundant, and neither does range. On the iris flowers the confidence band is under a third as long as the deviation band: fifty measurements locate a mean precisely, and their spread stays as wide as it was. Adding rows narrows the first band, while the second stays about as wide as it was. Interval draws the two side by side and says which sentence each one supports.

range(0.25, 0.75) answers a third version of the question. It reads the sorted values, so it reports where the middle half of the values actually lie. deviation starts from the mean instead, and reports a distance out from it. On a skewed group they disagree, and the disagreement is informative rather than a defect.

25.15 bounds: a pre-computed low/high pair

range and confidence compute the pair from raw values. bounds is their counterpart that computes nothing. Sometimes you already have the low and the high in two columns, computed before the plot: a model’s standard error, a psychometric standard error of measurement, a bootstrap interval. bounds(lower, upper) reshapes those two columns into the very same low/high pair, so a ribbon or interval draws them with no new atom and no new rule:

score_band: first 5 of 21 rows
score expected lower upper
0 0 -1.610304 1.610304
1 1 -1.865557 3.865557
2 2 -1.944424 5.944424
3 3 -1.694803 7.694803
4 4 -1.259232 9.259232
data(score_band) + ribbon * bounds(lower, upper) + x(score) +
  y_label("Expected true score") + x_label("Raw score") +
  title("A pre-computed band: ribbon with bounds")
(data(score_band) + ribbon * bounds(col.lower, col.upper) + x(col.score) +
  y_label("Expected true score") + x_label("Raw score") +
  title("A pre-computed band: ribbon with bounds"))
data(score_band) + ribbon * bounds(:lower, :upper) + x(:score) +
  y_label("Expected true score") + x_label("Raw score") +
  title("A pre-computed band: ribbon with bounds")
plot(data(score_band), layer(ribbon, bounds(col.lower, col.upper)),
  x(col.score), y_label("Expected true score"), x_label("Raw score"),
  title("A pre-computed band: ribbon with bounds"))
0 5 10 15 20 0 10 20 A pre-computed band: ribbon with bounds Expected true score Raw score

“Given the score band: a ribbon from lower to upper, x is score.”

It differs from every transform above in two ways that both follow from reshaping, not computing. Its arguments are column names, not a number: it has to be told which columns hold the bounds. And it needs no y(): those two columns are the extents, so (like count, which writes its own y column) the mark takes its low and high from bounds and the y axis fits them. bounds shows where gog stops computing. gog offers a small, deliberate set of statistics; everything else you compute yourself, and bounds draws a band you already have.

It is legal on the five marks that can draw a low/high pair, each a different geometry over the same pair, the No Exceptions rule that lets every mark in the bin table draw the same bins:

  • ribbon fills the pair (a filled band),
  • interval draws a whisker across it at each x,
  • line traces its two boundaries (the unfilled band),
  • step traces them as staircases (stepped control limits), and
  • zone shades the region between them, the band that names its own sides rather than tracking a curve.

So line * bounds(lower, upper) + style(pattern = "dashed") is the unfilled, dashed error band: the two ± curves with no fill between them. It is the same pair that ribbon * bounds fills, drawn by a different mark. gog refuses bounds on any other mark, and the message points to the band marks, or to a summary such as mean for one value per group.

25.16 partition: a whole among nested parts

Nearly every transform so far reads a column and writes a summary of it; bounds alone reshapes what you already had. partition reads a hierarchy: two or three columns that trace the path down to each row. It writes the region each node of that hierarchy occupies, four edges and a center, which is exactly what bin writes for a cell. That is what makes it a transform rather than a coordinate space, and it is why zone needs nothing new to draw the result.

The hierarchy’s levels can run two ways, and the difference is one parameter. Nested is the default: every level divides the same axis, and the other axis adds one band per level. On a flat plot that reading is the icicle. In polar() each band becomes a ring, which is the sunburst, and that chapter works it through in full.

Crossed is the other, and it is the one this chapter draws. The levels alternate: the first divides the width, the second divides the height within each of those columns, a third would divide the width again. The plot it makes is the mosaic.

25.16.1 The mosaic

A cross-tabulation raises two questions at once: how the two categories relate, and how many people each cell counts. A mosaic answers both in one picture.

commutes is a survey of how people get to work in four cities, one row per city and mode, with a count:

commutes: first 6 of 16 rows
city mode people
Ashford Car 980
Ashford Bus or tram 340
Ashford Bicycle 110
Ashford On foot 110
Brightwell Car 420
Brightwell Bus or tram 380
data(commutes) + zone * partition(city, mode, cross = TRUE) + x(people) +
  color(mode) + style(border_color = "white", border_size = 2) +
  x_label("People surveyed") + title("How four cities get to work")
(data(commutes) + zone * partition(col.city, col.mode, cross = True) + x(col.people) +
  color(col.mode) + style(border_color = "white", border_size = 2) +
  x_label("People surveyed") + title("How four cities get to work"))
data(commutes) + zone * partition(:city, :mode, cross = true) +
  x(:people) + color(:mode) +
  style(border_color = "white", border_size = 2) +
  x_label("People surveyed") + title("How four cities get to work")
plot(data(commutes),
  layer(zone, partition(col.city, col.mode, { cross: true })),
  x(col.people), color(col.mode),
  style({ border_color: "white", border_size: 2 }),
  x_label("People surveyed"), title("How four cities get to work"))
0 1K 2K 3K 0.0 0.2 0.4 0.6 0.8 1.0 How four cities get to work Share of column People surveyed Mode Car Bus or tram Bicycle On foot

“Given the commutes: zones derived by partition through city and mode, crossed, x is people, color by mode.”

Two marks read a partition, and text names the boxes that zone draws:

Mark derived by partition Shape
text * partition the node’s own name, at its center
zone * partition one box per node, the plot above

The plot shows two variables, one in each direction. A column’s width is that city’s share of everyone surveyed, so Ashford’s column is three times Millbrook’s; a cell’s height is that mode’s share within its own city, so every column fills the panel whatever its width. The consequence is the one a mosaic is drawn for: a cell’s area is the number of people in it, and a reader compares those areas across the whole panel without being asked to.

That is also what makes Millbrook readable rather than misleading. Its bicycle share is the largest of the four, and its column is the narrowest. So the plot reports the finding and the number of people it is based on at once, which four equal-width stacked bars cannot do.

The two axes therefore carry different quantities, so look at both before you read a value from either. The horizontal axis runs from 0 to the total in the data’s own units, 3,790 people. The vertical axis runs from 0 to 1 and can do nothing else, because what a height means is a share of its own column and the grand total is not the thing being divided.

Compose the partition with proportion and the horizontal axis reads shares of everyone surveyed instead:

data(commutes) + zone * partition(city, mode, cross = TRUE) * proportion +
  x(people) + color(mode) + style(border_color = "white", border_size = 2) +
  x_label("Share of people surveyed") +
  title("The same mosaic, its width in shares")
(data(commutes) + zone * partition(col.city, col.mode, cross = True) * proportion +
  x(col.people) + color(col.mode) + style(border_color = "white", border_size = 2) +
  x_label("Share of people surveyed") +
  title("The same mosaic, its width in shares"))
data(commutes) +
  zone * partition(:city, :mode, cross = true) * proportion + x(:people) +
  color(:mode) + style(border_color = "white", border_size = 2) +
  x_label("Share of people surveyed") +
  title("The same mosaic, its width in shares")
plot(data(commutes),
  layer(zone, partition(col.city, col.mode, { cross: true }), proportion),
  x(col.people), color(col.mode),
  style({ border_color: "white", border_size: 2 }),
  x_label("Share of people surveyed"),
  title("The same mosaic, its width in shares"))
0.0 0.2 0.4 0.6 0.8 1.0 0.0 0.2 0.4 0.6 0.8 1.0 The same mosaic, its width in shares Share of column Share of people surveyed Mode Car Bus or tram Bicycle On foot

“Given the commutes: zones derived by partition through city and mode, crossed, and proportion, x is people, color by mode.”

The columns keep their widths, and the axis under them now runs from 0 to 1. Ashford’s column ends near 0.41, its share of the 3,790 people. The heights do not change, because each was already a share of its own column.

partition writes a name column holding each node’s own label, and a text layer reads it. The sunburst uses the same kind of layer. A shallower partition of the same table puts its nodes in exactly the same places, so the second layer needs no filtering and no arithmetic of its own.

data(commutes) + x(people) +
  zone * partition(city, mode, cross = TRUE) + color(mode) +
  style(border_color = "white", border_size = 2) +
  text * partition(city, cross = TRUE) + label(name) +
  x_label("People surveyed") + title("The same plot, with its columns named")
(data(commutes) + x(col.people) +
  zone * partition(col.city, col.mode, cross = True) + color(col.mode) +
  style(border_color = "white", border_size = 2) +
  text * partition(col.city, cross = True) + label(col.name) +
  x_label("People surveyed") + title("The same plot, with its columns named"))
data(commutes) + x(:people) +
  zone * partition(:city, :mode, cross = true) + color(:mode) +
  style(border_color = "white", border_size = 2) +
  text * partition(:city, cross = true) + label(:name) +
  x_label("People surveyed") +
  title("The same plot, with its columns named")
plot(data(commutes), x(col.people),
  layer(zone, partition(col.city, col.mode, { cross: true })),
  color(col.mode), style({ border_color: "white", border_size: 2 }),
  layer(text, partition(col.city, { cross: true })), label(col.name),
  x_label("People surveyed"),
  title("The same plot, with its columns named"))
Ashford Brightwell Calder Millbrook 0 1K 2K 3K 0.0 0.2 0.4 0.6 0.8 1.0 The same plot, with its columns named Share of column People surveyed Mode Car Bus or tram Bicycle On foot

“Given the commutes: x is people, zones derived by partition through city and mode, crossed, colored by mode, and also text derived by partition through city, crossed, label by name.”

The labels are at the middle of each column because that is where the center of a column is, and style(nudge = ) is what moves them if you want them along the top. The city names would normally be on an axis. But gog’s category axis gives every slot the same width, and the mosaic’s columns all have different widths. So the names are drawn as a layer.

The cell edges are a style() setting like any other, and they matter more here than anywhere else in the book. Without them, two neighboring cells that share a color merge into one shape and the column boundary disappears:

data(commutes) + zone * partition(city, mode, cross = TRUE) + x(people) +
  color(mode) + x_label("People surveyed") +
  title("The same plot with no edges")
(data(commutes) + zone * partition(col.city, col.mode, cross = True) + x(col.people) +
  color(col.mode) + x_label("People surveyed") +
  title("The same plot with no edges"))
data(commutes) + zone * partition(:city, :mode, cross = true) +
  x(:people) + color(:mode) + x_label("People surveyed") +
  title("The same plot with no edges")
plot(data(commutes),
  layer(zone, partition(col.city, col.mode, { cross: true })),
  x(col.people), color(col.mode), x_label("People surveyed"),
  title("The same plot with no edges"))
0 1K 2K 3K 0.0 0.2 0.4 0.6 0.8 1.0 The same plot with no edges Share of column People surveyed Mode Car Bus or tram Bicycle On foot

Both plots draw the same numbers. The second plot shows why a zone has the style(border_color = , border_size = ) settings at all. The mark has two uses. It is a highlight behind a line, where a frame would distract from the data, and it is the cells that are the data.

One level is legal and means something: the spine plot, columns of varying width and nothing dividing them, which is the marginal distribution on its own.

data(commutes) + zone * partition(city, cross = TRUE) + x(people) +
  color(city) + style(border_color = "white", border_size = 2) +
  x_label("People surveyed") + title("The margin alone: a spine plot")
(data(commutes) + zone * partition(col.city, cross = True) + x(col.people) +
  color(col.city) + style(border_color = "white", border_size = 2) +
  x_label("People surveyed") + title("The margin alone: a spine plot"))
data(commutes) + zone * partition(:city, cross = true) + x(:people) +
  color(:city) + style(border_color = "white", border_size = 2) +
  x_label("People surveyed") + title("The margin alone: a spine plot")
plot(data(commutes), layer(zone, partition(col.city, { cross: true })),
  x(col.people), color(col.city),
  style({ border_color: "white", border_size: 2 }),
  x_label("People surveyed"), title("The margin alone: a spine plot"))
0 1K 2K 3K 0.0 0.2 0.4 0.6 0.8 1.0 The margin alone: a spine plot Share of column People surveyed City Ashford Brightwell Calder Millbrook

partition refuses the same things whether it is crossed or nested, because the refusals are about the tree and not about the layout. A level must be a categorical column, not a continuous one. A branch may stop early, but it may not skip a level and then continue. And an interior node carrying a value of its own is the one genuine ambiguity, so it is refused rather than guessed. The Polar chapter shows each refusal where it is easiest to see.

25.17 flow: a magnitude through its stages

Passengers are counted by class, then by whether they survived. The question is how many of each class reached each outcome.

flow is similar to partition in three ways. Both read categorical columns named in the atom, both place their own marks, and both supply more than one mark from one computation. Where partition divides a whole among nested parts, flow carries a magnitude through a sequence of stages. One row of the table is one path through every stage the atom names.

titanic: first 5 of 32 rows
class sex age survived n
1st Male Adult Yes 57
1st Male Adult No 118
1st Male Child Yes 5
1st Male Child No 0
1st Female Adult Yes 140
data(titanic) + y(n) +
  ribbon * flow(class, survived) + color(class) +
  zone * flow(class, survived) +
  title("Who survived, by class")
(data(titanic) + y(col.n) +
  ribbon * flow(col["class"], col.survived) + color(col["class"]) +
  zone * flow(col["class"], col.survived) +
  title("Who survived, by class"))
data(titanic) + y(:n) + ribbon * flow(:class, :survived) + color(:class) +
  zone * flow(:class, :survived) + title("Who survived, by class")
plot(data(titanic), y(col.n),
  layer(ribbon, flow(col.class, col.survived)), color(col.class),
  layer(zone, flow(col.class, col.survived)),
  title("Who survived, by class"))
class survived 0 1K 2K Who survived, by class N Class 1st 2nd 3rd Crew

“Given titanic: y is n, ribbons derived by flow through class and survived, colored by class, and also zones derived by flow through class and survived.”

Three marks read a flow, and a full diagram layers all three:

Mark derived by flow Shape
ribbon * flow the bands between the stages, the plot above
zone * flow the slots at each stage
text * flow the name of each slot

The ribbon layer draws the bands, one per path, as thick as the path’s total at both ends. The zone layer draws each stage’s slots, stacked with no padding, so the measure axis is drawn with its usual ticks and reads true counts. A text layer names the slots. With shared = TRUE, two columns such as exporter and importer name one set of places, and every place lies on one axis, which carries no numbers. Drawn flat, it is the arc diagram; in polar(), it is the chord diagram. With layered = TRUE, each row is a link from one place to another, and gog computes a layer for each place from the links. That picture is the Sankey diagram. The Flow chapter builds the full diagram and gives the rules; this section only shows where the atom fits among the transforms.

25.18 layout: a graph placed from an edge table

partition, flow, layout and cluster each compute a whole picture rather than a column. layout is the third of them, and the first whose output draws in a space of its own. layout(from, to) reads the two endpoint columns of an edge table and computes a position for every distinct name, in the engine, the same way in every language. Three marks read the placement inside network(): edge the connections, point the nodes, text their names. Its positions mean nothing as quantities, so its space draws no axes. This section draws no layout plot. The Network chapter draws those plots and gives the rules; this section only shows where the atom fits among the transforms.

Mark derived by layout Shape
point * layout one dot per node
text * layout each node’s name
edge * layout one stroke per link

25.19 cluster: the closest leaves joined first

cluster is the fourth transform that computes a whole picture, and the question it answers is which things are most alike. It treats each category of an axis as a leaf of a tree, joins the two closest leaves, then the next two, and keeps joining until one tree holds them all. Each leaf is described by a profile: cluster(amount, over = nutrient) says that one leaf’s profile is its amount in every category of nutrient.

nutrients: first 5 of 40 rows
food nutrient amount
salmon protein 25.4
salmon fat 12.4
salmon carbs 0.0
salmon fiber 0.0
salmon iron 0.5
data(nutrients) + path * cluster(amount, over = nutrient) + x(food) +
  title("Eight foods, joined by what they are made of")
(data(nutrients) + path * cluster(col.amount, over=col.nutrient) + x(col.food) +
  title("Eight foods, joined by what they are made of"))
data(nutrients) + path * cluster(:amount, over = :nutrient) + x(:food) +
  title("Eight foods, joined by what they are made of")
plot(data(nutrients),
  layer(path, cluster(col.amount, { over: col.nutrient })), x(col.food),
  title("Eight foods, joined by what they are made of"))
chicken salmon spinach oats rice beans lentils almonds 0 20 40 Eight foods, joined by what they are made of Distance Food

“Given nutrients: paths derived by cluster on amount, over nutrient, x is food.”

Two marks read a cluster:

Mark derived by cluster Shape
zone * cluster the same leaves as cells, in the tree’s order
path * cluster the tree as joined strokes, the plot above

path draws the tree, and the height of a join says how different its two branches are. The same transform on zone reorders a tile plot’s slots to the tree’s leaf order. The Cluster chapter grows the tree, orders the tiles, and builds the clustered heatmap; this section only shows where the atom fits among the transforms.

25.20 dodge: side by side instead of on top

Every transform above answers a question about the data. The four that remain answer none. They change where a mark is drawn rather than what was measured. dodge is a different kind of transform: a collision modifier. It writes no new values; it moves marks that would otherwise be drawn at one position, and sets them side by side instead.

When color splits a bar, a box or an interval into groups, the groups share their category’s slot and are drawn on top of one another. A histogram reads well that way, because the overlapping bars are translucent. A grouped bar chart does not, because there you want the groups set side by side. dodge does exactly that:

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) + bar * mean * dodge + x(continent) + y(life) + color(era) +
  y_label("Mean life expectancy") +
  title("Mean life expectancy by continent, 1957 vs 2007")
(data(gm_eras) + bar * mean * dodge + x(col.continent) + y(col.life) + color(col.era) +
  y_label("Mean life expectancy") +
  title("Mean life expectancy by continent, 1957 vs 2007"))
data(gm_eras) + bar * mean * dodge + x(:continent) + y(:life) +
  color(:era) + y_label("Mean life expectancy") +
  title("Mean life expectancy by continent, 1957 vs 2007")
plot(data(gm_eras), layer(bar, mean, dodge), x(col.continent),
  y(col.life), color(col.era), y_label("Mean life expectancy"),
  title("Mean life expectancy by continent, 1957 vs 2007"))
Asia Europe Africa Americas Oceania 0 20 40 60 80 Mean life expectancy by continent, 1957 vs 2007 Mean life expectancy Continent Era 1957 2007

“Given the gapminder eras: bars derived by mean and dodge, x is continent, y is life, color by era.”

Five marks take dodge. Three divide the slot’s width among their groups. A point moves sideways within the slot instead, and a text moves sideways to its own group’s bar:

Mark derived by dodge Shape
box * dodge one box per group, side by side in the slot
interval * dodge one whisker per group, side by side
point * dodge each point moved across the slot until none touch: the beeswarm
bar * dodge one bar per group, the plot above
text * dodge each group’s label over its own bar: Labels on dodged bars

The two eras are next to each other in every continent. Together they span the width one bar would. dodge divides that width into one part per group (G groups, each 1/G of it), and each bar leaves a fifth of its part empty, so the two do not touch. style(gap = ) sets that share; the space between continents does not change. And because the offset resolves the overlap outright, dodged bars take the same fill a single bar does. The translucent fill exists only to see through overlapping bars, and none overlap now.

It is written after * like any transform, so it composes cleanly: bar * mean * dodge aggregates first and dodges the result; box * dodge dodges a box’s own summary; interval * range * dodge dodges grouped whiskers. Here it sets each continent’s two distributions side by side:

data(gm_eras) + box * dodge + x(continent) + y(life) + color(era) +
  y_label("Life expectancy") +
  title("Life expectancy distribution, 1957 vs 2007")
(data(gm_eras) + box * dodge + x(col.continent) + y(col.life) + color(col.era) +
  y_label("Life expectancy") +
  title("Life expectancy distribution, 1957 vs 2007"))
data(gm_eras) + box * dodge + x(:continent) + y(:life) + color(:era) +
  y_label("Life expectancy") +
  title("Life expectancy distribution, 1957 vs 2007")
plot(data(gm_eras), layer(box, dodge), x(col.continent), y(col.life),
  color(col.era), y_label("Life expectancy"),
  title("Life expectancy distribution, 1957 vs 2007"))
Asia Europe Africa Americas Oceania 40 60 80 Life expectancy distribution, 1957 vs 2007 Life expectancy Continent Era 1957 2007

“Given the gapminder eras: boxes derived by dodge, x is continent, y is life, color by era.”

Which marks dodge serves is decided by what a mark is. On bar, box and interval it subdivides a width (a bar’s thickness, a box’s body, a whisker’s slot). A point has no width, so dodge moves it instead. Each point moves sideways within its slot, only as far as it must to touch no other point, and never along the measure axis. A text has no width either, so dodge moves each label to its own group’s bar. An area is piled rather than subdivided (that is stack, the next section). Ask for dodge on an area and gog refuses, naming the collision modifier that fits. A line takes neither dodge nor stack. It is a thin stroke, so lines that meet cross rather than cover each other. The rest of the kernel follows the same rule: an atom combines with the marks it was defined for, and the refusal names the atom that fits rather than drawing something wrong.

On a bar, box, interval or text, dodge also needs a split to separate: a channel that splits the rows into groups, such as color or group. Without one there is a single mark per slot and nothing to set beside anything, so bar * dodge on its own is refused, and the message asks for the split. It is never accepted as a word that quietly does nothing. A point needs no split, because points in one slot can already land on one another. With a split, each group takes its own part of the slot, as a dodged bar does, and its points are moved apart inside that part:

data(gm_eras) + point * dodge + x(continent) + y(life) + color(era) +
  style(size = 2.5) + y_label("Life expectancy") +
  title("Each era swarms in its half of the slot")
(data(gm_eras) + point * dodge + x(col.continent) + y(col.life) + color(col.era) +
  style(size = 2.5) + y_label("Life expectancy") +
  title("Each era swarms in its half of the slot"))
data(gm_eras) + point * dodge + x(:continent) + y(:life) + color(:era) +
  style(size = 2.5) + y_label("Life expectancy") +
  title("Each era swarms in its half of the slot")
plot(data(gm_eras), layer(point, dodge), x(col.continent), y(col.life),
  color(col.era), style({ size: 2.5 }), y_label("Life expectancy"),
  title("Each era swarms in its half of the slot"))
Asia Europe Africa Americas Oceania 40 60 80 Each era swarms in its half of the slot Life expectancy Continent Era 1957 2007

“Given the gapminder eras: points derived by dodge, x is continent, y is life, color by era.”

The dots are drawn smaller here, with style(size = 2.5). Each era has half a slot, and at the default size Europe’s 2007 countries are too close together to fit in it, so gog would draw some of them at the edge and say how many.

25.21 stack: piled up, not side by side

Side by side, two eras’ populations never show what they add up to, and when the total means something the pile is the better picture.

stack is dodge’s sibling: the other way to resolve a color split’s overlap. Where dodge sets the groups beside each other, stack piles them on top of each other along the measure axis, each group starting where the one below it ended. It is the collision modifier for the marks that accumulate: bar and area pile heights, and point piles rows.

Take the dodge example, change dodge to stack, and sum population instead of averaging life expectancy:

data(gm_eras) + bar * sum * stack + x(continent) + y(population) + color(era) +
  y_label("Population") +
  title("Population by continent, 1957 and 2007 stacked")
(data(gm_eras) + bar * sum * stack + x(col.continent) + y(col.population) + color(col.era) +
  y_label("Population") +
  title("Population by continent, 1957 and 2007 stacked"))
data(gm_eras) + bar * sum * stack + x(:continent) + y(:population) +
  color(:era) + y_label("Population") +
  title("Population by continent, 1957 and 2007 stacked")
plot(data(gm_eras), layer(bar, sum, stack), x(col.continent),
  y(col.population), color(col.era), y_label("Population"),
  title("Population by continent, 1957 and 2007 stacked"))
Asia Europe Africa Americas Oceania 0 2B 4B Population by continent, 1957 and 2007 stacked Population Continent Era 1957 2007

“Given the gapminder eras: bars derived by sum and stack, x is continent, y is population, color by era.”

Three marks pile instead of overlapping:

Mark derived by stack Shape
area * stack each region starting at the top of the one below
point * bin * stack one dot per row, piled into a column, which is the dot plot
bar * stack one segment per group, piled, the plot above

The measure changed because you stack quantities that add up. Two populations can be added into one bar, while two mean life expectancies cannot: Europe’s two eras would pile into a bar reaching 144 years, so that comparison wants dodge, side by side. The grammar allows either, and the data decides which is right. Because the pile resolves the overlap, the bars take the same fill a single bar does, and the axis grows to the tallest stack: Asia passes five billion, the sum of its two eras.

Which marks stack serves is decided by what a mark is: the same rule dodge follows. What stack computes is one thing for every mark, a span from a foot to a top along the measure axis, and what differs is how a geometry draws that span. A bar fills it, and an area fills it across x. A point has no length to stretch across the span, so the span becomes how many dots there are, which is the dot plot. A box or interval is set beside its neighbors rather than piled, which is dodge. An unfilled line has nothing to fill and pile, so use area. A ribbon already spans a low to a high, so it measures no height from a baseline. Ask for stack on one of those and gog refuses, naming what fits instead. For bar and area it also needs a channel that splits the rows into groups to pile, such as color or group. bar * stack alone is refused, and the message asks for the split. It is never accepted as a word that quietly does nothing.

An estimated curve cannot be piled either. density computes each group’s curve at points along that group’s own range of values. The groups’ points do not line up, so stack has nothing to pile at one position:

data(gm_eras) + area * density * stack + x(life) + color(era)
data(gm_eras) + area * density * stack + x(col.life) + color(col.era)
data(gm_eras) + area * density * stack + x(:life) + color(:era)
plot(data(gm_eras), layer(area, density, stack), x(col.life),
  color(col.era))
Error:
! gog: `area * density * stack` piles the groups that land on one position, and `density` computes each group's curve at points along that group's own range, so the groups' points do not line up and nothing would pile. To compare the groups' curves, overlay them: `area * density + color(<group>) + style(opacity = 0.5)`, or give each group its own panel: `area * density | facet(<group>)`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

To compare the shapes of the two eras, overlay them and make the fills partly transparent with style(opacity = 0.5), or give each era its own panel with | facet(era).

25.21.1 Stacked areas: the case stack was built for

A split area is where overlap matters most. Filled regions cover one another, and gog warns about it (the area chapter shows the warning and the opacity it suggests). Lowering the opacity does let you see through them, but five overlapping fills blend into colors the legend names for none of them. stack answers the same problem a different way. Each region starts at the cumulative height of the ones below, so they meet edge to edge in one band instead of covering each other, the classic “parts of a whole, over time”. The plot uses the gm_all table, shown in The same bin, cutting two axes:

data(gm_all) + area * sum * stack + x(year) + y(population) + color(continent) +
  y_label("Population") +
  title("World population by continent, stacked")
(data(gm_all) + area * sum * stack + x(col.year) + y(col.population) + color(col.continent) +
  y_label("Population") +
  title("World population by continent, stacked"))
data(gm_all) + area * sum * stack + x(:year) + y(:population) +
  color(:continent) + y_label("Population") +
  title("World population by continent, stacked")
plot(data(gm_all), layer(area, sum, stack), x(col.year),
  y(col.population), color(col.continent), y_label("Population"),
  title("World population by continent, stacked"))
1960 1980 2000 0 2B 4B 6B World population by continent, stacked Population Year Continent Asia Europe Africa Americas Oceania

“Given all the gapminder years: areas derived by sum and stack, x is year, y is population, color by continent.”

Asia is at the bottom; Europe, Africa, the Americas and Oceania pile on top, and the band’s upper edge is the world total. It climbs from 2.4 billion people to 6.25 billion. Read any one color’s thickness for that continent’s population.

25.21.2 stack(share = TRUE): every pile filled to one

Reading a thickness is the hard part of the plot above, because each band’s baseline moves under it. When the composition is the question and the total is not, stack(share = TRUE) divides every pile by its own slot’s total, so all of them reach 1 and only the proportions are left. That is filling the pile:

data(gm_all) + area * sum * stack(share = TRUE) + x(year) + y(population) +
  color(continent) + title("Every year's composition, filled to one")
(data(gm_all) + area * sum * stack(share = True) + x(col.year) + y(col.population) +
  color(col.continent) + title("Every year's composition, filled to one"))
data(gm_all) + area * sum * stack(share = true) + x(:year) +
  y(:population) + color(:continent) +
  title("Every year's composition, filled to one")
plot(data(gm_all), layer(area, sum, stack({ share: true })), x(col.year),
  y(col.population), color(col.continent),
  title("Every year's composition, filled to one"))
1960 1980 2000 0.0 0.5 1.0 Every year's composition, filled to one Share Year Continent Asia Europe Africa Americas Oceania

“Given all the gapminder years: areas derived by sum and stack as shares, x is year, y is population, color by continent.”

Now Asia’s band holds about three fifths, and Europe’s visibly narrows across those 55 years. Both facts were also in the stacked plot before this one, where the rising total hid them. The axis says Share, because whatever the numbers were before, they are fractions of one now.

This is not a second name for proportion. The two divide by different totals. proportion divides by the whole plot’s, so its bars still say how big each slot is. This one divides by the slot’s own, which deliberately discards the slot’s size. So each year here is a set of shares of that year’s world population, a reading proportion cannot give in a single panel. Filling is a position adjustment: it changes where the marks are drawn and what the scale reads, never what was counted, which is why it belongs to stack and not to a statistic.

A bar fills the same way, and the result is the 100% stacked bar, worked through in the bar chapter. One parameter, both geometries, exactly as plain stack hands both marks the same span and lets each draw it: No Exceptions.

The axis of a filled pile runs from 0 to 1. As a percent, it runs from 0% to 100%, which is how a share is often read. Add percent = TRUE to y(). Here y() names population, the column sum adds up:

data(gm_all) + area * sum * stack(share = TRUE) + x(year) +
  y(population, percent = TRUE) + color(continent)
(data(gm_all) + area * sum * stack(share = True) + x(col.year) +
  y(col.population, percent = True) + color(col.continent))
data(gm_all) + area * sum * stack(share = true) + x(:year) +
  y(:population, percent = true) + color(:continent)
plot(data(gm_all), layer(area, sum, stack({ share: true })), x(col.year),
  y(col.population, { percent: true }), color(col.continent))
1960 1980 2000 0% 50% 100% Share Year Continent Asia Europe Africa Americas Oceania

“Given all the gapminder years: areas derived by sum and stack as shares, x is year, y is population written as a percent, color by continent.”

Only the numbers on the axis change, as Scales explains.

25.21.3 stack(baseline = ): where the pile starts

Two things about a pile can be changed. Its heights are one, and share is how you change them. Where the pile starts is the other, and baseline is how you change that.

The default is "zero". Every pile starts at the axis, as in both plots above. That layout has a known weakness. Only the bottom band has a shape you can read. Every band above it rests on the sum of the bands below, so its shape carries all of their movement as well as its own.

The weakness is easiest to see when each series peaks at a different time. Here are four music genres over half a year, in the listening table:

listening: first 5 of 104 rows
week genre plays
1 Folk 38.0
2 Folk 36.4
3 Folk 38.6
4 Folk 39.5
5 Folk 38.3
data(listening) + area * stack + x(week) + y(plays) + color(genre) +
  y_label("Plays") + title("Stacked on zero")
(data(listening) + area * stack + x(col.week) + y(col.plays) + color(col.genre) +
  y_label("Plays") + title("Stacked on zero"))
data(listening) + area * stack + x(:week) + y(:plays) + color(:genre) +
  y_label("Plays") + title("Stacked on zero")
plot(data(listening), layer(area, stack), x(col.week), y(col.plays),
  color(col.genre), y_label("Plays"), title("Stacked on zero"))
5 10 15 20 25 0 20 40 60 80 100 Stacked on zero Plays Week Genre Folk Jazz Techno Ambient

Techno is the red band, the third from the bottom. It looks like it grows to about week 17 and then collapses. Part of that shape is Techno, and part of it is Folk and Jazz shrinking underneath it. You cannot tell which part is which.

"center" places each pile so that its middle is at zero:

data(listening) + area * stack(baseline = "center") + x(week) + y(plays) +
  color(genre) + title("Centered: the ThemeRiver")
(data(listening) + area * stack(baseline = "center") + x(col.week) + y(col.plays) +
  color(col.genre) + title("Centered: the ThemeRiver"))
data(listening) + area * stack(baseline = "center") + x(:week) +
  y(:plays) + color(:genre) + title("Centered: the ThemeRiver")
plot(data(listening), layer(area, stack({ baseline: "center" })),
  x(col.week), y(col.plays), color(col.genre),
  title("Centered: the ThemeRiver"))
5 10 15 20 25 Centered: the ThemeRiver Week Genre Folk Jazz Techno Ambient

“Given the listening table: areas derived by stack on the center baseline, x is week, y is plays, color by genre.”

That symmetric layout is the ThemeRiver (Havre et al., 2000), named for the plot it was built to draw: how much each theme is written about, over time.

"wiggle" instead chooses the foot that leaves the bands as flat as possible. The thickest bands move least, because a reader looks at them first. The movement is pushed into the thin bands instead:

data(listening) + area * stack(baseline = "wiggle") + x(week) + y(plays) +
  color(genre) + title("Flattest: the streamgraph")
(data(listening) + area * stack(baseline = "wiggle") + x(col.week) + y(col.plays) +
  color(col.genre) + title("Flattest: the streamgraph"))
data(listening) + area * stack(baseline = "wiggle") + x(:week) +
  y(:plays) + color(:genre) + title("Flattest: the streamgraph")
plot(data(listening), layer(area, stack({ baseline: "wiggle" })),
  x(col.week), y(col.plays), color(col.genre),
  title("Flattest: the streamgraph"))
5 10 15 20 25 Flattest: the streamgraph Week Genre Folk Jazz Techno Ambient

“Given the listening table: areas derived by stack on the wiggle baseline, x is week, y is plays, color by genre.”

Now Techno is one clean shape, widening and then narrowing, which is what the numbers actually say. Folk, the blue band, is a steady decline rather than a wedge. No band changed thickness between these three plots. Only the starting place of each pile changed.

The last plot is a streamgraph (Byron & Wattenberg, 2008). The middle one is a ThemeRiver. The two are often confused and they are not the same layout: one is symmetric, the other is chosen to reduce movement. The streamgraph’s designers argue readers compare values more accurately on it, and that argument is the reason this option exists (Byron & Wattenberg, 2008).

Notice also that these two plots have no numbers on the measure axis. Once a pile has moved off zero, no value on that axis means anything. A band drawn from 12 to 20 says “this group is 8 here”, and the 12 and the 20 only record where the pile was placed. So gog draws no ticks and no axis label of its own, for the same reason it leaves an empty heatmap cell unpainted. A label you write with y_label() is still drawn, because it names the quantity and not a position. The scale is unchanged, so thicknesses stay comparable everywhere in the plot, and thickness is what a streamgraph asks you to read.

baseline is not an area-only parameter. A bar has a pile too, so it is placed the same way:

data(listening) + bar * stack(baseline = "center") + x(week) + y(plays) +
  color(genre) + title("The same baseline, drawn as bars")
(data(listening) + bar * stack(baseline = "center") + x(col.week) + y(col.plays) +
  color(col.genre) + title("The same baseline, drawn as bars"))
data(listening) + bar * stack(baseline = "center") + x(:week) +
  y(:plays) + color(:genre) + title("The same baseline, drawn as bars")
plot(data(listening), layer(bar, stack({ baseline: "center" })),
  x(col.week), y(col.plays), color(col.genre),
  title("The same baseline, drawn as bars"))
0 10 20 The same baseline, drawn as bars Week Genre Folk Jazz Techno Ambient

One parameter, both geometries. share follows the same rule, and so does plain stack, which hands both marks one span and lets each draw it: No Exceptions.

Once a pile is moved, zero on the measure axis no longer means anything. Only a flat plot can work without a meaningful zero, so the other spaces refuse:

data(listening) + area * stack(baseline = "wiggle") + x(week) + y(plays) +
  color(genre) + polar()
(data(listening) + area * stack(baseline = "wiggle") + x(col.week) + y(col.plays) +
  color(col.genre) + polar())
data(listening) + area * stack(baseline = "wiggle") + x(:week) +
  y(:plays) + color(:genre) + polar()
plot(data(listening), layer(area, stack({ baseline: "wiggle" })),
  x(col.week), y(col.plays), color(col.genre), polar())
Error:
! gog: `stack(baseline = "wiggle")` moves every pile off the origin of the measure axis, and `polar()` has no origin to spare. A displaced pile is a statement about a plane: in `polar()` the measure is an angle or a radius or a height the space has already fixed, so moving the foot asks for a place that is not on the plot. Keep the space and drop the baseline (`stack` alone, or with its `share` set to true for composition), or keep the baseline and draw it flat.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

In polar() the measure is an angle or a radius. A radius of zero is the center of the circle, so moving a pile off it asks for a negative radius, which is not a place on the plot.

25.21.4 A pile has one direction

stack has a third condition. The first two, a mark that can pile and a channel that splits the rows, are about the sentence. This one is about the numbers.

In a table of sales and returns, the returns arrive as negative numbers. The question is then whether a pile can grow downward at all.

A pile puts its groups end to end. Every member must therefore point the same way.

Negative values are allowed. A pile of them grows downward from zero, exactly as a positive pile grows up:

drawdown: first 5 of 6 rows
quarter cost kind
Q1 -5 refunds
Q2 -4 refunds
Q3 -6 refunds
Q1 -3 chargebacks
Q2 -2 chargebacks
data(drawdown) + bar * stack + x(quarter) + y(cost) + color(kind) +
  y_label("Cost") + title("A pile that grows down")
(data(drawdown) + bar * stack + x(col.quarter) + y(col.cost) + color(col.kind) +
  y_label("Cost") + title("A pile that grows down"))
data(drawdown) + bar * stack + x(:quarter) + y(:cost) + color(:kind) +
  y_label("Cost") + title("A pile that grows down")
plot(data(drawdown), layer(bar, stack), x(col.quarter), y(col.cost),
  color(col.kind), y_label("Cost"), title("A pile that grows down"))
Q1 Q2 Q3 -8 -6 -4 -2 0 A pile that grows down Cost Quarter Kind refunds chargebacks

A pile whose values point in two directions has no correct drawing. Suppose one group is 5 and another is -3 at the same position. The negative band would begin where the first band ended, at 5, and reach back down to 2. That region already belongs to the first group. A reader would see a solid block of length 3 where the number is negative. So gog refuses:

mixed_signs: first 5 of 6 rows
quarter amount kind
Q1 5 sales
Q2 5 sales
Q3 5 sales
Q1 2 returns
Q2 -3 returns
data(mixed_signs) + bar * stack + x(quarter) + y(amount) + color(kind)
data(mixed_signs) + bar * stack + x(col.quarter) + y(col.amount) + color(col.kind)
data(mixed_signs) + bar * stack + x(:quarter) + y(:amount) + color(:kind)
plot(data(mixed_signs), layer(bar, stack), x(col.quarter), y(col.amount),
  color(col.kind))
Error:
! gog: `bar * stack` piles the groups at one position on top of one another, and at quarter = Q2 they disagree in sign — `sales` is 5 and `returns` is -3. A pile has one direction: the band going the other way is drawn *inside* the ones below it, so the plot would show a block of 3 where the number is negative and read its sign backwards. Either keep the sign and drop the pile — `bar * dodge` sets the groups side by side instead, and a dodged bar keeps its own baseline, so a negative one hangs below the axis where a reader can see that it is negative — or keep the pile and drop the sign, stacking magnitudes you took the absolute value of in the host. (A pile whose members agree stays legal both ways: all-negative grows downward from zero exactly as all-positive grows up.)
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The message names the position, both groups, and both numbers. A message that said only “some values are negative” would make you search the whole table for a pile you cannot see on the page.

There are two ways to fix this, and you choose by what you want the plot to show. If the sign matters, use dodge. Each bar then keeps its own baseline, so a negative bar hangs below the axis where a reader can see that it is negative. If the total matters, take the absolute value in the host language first. You are then stacking magnitudes, so say so in the column name or the axis label.

stack(share = TRUE) fails for the same reason, and the failure is easier to see. A share is a fraction of the pile’s total. Here the total is 5 plus -3, which is 2, so the two shares are 2.5 and -1.5. No rescaling fixes that. The problem is in the pile itself, not in how the pile is drawn.

Piles at different positions may still point different ways. Each pile is read on its own. One quarter can be positive and the next negative in the same plot.

25.21.5 A pile holds one value for each group

stack has a fourth condition. Like the third, it is about the table rather than the sentence. At each position, a pile holds one value for each group. The gm_eras table has one row for each country in each era, so Africa has 52 rows for 1957 and 52 for 2007. stack piles the groups, not the rows inside one group. All the rows of one era would start at the same height and cover one another, so gog refuses:

data(gm_eras) + bar * stack + x(continent) + y(population) + color(era)
data(gm_eras) + bar * stack + x(col.continent) + y(col.population) + color(col.era)
data(gm_eras) + bar * stack + x(:continent) + y(:population) + color(:era)
plot(data(gm_eras), layer(bar, stack), x(col.continent),
  y(col.population), color(col.era))
Error:
! gog: `bar * stack` piles one value for each group at a position, and at `continent = Africa` 52 rows have `era = 1957` (10 of the 10 position and group pairs hold more than one row). Those rows would all start from the same foot and cover one another, while the groups above would be lifted by all of them, so the pile would show none of its rows. To draw one segment for each group, summarize the rows first: `bar * sum * stack` adds them, and `mean`, `median`, `max` and `min` work the same way. To draw one segment for each row, split by a column that names each row as well: `+ group(country)`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The message names the first pile with repeated rows. It says that all ten continent and era pairs have them, and it offers two fixes. Which fix you choose depends on what each segment should show. To draw one segment for each era, summarize the rows first. That is the first plot of stack, with sum before stack. To draw one segment for each row, add a second split, by a column whose value differs in every row of a pile. Here that column is country, in the gm_eras table, shown in dodge: side by side instead of on top:

data(gm_eras) + bar * stack + x(continent) + y(population) + color(era) +
  group(country) + y_label("Population") +
  title("Every country in each era, its own segment")
(data(gm_eras) + bar * stack + x(col.continent) + y(col.population) + color(col.era) +
  group(col.country) + y_label("Population") +
  title("Every country in each era, its own segment"))
data(gm_eras) + bar * stack + x(:continent) + y(:population) +
  color(:era) + group(:country) + y_label("Population") +
  title("Every country in each era, its own segment")
plot(data(gm_eras), layer(bar, stack), x(col.continent),
  y(col.population), color(col.era), group(col.country),
  y_label("Population"),
  title("Every country in each era, its own segment"))
Asia Europe Africa Americas Oceania 0 2B 4B Every country in each era, its own segment Population Continent Era 1957 2007

“Given the gapminder eras: bars derived by stack, x is continent, y is population, color by era, grouped by country.”

Every country in each era is now one thin segment, and each pile reaches the same total as with sum.

25.21.6 The dot plot

A histogram’s bars hide the rows inside them. When every country should stay visible, and countable, the mark to pile is the point.

A point has no length, so it cannot stretch across the span every other mark gets. gog fills the span with copies of the point instead: one dot per unit, piled up from the foot. The unit of a count is one row, so a pile of dots is a count you can count. The dots are the countries of the gapminder_2007 table, shown in the bin section:

data(gapminder_2007) + point * bin * stack + x(life) +
  x_label("Life expectancy (years)") + title("One dot per country")
(data(gapminder_2007) + point * bin * stack + x(col.life) +
  x_label("Life expectancy (years)") + title("One dot per country"))
data(gapminder_2007) + point * bin * stack + x(:life) +
  x_label("Life expectancy (years)") + title("One dot per country")
plot(data(gapminder_2007), layer(point, bin, stack), x(col.life),
  x_label("Life expectancy (years)"), title("One dot per country"))
50 60 70 80 0 10 20 30 One dot per country Count Life expectancy (years)

“Given gapminder 2007: points derived by bin and stack, x is life.”

That is a dot plot (Wilkinson, 1999), and it is the same sentence as the histogram at the top of this chapter with one atom changed and one added. Compare the two. bar * bin draws each tally as a length, and you read the shape from the outline. point * bin * stack draws each tally as that many dots, so every country in the table is on the page. Count the tallest pile and you get its bin’s count exactly, which no bar can offer.

Nothing here is a color split, and none is needed. bar and area pile groups, so they ask for one; a point piles rows, which every table already has. What it asks for instead is a transform that counts them, because a pile of 3.7 dots means nothing:

data(gapminder_2007) + point * mean * stack + x(continent) + y(life)
data(gapminder_2007) + point * mean * stack + x(col.continent) + y(col.life)
data(gapminder_2007) + point * mean * stack + x(:continent) + y(:life)
plot(data(gapminder_2007), layer(point, mean, stack), x(col.continent),
  y(col.life))
Error:
! gog: `point * stack` piles one dot per observation along the measure axis — the dot plot — so the measure has to be a count of rows, and nothing here counts them. Add the transform that does: `point * bin * stack` for a continuous axis (dots per interval), `point * count * stack` for a categorical one (dots per category).
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The refusal names the two transforms that count rows: bin for a continuous axis, and count for a categorical one. The second draws a bar chart of counts as piles of dots, one dot per row:

data(gapminder_2007) + point * count * stack + x(continent) +
  title("One dot per country, piled by continent")
(data(gapminder_2007) + point * count * stack + x(col.continent) +
  title("One dot per country, piled by continent"))
data(gapminder_2007) + point * count * stack + x(:continent) +
  title("One dot per country, piled by continent")
plot(data(gapminder_2007), layer(point, count, stack), x(col.continent),
  title("One dot per country, piled by continent"))
Asia Europe Africa Americas Oceania 0 20 40 One dot per country, piled by continent Count Continent

The dots pack tightly here and loosely on a small table. No setting controls the spacing. The vertical gap between stacked dots is always one count unit on the y axis, so its size on the page depends on the tallest pile. A taller pile therefore subdivides the panel more finely, and a pile of ten dots in a tall panel leaves wide gaps. style(size = ) will enlarge the dots, but making them touch means fixing the panel’s height to the tallest pile, and no setting does that. The y axis shows real counts either way, and that choice is deliberate: the alternative is to draw the dots touching and leave the axis with no meaning.

Each pile’s height is the count, drawn on y, so this dot plot names no column there. Name continent on both x and y, and gog refuses it. The message suggests color(continent) or a facet instead.

Writing bin is not enough when a summary follows it. bin hands its measure job to the summary, such as mean, so the pile would again be a number rather than a count of rows, and gog refuses it in the same way. A running count is refused too, because cumulative counts every earlier row again in each later bin, and a dot would then stand for one row many times:

data(gapminder_2007) + point * bin * mean * stack + x(life) + y(gdp)
data(gapminder_2007) + point * bin * mean * stack + x(col.life) + y(col.gdp)
data(gapminder_2007) + point * bin * mean * stack + x(:life) + y(:gdp)
plot(data(gapminder_2007), layer(point, bin, mean, stack), x(col.life),
  y(col.gdp))
Error:
! gog: `point * bin * mean * stack` piles one dot per observation, the dot plot, so the measure has to be a count of rows, and here `mean` measures each bin instead of counting its rows. A pile of that number is not a count of anything. Keep whichever you meant: `point * bin * stack` to pile the rows of each bin, or `point * bin * mean` for one point at each bin's value.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

color() is how a dot plot splits its rows by a category. On the dot plot of life expectancy, each dot takes the color of its row’s continent, and each pile keeps its height:

data(gapminder_2007) + point * bin * stack + x(life) + color(continent) +
  x_label("Life expectancy (years)") + title("One dot per country, colored by continent")
(data(gapminder_2007) + point * bin * stack + x(col.life) + color(col.continent) +
  x_label("Life expectancy (years)") + title("One dot per country, colored by continent"))
data(gapminder_2007) + point * bin * stack + x(:life) +
  color(:continent) + x_label("Life expectancy (years)") +
  title("One dot per country, colored by continent")
plot(data(gapminder_2007), layer(point, bin, stack), x(col.life),
  color(col.continent), x_label("Life expectancy (years)"),
  title("One dot per country, colored by continent"))
50 60 70 80 0 10 20 30 One dot per country, colored by continent Count Life expectancy (years) Continent Asia Europe Africa Americas Oceania

“Given gapminder 2007: points derived by bin and stack, x is life, color by continent.”

The four piles left of 60 years hold 39 dots, and 38 of them are African. The last pile holds 32 dots, and 18 of them are European.

The piles of the continent dot plot can also be horizontal, as bars can. Write y(continent) in place of x(continent). The count is then drawn along x, and each pile grows to the right:

data(gapminder_2007) + point * count * stack + y(continent) +
  title("One dot per country, piled to the right by continent")
(data(gapminder_2007) + point * count * stack + y(col.continent) +
  title("One dot per country, piled to the right by continent"))
data(gapminder_2007) + point * count * stack + y(:continent) +
  title("One dot per country, piled to the right by continent")
plot(data(gapminder_2007), layer(point, count, stack), y(col.continent),
  title("One dot per country, piled to the right by continent"))
0 20 40 Oceania Americas Africa Europe Asia One dot per country, piled to the right by continent Continent Count

25.21.7 How many rows is too many?

One dot per country works at 142 rows. A table twelve times that size is where the picture stops working.

Write the same sentence for a much larger table and the dots in each pile merge into one solid shape. The gm_all table, shown in The same bin, cutting two axes, holds every year of gapminder rather than one. That is 1704 rows, and the tallest pile is 314 of them:

data(gm_all) + point * bin * stack + x(life) +
  x_label("Life expectancy (years)") + title("Every year, every country: 1704 dots")
(data(gm_all) + point * bin * stack + x(col.life) +
  x_label("Life expectancy (years)") + title("Every year, every country: 1704 dots"))
data(gm_all) + point * bin * stack + x(:life) +
  x_label("Life expectancy (years)") +
  title("Every year, every country: 1704 dots")
plot(data(gm_all), layer(point, bin, stack), x(col.life),
  x_label("Life expectancy (years)"),
  title("Every year, every country: 1704 dots"))
gog: `point * bin * stack` piled 314 dots into one column, and at that height they overlap instead of counting — a dot plot says what it says by showing every observation separately, so this is a histogram drawn dot by dot. For this many rows, `bar * bin` reads the same shape as a summary, and `line * density` as a curve. `style(size = )` shrinks the dots, which buys a little room.
40 60 80 0 100 200 300 Every year, every country: 1704 dots Count Life expectancy (years)

gog draws it, and says what happened. The piles have become solid sticks. With 314 dots in a pile, the gap is a sixth of a dot’s width, so the dots overlap and nothing can be counted, which was this plot’s entire claim. What you are looking at is a histogram drawn dot by dot, and a costly one: it draws 1704 circles where bar * bin draws twelve rectangles.

The warning is not a row limit, and that matters. It compares the gap against one dot’s diameter, both of which gog knows once the panel is laid out. So the warning appears exactly when the picture degrades, and not when it does not. With wider bins, a smaller table gets the warning too. Shrink the dots with style(size = ) far enough and the warning stops. On this table, though, that means style(size = 0.75), a dot 1.5 pixels wide and too small to count, because size is the dot’s radius. That is why the next plot also cuts more bins. A number like “more than 500 rows” would be wrong at some panel size or dot size, and there is no need to guess one when the real condition is available.

Which also means the warning is advice, not a limit. Two atoms usually answer it without removing a single dot, and the first is the one people forget: more bins spread the same rows over more piles, so every pile gets shorter without anything getting smaller. Then the dots themselves:

data(gm_all) + point * bin(40) * stack + x(life) + style(size = 1.5) +
  x_label("Life expectancy (years)") + title("The same 1704 rows, cut finer and drawn smaller")
(data(gm_all) + point * bin(40) * stack + x(col.life) + style(size = 1.5) +
  x_label("Life expectancy (years)") + title("The same 1704 rows, cut finer and drawn smaller"))
data(gm_all) + point * bin(40) * stack + x(:life) + style(size = 1.5) +
  x_label("Life expectancy (years)") +
  title("The same 1704 rows, cut finer and drawn smaller")
plot(data(gm_all), layer(point, bin(40), stack), x(col.life),
  style({ size: 1.5 }), x_label("Life expectancy (years)"),
  title("The same 1704 rows, cut finer and drawn smaller"))
40 60 80 0 50 100 The same 1704 rows, cut finer and drawn smaller Count Life expectancy (years)

“Given all the gapminder years: points derived by bin into 40 and stack, x is life, with size 1.5.”

The columns are made of visible dots again, and every one of the 1704 is still on the page. Faceting is the third way, since fewer rows per panel means shorter piles in each.

What none of them changes is the arithmetic. Counting needs at least one dot-diameter of axis per row, so the tallest pile you can still count is about the panel’s height divided by a dot. That is a few hundred with dots you can see, and a few dozen if you mean to count them one by one. Past that, a summary is the better answer, and the reason is the same one that makes the dot plot useful for small tables. A bin width or a bandwidth is a guess about structure when there are thirty rows. It is a reliable description when there are ten thousand. So the dot plot stops working at the same size where a summary starts working well.

25.21.8 One bar, split into its parts

Not every question has a category for the x axis. Here the rows are the 264 wind observations of the winds table, shown in The same count, tallying two axes. Together they make one total, and the only question is how that total divides by direction.

Every stacked bar so far has been drawn at a position, one pile per category on the x axis. Take the position away and the split still has something to divide: a single bar, showing how one total divides.

data(winds) + bar * count * stack + color(direction)
data(winds) + bar * count * stack + color(col.direction)
data(winds) + bar * count * stack + color(:direction)
plot(data(winds), layer(bar, count, stack), color(col.direction))
0 100 200 Count Direction N NE E SE S SW W NW

“Given the winds: bars derived by count and stack, color by direction.”

There is no x() in that sentence, and none is needed. A bar normally requires a position because it has to be drawn somewhere. Here the color split is what divides the bar into parts, and there is one slot for those parts to share, so the position is no longer required. Bend this same plot into a circle and it is a pie, the subject of Polar.

Without a split there is nothing to divide, so the position is required again and the refusal says so. The statistic also has to be one that means something with nothing to group by. count and sum reduce a set of rows to one number. bin and density describe how values spread along an axis, and there is no axis here.

A single bar can also be horizontal when it adds up a column. With sum, the bar is drawn along the axis where you name that column. Here x(population) gives the bar’s length, not a position:

data(gapminder_2007) + bar * sum * stack + x(population) + color(continent) +
  title("The world's population, divided by continent")
(data(gapminder_2007) + bar * sum * stack + x(col.population) + color(col.continent) +
  title("The world's population, divided by continent"))
data(gapminder_2007) + bar * sum * stack + x(:population) +
  color(:continent) +
  title("The world's population, divided by continent")
plot(data(gapminder_2007), layer(bar, sum, stack), x(col.population),
  color(col.continent),
  title("The world's population, divided by continent"))
0 2B 4B 6B The world's population, divided by continent Population Continent Asia Europe Africa Americas Oceania

“Given gapminder 2007: bars derived by sum and stack, x is population, color by continent.”

The bar’s length is the world’s population in 2007, about 6.25 billion people. Asia’s part is about three fifths of it.

That ends the sections on stack. Two collision modifiers remain: jitter, which spreads points within a category, and repel, which moves labels apart.

25.22 jitter: spread sideways, along the category

dodge and stack resolve the overlap of grouped marks. The third collision modifier resolves a mark’s own points landing on top of each other, as dodge also does on a point. jitter does it another way: it moves each point sideways by a small amount that varies from point to point. A strip plot, every country as a dot above its continent, draws its points in a single vertical line, and heavy overlap hides how many there are:

data(gapminder_2007) + point + x(continent) + y(life) + style(opacity = 0.5) +
  y_label("Life expectancy") + title("Strip plot: the points pile onto one line")
(data(gapminder_2007) + point + x(col.continent) + y(col.life) + style(opacity = 0.5) +
  y_label("Life expectancy") + title("Strip plot: the points pile onto one line"))
data(gapminder_2007) + point + x(:continent) + y(:life) +
  style(opacity = 0.5) + y_label("Life expectancy") +
  title("Strip plot: the points pile onto one line")
plot(data(gapminder_2007), point, x(col.continent), y(col.life),
  style({ opacity: 0.5 }), y_label("Life expectancy"),
  title("Strip plot: the points pile onto one line"))
Asia Europe Africa Americas Oceania 40 50 60 70 80 Strip plot: the points pile onto one line Life expectancy Continent

jitter nudges each point a little to the side, so the crowded points spread out and the shape of each group shows:

data(gapminder_2007) + point * jitter + x(continent) + y(life) + style(opacity = 0.5) +
  y_label("Life expectancy") + title("point * jitter: the same points, spread to show density")
(data(gapminder_2007) + point * jitter + x(col.continent) + y(col.life) + style(opacity = 0.5) +
  y_label("Life expectancy") + title("point * jitter: the same points, spread to show density"))
data(gapminder_2007) + point * jitter + x(:continent) + y(:life) +
  style(opacity = 0.5) + y_label("Life expectancy") +
  title("point * jitter: the same points, spread to show density")
plot(data(gapminder_2007), layer(point, jitter), x(col.continent),
  y(col.life), style({ opacity: 0.5 }), y_label("Life expectancy"),
  title("point * jitter: the same points, spread to show density"))
Asia Europe Africa Americas Oceania 40 50 60 70 80 point * jitter: the same points, spread to show density Life expectancy Continent

“Given gapminder 2007: points derived by jitter, x is continent, y is life.”

Africa’s long tail of low values and Europe’s tight cluster near the top were both there before; the spread is what lets you read them.

jitter moves a point along the categorical axis, never along the measured one. Look again: every dot is at exactly the same height as before; the spread is entirely sideways. This is the rule, not a coincidence. The horizontal axis is a category, and a category has no magnitude, so nudging a point along it changes nothing you could misread. The vertical axis is a measurement, and moving a point off its measured value would misreport the data, so jitter never touches it. (ggplot2’s geom_jitter() spreads in both directions by default, by up to 0.4 of the smallest gap between values on each axis. On rounded data, such as whole numbers, that draws a point off its measured value, which is why gog spreads along one axis only.) On a horizontal strip, with the category on y, the spread is vertical instead. jitter always spreads the categorical axis and leaves the measured one exact.

The spread is deterministic: it is computed from the data itself, not from a random-number generator or the clock, so the same plot drawn twice is pixel-for-pixel the same picture. A specification always means one plot.

25.22.1 How much to spread: jitter(amount)

The default spread is one guess at what reads well, and on your figure it may be too wide or too narrow.

By default a point moves at most 0.4 of a slot from its category’s center: far enough to show how many points there are, and near enough to leave a gap between categories. For a wider or narrower spread, jitter(amount) multiplies the default by a plain number, so jitter(0.5) is half as wide (a bare jitter is jitter(1)).

data(gapminder_2007) + point * jitter(0.5) + x(continent) + y(life) +
  style(opacity = 0.5) + y_label("Life expectancy") +
  title("point * jitter(0.5): a tighter spread")
(data(gapminder_2007) + point * jitter(0.5) + x(col.continent) + y(col.life) +
  style(opacity = 0.5) + y_label("Life expectancy") +
  title("point * jitter(0.5): a tighter spread"))
data(gapminder_2007) + point * jitter(0.5) + x(:continent) + y(:life) +
  style(opacity = 0.5) + y_label("Life expectancy") +
  title("point * jitter(0.5): a tighter spread")
plot(data(gapminder_2007), layer(point, jitter(0.5)), x(col.continent),
  y(col.life), style({ opacity: 0.5 }), y_label("Life expectancy"),
  title("point * jitter(0.5): a tighter spread"))
Asia Europe Africa Americas Oceania 40 50 60 70 80 point * jitter(0.5): a tighter spread Life expectancy Continent

“Given gapminder 2007: points derived by jitter at 0.5, x is continent, y is life.”

The amount can be any number from 0 to 1.25. jitter(0) moves no point. It is harmless, so gog draws the plot and prints a note that the jitter did nothing. At the other end, jitter(1.25) moves a point up to half a slot, to the edge of its slot. gog refuses a larger amount, because it would move some points into a neighboring category, where they would be read as that category’s rows:

data(gapminder_2007) + point * jitter(2) + x(continent) + y(life)
data(gapminder_2007) + point * jitter(2) + x(col.continent) + y(col.life)
data(gapminder_2007) + point * jitter(2) + x(:continent) + y(:life)
plot(data(gapminder_2007), layer(point, jitter(2)), x(col.continent),
  y(col.life))
Error:
! gog: `jitter(2)` spreads points past the edge of their own category, so some would land in the neighboring one and be read as its rows. A point moves up to 0.4 of a slot for each unit of the amount, so `jitter(1.25)` reaches the slot's edge and is the widest spread that keeps every point in its own category. Use an amount of 1.25 or less; to show where many points overlap, `style(opacity = )` or a smaller `style(size = )` does it without moving any point.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The message names 1.25 as the widest amount, and style(opacity = ) as a way to show where points overlap without moving any of them.

The parameter has the same shape as density’s adjust, a dimensionless multiple of an automatic value. It is there for the same reason density and bin take a parameter while dodge does not. The jitter amount is a legibility choice with no single right value, whereas gog computes dodge’s offsets itself: from the group count on a bar, and from the other points in the slot on a point. A value you must choose takes a parameter; a value gog can compute does not.

Like dodge on a point, jitter needs no color split. It separates a mark’s own points where they land on one another, so point * jitter with only x and y is a whole sentence. It also needs a categorical position axis to spread within. Ask for jitter when both x and y are continuous and gog refuses. There is no slot to spread across, and no axis it could move a point along without misplacing its value. The answer for overplotting on two continuous axes is style(opacity = ), which shows where points are crowded without moving any of them.

Which marks jitter serves is decided by what a mark is: the rule that divides the whole trio. jitter spreads a scatter of individual points, so it is defined for point alone. A bar, box or interval has a width, and its groups are set apart by dodge; a line or area is one connected shape, not scattered points. Ask for jitter on any of those and gog refuses, naming the collision modifier that fits.

Three of the four collision modifiers divide the marks by geometry and by axis: dodge sets marks side by side within a slot, stack accumulates along the measure axis, jitter spreads along the categorical one. Each mark that dodge, stack or jitter serves has an answer to overlap that moves nothing off its value, and point has three. It is the one mark that can collide on either axis. Along a measure, it is piled into a countable stack of dots. Along a category, it is moved sideways within its slot, by jitter or by dodge. All three are modifiers the kernel already has, so these uses of point need no new atom.

25.23 repel: move the labels off each other

The fourth collision modifier answers a collision the first three cannot see. They all resolve marks that landed on one position, which is a fact about the data. A label is as wide as the word it draws, so two labels overlap at positions their points never shared, and no modifier computed from a position can find them. Thirty European countries, each named:

gm_europe: first 5 of 30 rows
country continent year life population gdp
Albania Europe 2007 76.423 3600523 5937.030
Austria Europe 2007 79.829 8199783 36126.493
Belgium Europe 2007 79.441 10392226 33692.605
Bosnia and Herzegovina Europe 2007 74.852 4552198 7446.299
Bulgaria Europe 2007 73.005 7322858 10680.793
data(gm_europe) + point + style(color = "#9e9e9e") +
  text + x(gdp) + y(life) + label(country) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("The names overlap")
(data(gm_europe) + point + style(color = "#9e9e9e") +
  text + x(col.gdp) + y(col.life) + label(col.country) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("The names overlap"))
data(gm_europe) + point + style(color = "#9e9e9e") + text + x(:gdp) +
  y(:life) + label(:country) + x_label("GDP per person") +
  y_label("Life expectancy") + title("The names overlap")
plot(data(gm_europe), point, style({ color: "#9e9e9e" }), text,
  x(col.gdp), y(col.life), label(col.country), x_label("GDP per person"),
  y_label("Life expectancy"), title("The names overlap"))
Albania Austria Belgium Bosnia and Herzegovina Bulgaria Croatia Czech Republic Denmark Finland France Germany Greece Hungary Iceland Ireland Italy Montenegro Netherlands Norway Poland Portugal Romania Serbia Slovak Republic Slovenia Spain Sweden Switzerland Turkey United Kingdom 10K 20K 30K 40K 50K 72 74 76 78 80 82 The names overlap Life expectancy GDP per person

text * repel measures the space each label will take, and moves the labels until no two words overlap:

data(gm_europe) + point + style(color = "#9e9e9e") +
  text * repel + x(gdp) + y(life) + label(country) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("text * repel: the same names, moved apart")
(data(gm_europe) + point + style(color = "#9e9e9e") +
  text * repel + x(col.gdp) + y(col.life) + label(col.country) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("text * repel: the same names, moved apart"))
data(gm_europe) + point + style(color = "#9e9e9e") + text * repel +
  x(:gdp) + y(:life) + label(:country) + x_label("GDP per person") +
  y_label("Life expectancy") +
  title("text * repel: the same names, moved apart")
plot(data(gm_europe), point, style({ color: "#9e9e9e" }),
  layer(text, repel), x(col.gdp), y(col.life), label(col.country),
  x_label("GDP per person"), y_label("Life expectancy"),
  title("text * repel: the same names, moved apart"))
Albania Austria Belgium Bosnia and Herzegovina Bulgaria Croatia Czech Republic Denmark Finland France Germany Greece Hungary Iceland Ireland Italy Montenegro Netherlands Norway Poland Portugal Romania Serbia Slovak Republic Slovenia Spain Sweden Switzerland Turkey United Kingdom 10K 20K 30K 40K 50K 72 74 76 78 80 82 text * repel: the same names, moved apart Life expectancy GDP per person

“Given gapminder Europe: points, and also text derived by repel, x is gdp, y is life, label by country.”

No two names overlap now. A label that ends up more than a line of text away from its point is joined to it by a thin line. Two rules set where each label rests. First, a label rests beside its dot, on whichever side has room. repel pulls the label toward its dot and pushes it away from every word and every dot, and the label stops where the two balance. A label with no neighbors is just above its dot. One with neighbors moves to whichever side is free. Second, when a label is pushed by another label and by its own dot at the same time, it moves off the other label and may rest over its dot. A word over a dot is still readable. Two words over each other are not.

Like jitter, the placement is deterministic: it depends only on the labels and the rows, never on a random-number generator, so one specification always draws the same picture. Like jitter, it needs no color split, since what it separates is the layer’s own labels.

repel takes no parameter, and jitter takes one, for the same reason. A jitter’s spread has no single right value, so you choose it. A label moves exactly as far as the overlap requires and no further. That distance is computed, the way dodge computes a width, so there is nothing to set. The gap left between two labels is set by the font size. The engine sets the distance of style(nudge = ) from the font size in the same way.

Above some number of labels, no placement fits at all. repel draws every one of them anyway and reports how many still overlap. A plot missing the names it could not fit would read as a plot with fewer rows, and nobody looking at it would know.

repel is text-only. Each of the other three serves the marks whose geometry fits it, and a label is the only glyph made of a word. Ask for it on a point and gog refuses, naming jitter; on a bar it names dodge. Its counterpart among the settings is style(nudge = ), a fixed shift in a direction you name, described in Text. The two compose: the nudge says which side a label prefers, and repel resolves the overlaps that remain.

The four collision modifiers are one family, and two questions divide it. Three of them ask what geometry does this mark have, and answer with a width, a measure or a slot. The fourth asks what is this mark made of, and only one mark is made of words.

25.24 cumulative: the running total

How much has been sold so far this year? In which week did one genre’s plays pass another’s? A running total answers both: each value is added to the values before it, so each position shows the total so far. stack adds up the groups that share one position, and cumulative adds up the positions of one group. sum adds the rows of a category into one number, while cumulative keeps every position. It follows the order of the axis: from the smallest value to the largest on a continuous axis, and in the categories’ own order on a category axis.

The actuals table holds the sales of one year in each row:

actuals: all 5 rows
year sales
2019 120
2020 135
2021 128
2022 152
2023 168
data(actuals) + line * cumulative + x(year) + y(sales) +
  title("Sales so far")
(data(actuals) + line * cumulative + x(col.year) + y(col.sales) +
  title("Sales so far"))
data(actuals) + line * cumulative + x(:year) + y(:sales) +
  title("Sales so far")
plot(data(actuals), layer(line, cumulative), x(col.year), y(col.sales),
  title("Sales so far"))
2019 2020 2021 2022 2023 200 400 600 Sales so far Cumulative Year

“Given the actuals: a line derived by cumulative, x is year, y is sales.”

The line starts at 120, the sales of 2019, and ends at 703, the sales of all five years together. y(sales) names the column that is added up. The axis is labeled “Cumulative”, because it shows the total, not the sales of one year.

cumulative measures nothing itself. It adds up what the layer measures: the column named on y, as here, or the measurement that a transform before it writes. The marks that draw one value at each position draw the total. A bar or an area grows from zero, a line, a step or a point is drawn at the total, and text prints it. The other marks refuse cumulative. With several rows at one position, write sum first, as in line * sum * cumulative, so that each position adds one number.

When color splits the rows, each group adds up its own total, as each group gets its own statistic. The listening table, shown in the section on stack(baseline = ), holds the weekly plays of four music genres:

data(listening) + line * cumulative + x(week) + y(plays) + color(genre) +
  title("Plays so far, by genre")
(data(listening) + line * cumulative + x(col.week) + y(col.plays) + color(col.genre) +
  title("Plays so far, by genre"))
data(listening) + line * cumulative + x(:week) + y(:plays) +
  color(:genre) + title("Plays so far, by genre")
plot(data(listening), layer(line, cumulative), x(col.week), y(col.plays),
  color(col.genre), title("Plays so far, by genre"))
0 10 20 0 200 400 600 Plays so far, by genre Cumulative Week Genre Folk Jazz Techno Ambient

“Given the listening table: lines derived by cumulative, x is week, y is plays, color by genre.”

Folk has the most plays for the first 19 weeks. Techno has few plays in the first weeks, and it passes Folk in week 20. After 26 weeks, Techno has 674 plays in total and Folk has 515. Each line adds up the weeks of its own genre.

25.24.1 A running count

After count or bin, the measurement is a number of rows, so the total is a running count. Here each bar is the number of countries in its continent and in every continent before it on the axis. A label prints each continent’s own count:

data(gapminder_2007) + bar * count * cumulative + x(continent) +
  text * count * cumulative + label(count) + style(nudge = "up") +
  title("Running count of countries, by continent")
(data(gapminder_2007) + bar * count * cumulative + x(col.continent) +
  text * count * cumulative + label(col.count) + style(nudge = "up") +
  title("Running count of countries, by continent"))
data(gapminder_2007) + bar * count * cumulative + x(:continent) +
  text * count * cumulative + label(:count) + style(nudge = "up") +
  title("Running count of countries, by continent")
plot(data(gapminder_2007), layer(bar, count, cumulative),
  x(col.continent), layer(text, count, cumulative), label(col.count),
  style({ nudge: "up" }),
  title("Running count of countries, by continent"))
33 30 52 25 2 Asia Europe Africa Americas Oceania 0 50 100 Running count of countries, by continent Cumulative Continent

“Given gapminder 2007: bars derived by count and cumulative, x is continent, and also text derived by count and cumulative, label by count, with nudge up.”

The last bar is 142, every country in the table. Africa’s bar is 52 countries taller than Europe’s, and 52 is the number on Africa’s label. The total is written to a column named cumulative, while count keeps each continent’s own count. So label(count) prints the count of each continent, and label(cumulative) would print the total so far.

On a continuous axis, bin cuts the axis into bins first. A step through the running counts is the cumulative frequency curve, also called an ogive:

data(gapminder_2007) + step * bin * cumulative + x(life) +
  title("Countries up to each life expectancy")
(data(gapminder_2007) + step * bin * cumulative + x(col.life) +
  title("Countries up to each life expectancy"))
data(gapminder_2007) + step * bin * cumulative + x(:life) +
  title("Countries up to each life expectancy")
plot(data(gapminder_2007), layer(step, bin, cumulative), x(col.life),
  title("Countries up to each life expectancy"))
50 60 70 80 0 50 100 Countries up to each life expectancy Cumulative Life

“Given gapminder 2007: a step outline derived by bin and cumulative, x is life.”

Each flat part is at the number of countries whose life expectancy is in that bin or a lower one. The curve rises most over its last three bins, from about 68 to 83 years. Those three bins hold 84 of the 142 countries.

25.24.2 Ending at 1: cumulative(share = TRUE)

cumulative(share = TRUE) divides each total by the last total of its group, so every group ends at 1. Each line then shows the share of a group’s rows up to each value: the group’s empirical CDF. On a continuous axis, count tallies the rows at each distinct value, so the line rises at every value that occurs. The plot uses the iris_flowers table, shown in the range section:

data(iris_flowers) + step * count * cumulative(share = TRUE) + x(petal_length) +
  color(species) + x_label("Petal length (cm)") +
  title("Share of each species, by petal length")
(data(iris_flowers) + step * count * cumulative(share = True) + x(col.petal_length) +
  color(col.species) + x_label("Petal length (cm)") +
  title("Share of each species, by petal length"))
data(iris_flowers) + step * count * cumulative(share = true) +
  x(:petal_length) + color(:species) + x_label("Petal length (cm)") +
  title("Share of each species, by petal length")
plot(data(iris_flowers), layer(step, count, cumulative({ share: true })),
  x(col.petal_length), color(col.species), x_label("Petal length (cm)"),
  title("Share of each species, by petal length"))
2 4 6 0.0 0.2 0.4 0.6 0.8 1.0 Share of each species, by petal length Cumulative Petal length (cm) Species setosa versicolor virginica

“Given the iris flowers: step outlines derived by count and cumulative as shares, x is petal length, color by species.”

Each line reaches 1 at the longest petal of its species: 1.9 cm for setosa, 5.1 cm for versicolor and 6.9 cm for virginica. After that, the line stays at 1, and before the shortest petal of its species it is at 0. A running total has a value at every position on the axis, for every group. Where a group has no rows, its total is the total so far, or 0 before its first row. That is also why stacked running totals stay correct: each group starts at the totals below it, even where a group below has no rows. Each passes one half at the 25th of its 50 petals: 1.5 cm, 4.3 cm and 5.5 cm. Setosa’s line has 9 flat parts, one for each petal length that occurs in that species. With bin in place of count, the line has one flat part for each bin instead.

proportion cannot draw this plot. It divides by the total of the whole plot, so each species’ line would end at one third, its share of all 150 flowers. gog refuses proportion beside cumulative, and the message points to share.

25.24.3 The order of a category axis

On a category axis, the total is added up in the order of the categories. That is the order your table declares, or else the order in which the categories first appear in its rows. gog adds up the totals in that order, and places the categories on the axis after that. With order(count, desc = TRUE), Africa would move to the first slot and keep its total of 115, so the bars would no longer rise from left to right. That sentence is refused:

data(gapminder_2007) + bar * count * cumulative + x(continent) +
  order(count, desc = TRUE)
(data(gapminder_2007) + bar * count * cumulative + x(col.continent) +
  order(col.count, desc = True))
data(gapminder_2007) + bar * count * cumulative + x(:continent) +
  order(:count, desc = true)
plot(data(gapminder_2007), layer(bar, count, cumulative),
  x(col.continent), order(col.count, { desc: true }))
Error:
! gog: `order(count)` would sort the categories of `continent` after `bar * count * cumulative` has added them up in their own order, so each total would stand at a category it was not added at. gog does not add up in a sorted order yet. Declare the order in the table instead (a factor in R, `ordered()` in Python, Julia and JavaScript), and the totals are added up in that order.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

To choose the order, declare it in the table: as a factor in R, or with ordered() in Python, Julia and JavaScript. The totals are then added up in that order. A facet or play splits the rows before any transform runs, so each panel or frame adds up its own rows.

cumulative does not draw a waterfall chart. Each block of a waterfall is one change, and it starts at the total before it, so its length is the change and not the total. Zone draws one from columns that your table computes.

25.25 Columns a transform writes

Many sentences in this book name a column that their table does not have. A histogram binds y(count), a flow diagram binds label(name), and a network binds size(degree). None of those tables has a count, a name or a degree column. In each case, the transform writes that column. A transform runs before anything is drawn, and it can write columns that your table never held. A sentence names some of those columns the way it names any other column. The transform sets each name, and a sentence must use it exactly.

25.25.1 The name on a measure axis

When they read one column, bin, count, proportion and density write their measurement on y, and they name it themselves. bin and count name it count, proportion names it proportion, and density names it density. When proportion follows bin or count, as in bar * bin * proportion, the bars are shares, so the name is proportion. When cumulative follows them, as in step * bin * cumulative, the axis holds running totals, so the name is cumulative. You can write that name in y(), or leave y() out. Either way, the axis is labeled with that name, capitalized: “Count”, “Proportion”, “Density” or “Cumulative”. Write the name, such as y(count), when the axis needs a setting such as limits.

Any other name in y() is refused. In other sentences, a name in y() chooses the column to draw. Here the transform has already chosen it, so a new name would change only the label on the axis. gog refuses it and points to y_label(), which sets the axis label:

data(gapminder_2007) + bar * count + x(continent) + y(countries)
data(gapminder_2007) + bar * count + x(col.continent) + y(col.countries)
data(gapminder_2007) + bar * count + x(:continent) + y(:countries)
plot(data(gapminder_2007), layer(bar, count), x(col.continent),
  y(col.countries))
Error:
! gog: `bar * count` draws counts on `y`, in a column it names `count`, so `y()` takes that name or none. `countries` is not a column of the table, so `y(countries)` would change only the axis label. Write `y(count)`, which still takes the axis's settings such as `limits`, or leave `y()` out. To choose the label, write `y_label("Countries")`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

y_label() changes the label and nothing else:

data(gapminder_2007) + bar * count + x(continent) + y_label("Countries") +
  title("The label comes from y_label()")
(data(gapminder_2007) + bar * count + x(col.continent) + y_label("Countries") +
  title("The label comes from y_label()"))
data(gapminder_2007) + bar * count + x(:continent) +
  y_label("Countries") + title("The label comes from y_label()")
plot(data(gapminder_2007), layer(bar, count), x(col.continent),
  y_label("Countries"), title("The label comes from y_label()"))
Asia Europe Africa Americas Oceania 0 20 40 The label comes from y_label() Countries Continent

The bars are the same counts. Only the axis label changes, to Countries.

Because a new name is refused, a misspelled one is refused too. y(cuont) would otherwise draw an axis labeled “Cuont”, and gog says it looks like count. Two other kinds of name are refused for the same reason. The first is a column of your table: bar * bin + x(life) + y(gdp) would label counts as gdp. The second is the name of another transform. On a histogram, y(density) would label counts as a density, so gog refuses it and names the transform that computes one:

data(gapminder_2007) + bar * bin + x(life) + y(density)
data(gapminder_2007) + bar * bin + x(col.life) + y(col.density)
data(gapminder_2007) + bar * bin + x(:life) + y(:density)
plot(data(gapminder_2007), layer(bar, bin), x(col.life), y(col.density))
Error:
! gog: `bar * bin` draws counts on `y`, in a column it names `count`, so `y()` takes that name or none. `density` is the name of a transform, so `y(density)` would label the axis `density` over counts. For a density curve, `line * density + x(life)` estimates one. Write `y(count)`, which still takes the axis's settings such as `limits`, or leave `y()` out. To choose a different label, write `y_label()`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The message offers line * density for a density curve. To keep the bars and change their heights, write bar * bin * proportion: each bar is then its bin’s share of all the rows. A share is not a density, but a density histogram on the same bins has the same shape. To show a density curve and a histogram together, put them in two plots, one above the other, as One variable shows. In one panel the pair is refused.

A stack that fills each pile to one, stack(share = TRUE), keeps the transform’s name, such as count for bin, because it rescales the values in their own column. Its axis is labeled “Share” even when you write y(count). Horizontal bars follow the same rule, with x() and x_label().

25.25.2 Other columns a transform writes

When bin, count, proportion and density read two columns, one on each position, they write their measurement to color rather than to an axis. partition, flow, layout and cluster also write columns with names of their own, and a sentence must use each name exactly. For each column, the table lists what it holds, how a sentence names it, and where it is drawn:

Column Written by What it holds Named with Drawn in
count zone * bin, zone * count the number of rows in each cell color(count) Zone
proportion zone * proportion each cell’s share of all the rows color(proportion) Zone
density zone * density, surface * density the estimate in each cell or face color(density) Zone, Surface
level path * density the value each contour ring was traced at color(level) Path
name partition, flow, layout the name of each node or slot label(name), which a text layer derived by these transforms may leave out, and under flow also brush(name) Polar, Flow, Network
depth partition the ring a node is on, counting the innermost as 1 y(depth), color(depth) on a zone, label(depth) Polar, Naming the nodes
degree layout the number of rows that name the node size(degree), color(degree), label(degree) Network
distance path * cluster the distance at which each pair of branches joins y(distance), or x(distance) for a tree drawn sideways; both may be left out Cluster

These names are checked too. Write another name, and gog refuses the sentence and says which name to use:

data(titanic) + y(n) + text * flow(class, survived) + label(slot)
data(titanic) + y(col.n) + text * flow(col["class"], col.survived) + label(col.slot)
data(titanic) + y(:n) + text * flow(:class, :survived) + label(:slot)
plot(data(titanic), y(col.n), layer(text, flow(col.class, col.survived)),
  label(col.slot))
Error:
! gog: under `flow` a `text` names the slots, and the flow publishes each slot's name under `name` — write `label(name)`, not `label(slot)`.
gog: `label(slot)` refers to a column that is not in the data. Check the spelling of `slot`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

A column that a transform writes can also be named on other channels. Each channel checks it by the same rules it uses for a column of your table. The count that point * count puts on y can also size each point, so the larger points are the continents with more countries:

data(gapminder_2007) + point * count + x(continent) + size(count)
data(gapminder_2007) + point * count + x(col.continent) + size(col.count)
data(gapminder_2007) + point * count + x(:continent) + size(:count)
plot(data(gapminder_2007), layer(point, count), x(col.continent),
  size(col.count))
Asia Europe Africa Americas Oceania 0 10 20 30 40 50 Count Continent Count 2 27 52

“Given gapminder 2007: points derived by count, x is continent, size by count.”

A cluster tree takes color(distance) the same way (Cluster). On label, a text layer prints the count on each bar, as Text shows. A zone’s color takes a continuous column, so color(count) draws a heatmap, as the table of columns at the start of this section shows. A bar’s color needs a categorical column, and count is continuous, so this sentence is refused:

data(gapminder_2007) + bar * count + x(continent) + color(count)
data(gapminder_2007) + bar * count + x(col.continent) + color(col.count)
data(gapminder_2007) + bar * count + x(:continent) + color(:count)
plot(data(gapminder_2007), layer(bar, count), x(col.continent),
  color(col.count))
Error:
! gog: `color(count)` names the `count` that `bar * count` writes, a continuous (numeric) column, but `color` on `bar` needs a categorical (text) column. Use `opacity` to show a numeric column.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

To color the bars, name a categorical column, such as color(continent). To show each bar’s count by how faint it is, write opacity(count), the channel the message names. opacity takes a continuous column, so the count is accepted. Oceania, with two countries, has the faintest bar:

data(gapminder_2007) + bar * count + x(continent) + opacity(count)
data(gapminder_2007) + bar * count + x(col.continent) + opacity(col.count)
data(gapminder_2007) + bar * count + x(:continent) + opacity(:count)
plot(data(gapminder_2007), layer(bar, count), x(col.continent),
  opacity(col.count))
Asia Europe Africa Americas Oceania 0 20 40 Count Continent Count 2 27 52

“Given gapminder 2007: bars derived by count, x is continent, opacity by count.”

Here the opacity repeats what the height already shows. The example shows only that opacity accepts a column that a transform writes, just as it accepts a column of your table.

Some sentences that name a column a transform writes are valid grammar that gog does not draw yet. One is a sunburst with color(name), which would give each node a color of its own. gog refuses it and draws nothing, and the message asks you to leave that color() out. confidence, deviation, smooth_band and straight_band write one more column, center. An interval draws it for you, as a dot. gog does not yet draw center on any channel, so a sentence that names it on one is refused.

25.25.3 Your table’s columns after a transform

You may want a plot of counts to show one more column of your table, such as gdp on size. A statistic such as count or mean replaces your rows with new ones, so that column may no longer be there. After bar * count there is one row for each bar, and every channel of that layer reads those rows.

Your table may already have a column with the same name as one a transform writes. A table of counts often has a count column, and a table of people often has a name column. The name in your sentence still means the column the transform writes, because the new rows do not hold your count column. So bar * count + y(count) draws the transform’s counts, and gog prints a message that says which count it drew. If your count column already holds counts, add them up instead with bar * sum + y(count). The message gives that sentence too.

Such a sentence is refused in one case: when another channel reads your table’s column of that name. In bar * count + x(count) + y(count), x(count) reads your column and y(count) names the transform’s, so one name would mean two columns. Rename your table’s column, or leave out y(count), since bar * count writes y itself.

The new rows also lack most other columns of your table, such as gdp. After point * count + x(continent) there is one row for each continent, and those rows have no gdp. So a sentence that sizes the points by gdp is refused:

data(gapminder_2007) + point * count + x(continent) + size(gdp)
data(gapminder_2007) + point * count + x(col.continent) + size(col.gdp)
data(gapminder_2007) + point * count + x(:continent) + size(:gdp)
plot(data(gapminder_2007), layer(point, count), x(col.continent),
  size(col.gdp))
Error:
! gog: `size(gdp)` names a column of your table, and `point * count` replaces your rows with its own, one for each `continent`, which have no `gdp`. To show `gdp`, make a table with one row for each `continent`, holding its count and a summary of `gdp`. Draw that table with `point`, without `count`, and put the count on `y`. Otherwise, leave `size(gdp)` out.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

To size each point by its continent’s mean gdp, make a new table in the host language, as the message describes. Give it one row for each continent, with its number of countries and its mean gdp. Data makes the same counts with a database query. This book has made the table for you as continents_2007:

continents_2007: all 5 rows
continent countries mean_gdp
Asia 33 12473
Europe 30 25054
Africa 52 3089
Americas 25 11003
Oceania 2 29810

Draw it with point and without count, because the table already holds the counts, and put its countries column on y:

data(continents_2007) + point + x(continent) + y(countries) + size(mean_gdp)
data(continents_2007) + point + x(col.continent) + y(col.countries) + size(col.mean_gdp)
data(continents_2007) + point + x(:continent) + y(:countries) +
  size(:mean_gdp)
plot(data(continents_2007), point, x(col.continent), y(col.countries),
  size(col.mean_gdp))
Asia Europe Africa Americas Oceania 0 10 20 30 40 50 Countries Continent Mean Gdp 3089 16.4K 29.8K

“Given the continents in 2007: points, x is continent, y is countries, size by mean gdp.”

Here the height shows the count and the size shows the mean gdp. Africa has the most countries and the smallest point, and Oceania has two countries and the largest.

Some statistics get different advice: the ones that fit a curve, such as smooth, and the ones that compute a low and a high value, such as range. For line * smooth + color(population), the message says to draw your rows in a layer of their own. The points below are your rows, so size(population) reads them. line * smooth has no size, and it fits its curve through the same points:

data(gapminder_2007) + x(gdp) + y(life) + point + size(population) +
  line * smooth
(data(gapminder_2007) + x(col.gdp) + y(col.life) + point + size(col.population) +
  line * smooth)
data(gapminder_2007) + x(:gdp) + y(:life) + point + size(:population) +
  line * smooth
plot(data(gapminder_2007), x(col.gdp), y(col.life), point,
  size(col.population), layer(line, smooth))
0 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Population 199.6K 659.4M 1.3B

“Given gapminder 2007: x is gdp, y is life, points sized by population, and also a line derived by smooth.”

Not every column of your table is lost. Besides the positions a statistic groups by, two kinds of column are still on the new rows. One is a categorical column on a channel that splits the rows, such as color. The statistic runs within each group, so each new row keeps its group and is drawn in that group’s color. The other is the column a statistic reads. Under point * mean + x(continent) + y(life), size(life) sizes each point by its continent’s mean life.

order() cannot read gdp after bar * count either. There, order() ranks each continent by how many rows it has and reads no column of your table. So order(gdp) would name gdp but sort the bars by their counts, and gog refuses it:

data(gapminder_2007) + bar * count + x(continent) + order(gdp, desc = TRUE)
data(gapminder_2007) + bar * count + x(col.continent) + order(col.gdp, desc = True)
data(gapminder_2007) + bar * count + x(:continent) +
  order(:gdp, desc = true)
plot(data(gapminder_2007), layer(bar, count), x(col.continent),
  order(col.gdp, { desc: true }))
Error:
! gog: `order(gdp)` would never read `gdp`. `bar * count` measures each category of `continent` by how many rows it has and reads no column, so the categories would be sorted by that number. To sort them that way, write `order(count)`. To sort by `gdp`, draw it: `bar * mean + y(gdp) + order(gdp)` ranks each category by its mean `gdp`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The message gives two sentences that draw. order(count) sorts the bars by their counts. bar * mean + y(gdp) + order(gdp) draws each continent’s mean gdp and sorts by it.

A column that a transform writes has the opposite limit. Four atoms read your table’s rows before any transform runs. group and play split them, a facet splits them into panels, and brush selects them. None of the four can name a column that a transform writes, because the column does not exist yet. color is not one of the four. It splits the rows only by a category of your table, and given the count, as in point * count + color(count), it reads the new rows. A sentence that names a written column in one of the four is refused:

data(gapminder_2007) + point * count + x(continent) + play(count)
data(gapminder_2007) + point * count + x(col.continent) + play(col.count)
data(gapminder_2007) + point * count + x(:continent) + play(:count)
plot(data(gapminder_2007), layer(point, count), x(col.continent),
  play(col.count))
Error:
! gog: `play(count)` names the `count` that `point * count` writes, but `play` cuts the table's rows into frames before any transform runs, so there is no `count` yet to cut by. Name a column of the table: `play(<column>)`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

play() needs a column of your table instead. Play counts the countries in each continent and plays them by year, a column of its table.

25.25.4 What each statistic reads and writes

The eighteen transforms in the table at the start of this chapter each write their answer into a named column. This table lists the column each one reads and the column it writes.

Transform Reads from Writes to
bin x(column) x(column) (bin centers), y(count) (counts)
count x(column) x(column) (unique values), y(count) (counts)
proportion the measurement before it, or x(column) if there is none y(proportion) (shares, summing to 1); after a statistic that reads y(column), such as sum, also y(proportion), while that y(column) keeps the statistic’s values
cumulative the measurement before it, or y(column) if there is none y(cumulative) (the running totals), while the measurement keeps each position’s own value
smooth x(column), y(column) same x, same y (smoothed values)
straight x(column), y(column) same x (100 positions along the line), same y (fitted values)
density x(column) x(column) (the points where the curve is estimated), y(density) (density)
sum / mean / median / max / min / quantile x(column), y(column) x(column) (unique), y(column) (aggregated)
range x(column), y(column) x(column) (each group twice), y(column) (low, then high)
confidence x(column), y(column) x(column) (each group twice), y(column) (low, then high), center (the mean)
deviation x(column), y(column) x(column) (each group twice), y(column) (low, then high), center (the mean)
smooth_band x(column), y(column) x(column) (100 positions along the curve, each twice), y(column) (low, then high), center (the fitted value)
straight_band x(column), y(column) x(column) (100 positions along the line, each twice), y(column) (low, then high), center (the fitted value)

One row of that table changes when bin is composed with another statistic (see Chaining transforms). Cutting is then all it does. It writes the bin centers to x(column) as before and does not change y(column), so the statistic can reduce it. The y column then holds your column’s summary rather than a count.

25.26 In short

The counting transforms and proportion name the measurement they write, so y() uses that name or is left out. The reading transforms refuse without the column they read. A statistic runs inside each group, so binding color changes what it computes. Two transforms compose when their jobs differ and contradict when they do not. Four more transforms compute a whole picture, and four move overlapping marks. Some transforms write columns a sentence can name, and a statistic replaces your rows.

25.27 What comes next

A transform changes the values of one mark and no other. Operators says how whole sentences join: how two marks share a panel, and how one plot is split or set beside another.