| 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 |
23 Channels and legends
Which visual property should carry which column? A channel binds a data column to a visual property of a mark, and the channels do not all do the same work. x, y and z are the vowels: a mark does not render without a position, which you write or a transform computes. Five other channels get a legend: color, size, shape, pattern and opacity. Those five are the final consonants, the optional ㅁ (m) that turns 가 (ga) into 감 (gam): never required, and each one changes what the plot says. Every channel atom has the same form:
channel(field)
No exceptions. x and y are channels too: the same rule applies everywhere.
Different fields use different words for this, so you may know it by another name. Bertin wrote the vocabulary down first, in Sémiologie graphique (Bertin, 1967). He separated the two dimensions of the page from six retinal variables: size, value (his word for lightness), texture, color, orientation, and shape. The eye does not read the two groups the same way. Wilkinson and ggplot2 both say aesthetic, which is why ggplot2 code has aes() and gog code has nothing like it. The visualization literature chose channel instead, paired with the mark it modifies (Munzner, 2014), and that is the word gog uses. In everyday English, a channel carries a signal. Here the column is the signal, and color or size is the channel that carries it to the reader. Choosing an everyday word follows gog’s Plain Names law. Encoding is the same idea named as an act rather than a thing, and this book keeps both words. A channel is what you write; an encoding is what it does. Encoding scope is about the second.
| Atom | Visual property | Variable type |
|---|---|---|
x(field) |
horizontal position | any |
y(field) |
vertical position | any |
z(field) |
depth: the third position, which makes a plot 3-D | continuous |
color(field) |
fill / stroke color | continuous or categorical |
size(field) |
point radius, or a label’s font size | continuous |
shape(field) |
glyph shape | categorical |
pattern(field) |
fill hatch / stroke dash | categorical |
opacity(field) |
transparency | continuous |
group(field) |
splits a mark into one series per category, without encoding it | categorical |
label(field) |
the string a text mark draws |
any |
play(field) |
cuts the plot into frames and plays them in sequence | any |
A legend is generated automatically whenever one of the five refining channels (color, size, shape, pattern, opacity) is bound to a column, with no configuration required. x, y and z get an axis instead, and group gets neither: it splits a mark without encoding anything, so there is nothing for a legend to decode. label belongs to the text mark, and play has a chapter of its own.
23.1 color
Which points belong together? A plain scatter cannot say, so the grouping has to come from a column.
color(field) maps a column to color, and a legend appears automatically. On point, line, step, path, zone and surface it takes either kind of column, and the kind decides what color means. On the other marks it takes only a categorical column.
It is spelled color, and only color. gog writes American English throughout and accepts no second spelling, so colour, which ggplot2 accepts, does not exist here. Writing it is refused, and the refusal names the fix (see Law 3).
A categorical column answers “which one?” Each category gets its own color from the palette:
data(gapminder_2007) + point + x(gdp) + y(life) +
color(continent)(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
color(col.continent))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent)plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.continent))“Given gapminder 2007: points, x is gdp, y is life, color by continent.”
A continuous column answers “how much?” Its values run along a ramp. A ramp is a range of colors that a number is placed along, the way an axis is a range of positions. This one runs light to dark, and the legend is the ramp itself, labeled at its minimum, midpoint, and maximum:
data(gapminder_2007) + point + x(gdp) + y(life) +
color(life) + style(size = 6)(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
color(col.life) + style(size = 6))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:life) +
style(size = 6)plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.life), style({ size: 6 }))Notice the legend: color gets one continuous strip. The size and opacity legends later in this chapter show three sampled examples instead. A legend should show the scale. A range of colors fits in one strip, but there is no way to draw a continuum of circles. The rule is the same for every channel; only the form of the legend changes. The difference matters most for a multi-hue ramp. Three sampled colors from viridis, one of the ramps below, say nothing about the colors between them. Three circles of growing size do, because a reader can imagine the sizes in between and cannot imagine the colors.
The default ramp is one hue, not a rainbow, and that is deliberate. A sequential ramp, one that runs from low values to high, encodes magnitude. A reader reads a darker color as more, so lightness is what such a ramp varies. One hue running light to dark is read quickly, stays readable in grayscale and under color blindness, and matches the categorical palette when both appear in one figure. A rainbow encodes magnitude as hue, which has no intrinsic order, so the reader has to consult the legend for every point. gog’s default is a single blue built on the same hue as the first categorical color, so the two scales read as one system. Ramps has the multi-hue alternatives, and the sections after it have the diverging ramps and the ramps you build from colors you choose.
A categorical color also implies grouping for line marks. Each unique value gets its own connected line:
| country | continent | year | life | population | gdp |
|---|---|---|---|---|---|
| China | Asia | 1952 | 44.00000 | 556263527 | 400.4486 |
| China | Asia | 1957 | 50.54896 | 637408000 | 575.9870 |
| China | Asia | 1962 | 44.50136 | 665770000 | 487.6740 |
| China | Asia | 1967 | 58.38112 | 754550000 | 612.7057 |
| China | Asia | 1972 | 63.11888 | 862030000 | 676.9001 |
data(gapminder_asia) + x(year) + y(life) + color(country) +
line + point(data(gapminder_asia) + x(col.year) + y(col.life) + color(col.country) +
line + point)data(gapminder_asia) + x(:year) + y(:life) + color(:country) + line +
pointplot(data(gapminder_asia), x(col.year), y(col.life), color(col.country),
line, point)“Given gapminder Asia: x is year, y is life, color by country, lines and also points.”
Where you write a channel decides which layers it reaches. color(country) is written before any mark in the plot above, so it belongs to the plot and colors both the lines and the points. Written after a mark it binds to that mark alone:
data(gapminder_asia) + x(year) + y(life) +
line + color(country) +
point + size(population)(data(gapminder_asia) + x(col.year) + y(col.life) +
line + color(col.country) +
point + size(col.population))data(gapminder_asia) + x(:year) + y(:life) + line + color(:country) +
point + size(:population)plot(data(gapminder_asia), x(col.year), y(col.life), line,
color(col.country), point, size(col.population))“Given gapminder Asia: x is year, y is life, lines colored by country, and also points sized by population.”
After the first mark, a channel belongs to the mark just before it, never to an earlier one. See Encoding scope.
23.2 size
The color section used size without saying what it does. Here is the problem it solves. In the 2007 scatter, x carries GDP per person and y carries life expectancy. Population is a third number with no position left for it.
size(field) scales point radius linearly from 3px (min) to 12px (max). A size legend shows the minimum, the midpoint and the maximum of the column.
data(gapminder_2007) + point + x(gdp) + y(life) +
size(population)(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
size(col.population))data(gapminder_2007) + point + x(:gdp) + y(:life) + size(:population)plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
size(col.population))Combine color and size and the result is a bubble chart, four columns on one set of points:
data(gapminder_2007) + point + x(gdp) + y(life) +
color(continent) + size(population)(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
color(col.continent) + size(col.population))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent) +
size(:population)plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.continent), size(col.population))23.3 shape
A journal prints your figure in black and white, and the colors you chose print as grays that look almost the same. The grouping has to come from the glyph instead.
shape(field) maps a categorical column to one of seven glyphs: circle, square, triangle, diamond, cross, star, wye (a Y shape). Seven is the whole set. It is also about as many shapes as a reader can recognize quickly. Map shape to a column with more than seven categories and the eighth category starts again at the circle. Two categories then share a glyph, and gog prints a message to say so. The Shape section of the point chapter shows what to do when a column has more than seven categories.
data(gapminder_2007) + point + x(gdp) + y(life) +
shape(continent)(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
shape(col.continent))data(gapminder_2007) + point + x(:gdp) + y(:life) + shape(:continent)plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
shape(col.continent))“Given gapminder 2007: points, x is gdp, y is life, shape by continent.”
color and shape can be bound to the same column. Binding one column to two channels is redundant encoding, and it makes the plot easier to read:
data(gapminder_2007) + point + x(gdp) + y(life) +
color(continent) + shape(continent)(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
color(col.continent) + shape(col.continent))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent) +
shape(:continent)plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.continent), shape(col.continent))Either channel alone would separate the continents. Using both separates them in two ways, so a reader who cannot see the colors still has the shapes. gog draws one legend for the two, with each glyph in its continent’s color.
23.4 pattern
shape answers the black-and-white problem for points. A bar or a line has no glyph to change, so it needs a different answer, and that answer is texture.
pattern(field) maps a categorical column to a texture, the way shape maps one to a glyph. The mark’s geometry decides which one: a hatch, a repeated pattern of lines or dots, on the filled regions (bar, box, area, ribbon, zone), and a dash on the strokes (line, step, interval, path, rule, edge). Textures do not depend on color, so they work in grayscale and for a colorblind reader. The first category is always the plain one (solid), the rest textured:
| sepal_length | sepal_width | petal_length | species |
|---|---|---|---|
| 5.1 | 3.5 | 1.4 | setosa |
| 4.9 | 3.0 | 1.4 | setosa |
| 4.7 | 3.2 | 1.3 | setosa |
| 4.6 | 3.1 | 1.5 | setosa |
| 5.0 | 3.6 | 1.4 | setosa |
data(iris_flowers) + bar * mean + x(species) + y(petal_length) +
pattern(species) + y_label("Mean petal length (cm)")(data(iris_flowers) + bar * mean + x(col.species) + y(col.petal_length) +
pattern(col.species) + y_label("Mean petal length (cm)"))data(iris_flowers) + bar * mean + x(:species) + y(:petal_length) +
pattern(:species) + y_label("Mean petal length (cm)")plot(data(iris_flowers), layer(bar, mean), x(col.species),
y(col.petal_length), pattern(col.species),
y_label("Mean petal length (cm)"))“Given the iris flowers: bars derived by mean, x is species, y is petal length, pattern by species.”
A second use of the channel is redundant encoding: the same column on color and pattern, so a reader separates the series by hue and by texture at once. As with shape, the two share one legend:
| quarter | channel | revenue |
|---|---|---|
| Q1 | Retail | 4 |
| Q1 | Online | 3 |
| Q2 | Retail | 5 |
| Q2 | Online | 6 |
| Q3 | Retail | 4 |
data(channel_sales) + bar * sum * dodge + x(quarter) + y(revenue) + color(channel) +
pattern(channel) + title("Grouped bars, separated by hue and hatch")(data(channel_sales) + bar * sum * dodge + x(col.quarter) + y(col.revenue) + color(col.channel) +
pattern(col.channel) + title("Grouped bars, separated by hue and hatch"))data(channel_sales) + bar * sum * dodge + x(:quarter) + y(:revenue) +
color(:channel) + pattern(:channel) +
title("Grouped bars, separated by hue and hatch")plot(data(channel_sales), layer(bar, sum, dodge), x(col.quarter),
y(col.revenue), color(col.channel), pattern(col.channel),
title("Grouped bars, separated by hue and hatch"))“Given the channel sales table: bars derived by sum and dodge, x is quarter, y is revenue, color by channel, pattern by channel.”
On a stroke the same channel dashes each series instead. The plot below carries two teams, separated by hue and by dash:
| month | team | sales |
|---|---|---|
| 1 | North | 2 |
| 2 | North | 3 |
| 3 | North | 4 |
| 4 | North | 5 |
| 5 | North | 6 |
data(team_trend) + line + x(month) + y(sales) + color(team) + pattern(team) +
title("Two series, by hue and dash")(data(team_trend) + line + x(col.month) + y(col.sales) + color(col.team) + pattern(col.team) +
title("Two series, by hue and dash"))data(team_trend) + line + x(:month) + y(:sales) + color(:team) +
pattern(:team) + title("Two series, by hue and dash")plot(data(team_trend), line, x(col.month), y(col.sales), color(col.team),
pattern(col.team), title("Two series, by hue and dash"))The set of textures is deliberately small: six on a fill and five on a stroke, and the first of each is solid. A fill covers a large area, so a reader can distinguish more textures on it. A dash needs a long enough line to be seen, so a reader can distinguish fewer on a stroke. Because both sets are small, pattern suits a few categories rather than dozens. A seventh category on a fill, or a sixth on a stroke, repeats a texture, and gog says so. To set one texture for a whole layer instead of mapping a column, use style(pattern = ).
23.5 opacity
Sometimes a number should decide how faint a mark is drawn, so that small values fade and large ones stay opaque.
opacity(field) maps a continuous column to opacity: a larger value gives a more opaque mark.
data(gapminder_2007) + point + x(gdp) + y(life) + opacity(population) +
title("Opacity: larger populations drawn more opaque")(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + opacity(col.population) +
title("Opacity: larger populations drawn more opaque"))data(gapminder_2007) + point + x(:gdp) + y(:life) + opacity(:population) +
title("Opacity: larger populations drawn more opaque")plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
opacity(col.population),
title("Opacity: larger populations drawn more opaque"))“Given gapminder 2007: points, x is gdp, y is life, opacity by population.”
Only China and India are drawn nearly opaque, because their populations are several times larger than any other country’s. A ramp on a skewed column shows the fix, a log scale, and it works on opacity too.
Mapped opacities run from 0.15 to 0.95 rather than 0 to 1: a fully transparent mark is invisible, which is never what a reader wants.
With no opacity binding, the default depends on the mark, so you may notice that some marks are paler than others. One idea sets all of them: a mark is as translucent as it needs to be so that it does not hide other marks, and no more.
| Mark | Default | Why |
|---|---|---|
point, and a bar, box or area on its own |
0.82 | marks may overlap a little, and the overlap shows |
| bars, boxes, violins and ribbons drawn over one another, as in overlaid histograms | 0.4, with a solid outline | the ones underneath stay visible |
the bands of a flow |
0.45 | bands cross one another |
a highlight zone |
pale | the plot behind it stays readable |
a line, most strokes, and text |
opaque | a thin stroke or a letter would only look faint |
a zone of cells such as a heatmap, a surface, and a bar or box in the cube |
opaque | they do not overlap on the flat page, and in the cube the nearer one hides the farther |
style(opacity = ) sets your own value on any of them.
Like size, opacity produces a legend showing the minimum, midpoint, and maximum of the column.
On line, opacity cannot be mapped: a stroke has one opacity, and one value cannot carry a column. This is the same rule that refuses a per-row size on a line, not a special case for opacity:
data(gapminder_asia) + line + x(year) + y(life) + opacity(population)data(gapminder_asia) + line + x(col.year) + y(col.life) + opacity(col.population)data(gapminder_asia) + line + x(:year) + y(:life) + opacity(:population)plot(data(gapminder_asia), line, x(col.year), y(col.life),
opacity(col.population))Error:
! gog: `opacity` cannot be bound to `line` — a line takes one opacity for the whole layer, not one per row. Drop the `population` mapping and set it with `style(opacity = )`, or use a mark that maps `opacity`: `point`, `bar`, `text` or `surface`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
That one stroke still has an opacity, so you can set it for the whole line:
data(gapminder_asia) + line + x(year) + y(life) + group(country) +
style(opacity = 0.4)(data(gapminder_asia) + line + x(col.year) + y(col.life) + group(col.country) +
style(opacity = 0.4))data(gapminder_asia) + line + x(:year) + y(:life) + group(:country) +
style(opacity = 0.4)plot(data(gapminder_asia), line, x(col.year), y(col.life),
group(col.country), style({ opacity: 0.4 }))“Given gapminder Asia: lines, x is year, y is life, grouped by country, with opacity 0.4.”
To split the rows into one line per country, use group, as above; to give each line its own color as well, use color.
That refusal looks like an inconsistency: color maps onto a line and opacity does not. It is not one. A width, an opacity and a dash are properties of the whole stroke: ask where along the line the width is 3px and the question has no answer. A color is not like that. You can point at one place on a line and ask what color it is, so a measure can vary along it, and color on line, step and path accepts either kind of column:
data(gapminder_asia) + line + x(year) + y(life) + group(country) +
color(gdp, scale = "log") + palette("viridis") +
y_label("Life expectancy") + title("One line per country, colored by GDP per person")(data(gapminder_asia) + line + x(col.year) + y(col.life) + group(col.country) +
color(col.gdp, scale = "log") + palette("viridis") +
y_label("Life expectancy") + title("One line per country, colored by GDP per person"))data(gapminder_asia) + line + x(:year) + y(:life) + group(:country) +
color(:gdp, scale = "log") + palette("viridis") +
y_label("Life expectancy") +
title("One line per country, colored by GDP per person")plot(data(gapminder_asia), line, x(col.year), y(col.life),
group(col.country), color(col.gdp, { scale: "log" }),
palette("viridis"), y_label("Life expectancy"),
title("One line per country, colored by GDP per person"))“Given gapminder Asia: lines, x is year, y is life, grouped by country, color by gdp on a log scale, with the viridis palette.”
A category on color splits the mark into one stroke per group. A measure varies the color along each stroke and gets a ramp legend, not one swatch (a small sample of the color) per series. One channel and one mark give two behaviors, and the column’s type decides which behavior you get. group still splits the mark either way, which is why the sentence above draws one line per country and runs the ramp along each line. See Setting vs mapping.
23.6 group
The section above used group twice without saying what it does. It is the one channel that splits a mark and encodes nothing.
group(field) splits the mark into one series per value in the column. On a line, that is one stroke per value, and every stroke gets the same color. Use it when the lines must be kept apart but one color is what you want.
data(gapminder_asia) + line + x(year) + y(life) +
group(country)(data(gapminder_asia) + line + x(col.year) + y(col.life) +
group(col.country))data(gapminder_asia) + line + x(:year) + y(:life) + group(:country)plot(data(gapminder_asia), line, x(col.year), y(col.life),
group(col.country))When you want color and grouping, use a categorical color(field) alone: it implies grouping automatically. A surface is different: there a color gives each face its color, and only group makes separate sheets (Two sheets over one floor).
A bar, a point or a text draws one shape or one label per row, so on those marks gog accepts group only with a statistic, with dodge or, on a bar, with stack. bar * mean * dodge + group(era) draws one bar for each era, side by side and in one color, as Splitting without color shows. Beside bar * mean * dodge, text * mean * dodge + group(era) prints each era’s mean on its own bar, as Labels on dodged bars shows.
23.7 Multiple channels
Two positions and one refining channel carry three columns. A table usually has a fourth you want on the page.
You have already seen two refining channels on one mark several times. Channels compose freely, so you can bind more of them at once:
# Two positions and three refining channels
data(gapminder_2007) + point + x(gdp) + y(life) +
color(continent) + size(population) + shape(continent) +
title("GDP and life expectancy, 2007")(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
color(col.continent) + size(col.population) + shape(col.continent) +
title("GDP and life expectancy, 2007"))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent) +
size(:population) + shape(:continent) +
title("GDP and life expectancy, 2007")plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.continent), size(col.population), shape(col.continent),
title("GDP and life expectancy, 2007"))Five channels carry four columns here. The three refining channels draw only two legends, because shape repeats color and the two share one. Each column on a refining channel adds a legend for the reader to check. So add a channel only when the plot needs its column, or when it makes a column easier to read, as shape does here.
A continuous column on both size and opacity gets one legend, as continent on color and shape does. With population on both, a country with more people gets a larger and more opaque point:
data(gapminder_2007) + point + x(gdp) + y(life) +
size(population) + opacity(population) + title("Population, shown twice")(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
size(col.population) + opacity(col.population) + title("Population, shown twice"))data(gapminder_2007) + point + x(:gdp) + y(:life) + size(:population) +
opacity(:population) + title("Population, shown twice")plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
size(col.population), opacity(col.population),
title("Population, shown twice"))“Given gapminder 2007: points, x is gdp, y is life, size by population, opacity by population.”
The legend has three circles, each drawn at the size and the opacity of the value beside it. A continuous column on color and size gets two legends, because three circles would show only three colors of its ramp (see color).
23.8 Palettes
Most plots so far used the colors gog chose by default. To choose your own, use palette().
palette() chooses the colors the color channel assigns. There is no separate atom for ramps, which ggplot2 calls gradients. One atom serves both, because the column’s type decides which meaning the colors get. For a categorical column, gog reads a set of colors as one color per category. For a continuous column, it reads the same set as stops, the fixed colors a ramp passes through.
There are three kinds of named palette, and they say three different things: a categorical palette gives each category its own color, a sequential ramp runs from low values to high, and a diverging ramp measures distance from a center in either direction. The type of the column decides which kinds you may name: a categorical column takes a categorical palette, and a continuous column takes a sequential or a diverging ramp. The sections below follow that order.
gog’s default palette holds 20 colors, which is more than most tables need. When a column has more categories than that, gog builds the extra colors from the HSL color wheel (hue, saturation and lightness), which places every hue around a circle. No two categories share a color.
23.8.1 Categorical palettes
Most of the time the default is what you want. You change it when a reader may be colorblind, when a journal requires particular colors, or when the marks are large enough that the default is too strong.
A categorical palette gives one color to each category, so a reader matches a swatch in the legend to a name. gog provides three, and the last column says which one to pick:
| Name | Colors | Best for |
|---|---|---|
"gog" |
20 (default) | General use |
"okabe" |
8 base + 8 light variants | Colorblind accessibility |
"soft" |
8, lighter | Filled marks: bars, areas, zones |
"okabe" is Okabe and Ito’s set (Okabe & Ito, 2008), chosen so that its eight colors stay distinct for readers with the common forms of color blindness. gog adds eight lighter versions of these colors, used when a column has more than eight categories:
data(gapminder_2007) + point + x(gdp) + y(life) +
color(continent) + palette("okabe")(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
color(col.continent) + palette("okabe"))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent) +
palette("okabe")plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.continent), palette("okabe"))The default is designed for points and lines, where a color occupies a few pixels and must stay visible at reading distance. A fill is large, so it is already easy to see, and a color strong enough to make a small point easy to find is more than a bar needs. In a row of such bars, the strong color takes attention away from the heights, which are what the bars show. soft is a different set of eight colors, lighter than the default. Compare these two. Apart from the title, the palette is the only difference:
data(gapminder_2007) + bar * count + x(continent) + color(continent) +
title("The default, over filled bars")(data(gapminder_2007) + bar * count + x(col.continent) + color(col.continent) +
title("The default, over filled bars"))data(gapminder_2007) + bar * count + x(:continent) + color(:continent) +
title("The default, over filled bars")plot(data(gapminder_2007), layer(bar, count), x(col.continent),
color(col.continent), title("The default, over filled bars"))“Given gapminder 2007: bars derived by count, x is continent, color by continent.”
data(gapminder_2007) + bar * count + x(continent) + color(continent) +
palette("soft") + title("palette(\"soft\")")(data(gapminder_2007) + bar * count + x(col.continent) + color(col.continent) +
palette("soft") + title("palette(\"soft\")"))data(gapminder_2007) + bar * count + x(:continent) + color(:continent) +
palette("soft") + title("palette(\"soft\")")plot(data(gapminder_2007), layer(bar, count), x(col.continent),
color(col.continent), palette("soft"), title("palette(\"soft\")"))The default still works on bars, and on a scatter of the same data it would be the better choice. The difference is what the color is doing: a point has to be easy to find, while a bar is already visible and only has to differ from its neighbors.
23.8.2 Sequential ramps
A continuous column has no categories to give a color to, so a palette has to mean something else. For a continuous color column, palette() names a ramp instead. The ramps below run one way, from low values to high. Lightness changes steadily along the ramp, and that is what shows the order.
| Name | What it is | Use it when |
|---|---|---|
"blue" |
the default: one hue, light→dark | almost always |
"viridis" |
many hues, with equal color steps for equal value steps | dense data where small differences must stay visible |
"magma" |
viridis’s family: black through purple to cream | a dark plot with strong contrast |
"inferno" |
the same family: black through red and orange to pale yellow | as for magma, with more red and orange |
"plasma" |
the same family, blue through magenta to yellow | a dark plot with strong contrast, starting at dark blue rather than black |
"cividis" |
the family’s colorblind-optimized member | the ramp itself has to stay readable under two-color vision (dichromacy) |
"gray" |
no hue at all: light gray to near-black | figures that will be printed in black and white |
Those seven names do not tell you what the ramps look like. Here are six of the seven, every one except the default blue, on Maunga Whau, a volcano in Auckland. The volcano is cut into cells, each colored by its mean elevation. A dense grid of continuous values like this is what a ramp is for. palette() is a property of the plot, so six ramps means six plots, and | and / put them in one arrangement:
| east | north | elevation |
|---|---|---|
| 20 | 20 | 100 |
| 20 | 40 | 102 |
| 20 | 60 | 104 |
| 20 | 80 | 105 |
| 20 | 100 | 107 |
((data(maunga_whau) + zone * bin(16) * mean + x(east) + y(north) +
color(elevation) + palette("viridis") + title("viridis")) |
(data(maunga_whau) + zone * bin(16) * mean + x(east) + y(north) +
color(elevation) + palette("magma") + title("magma"))) /
((data(maunga_whau) + zone * bin(16) * mean + x(east) + y(north) +
color(elevation) + palette("inferno") + title("inferno")) |
(data(maunga_whau) + zone * bin(16) * mean + x(east) + y(north) +
color(elevation) + palette("plasma") + title("plasma"))) /
((data(maunga_whau) + zone * bin(16) * mean + x(east) + y(north) +
color(elevation) + palette("cividis") + title("cividis")) |
(data(maunga_whau) + zone * bin(16) * mean + x(east) + y(north) +
color(elevation) + palette("gray") + title("gray")))(((data(maunga_whau) + zone * bin(16) * mean + x(col.east) + y(col.north) +
color(col.elevation) + palette("viridis") + title("viridis")) |
(data(maunga_whau) + zone * bin(16) * mean + x(col.east) + y(col.north) +
color(col.elevation) + palette("magma") + title("magma"))) /
((data(maunga_whau) + zone * bin(16) * mean + x(col.east) + y(col.north) +
color(col.elevation) + palette("inferno") + title("inferno")) |
(data(maunga_whau) + zone * bin(16) * mean + x(col.east) + y(col.north) +
color(col.elevation) + palette("plasma") + title("plasma"))) /
((data(maunga_whau) + zone * bin(16) * mean + x(col.east) + y(col.north) +
color(col.elevation) + palette("cividis") + title("cividis")) |
(data(maunga_whau) + zone * bin(16) * mean + x(col.east) + y(col.north) +
color(col.elevation) + palette("gray") + title("gray"))))((data(maunga_whau) + zone * bin(16) * mean + x(:east) + y(:north) +
color(:elevation) + palette("viridis") + title("viridis")) |
(data(maunga_whau) + zone * bin(16) * mean + x(:east) + y(:north) +
color(:elevation) + palette("magma") + title("magma"))) /
((data(maunga_whau) + zone * bin(16) * mean + x(:east) + y(:north) +
color(:elevation) + palette("inferno") + title("inferno")) |
(data(maunga_whau) + zone * bin(16) * mean + x(:east) + y(:north) +
color(:elevation) + palette("plasma") + title("plasma"))) /
((data(maunga_whau) + zone * bin(16) * mean + x(:east) + y(:north) +
color(:elevation) + palette("cividis") + title("cividis")) |
(data(maunga_whau) + zone * bin(16) * mean + x(:east) + y(:north) +
color(:elevation) + palette("gray") + title("gray")))below(below(beside(plot(data(maunga_whau), layer(zone, bin(16), mean),
x(col.east), y(col.north), color(col.elevation), palette("viridis"),
title("viridis")),
plot(data(maunga_whau), layer(zone, bin(16), mean), x(col.east),
y(col.north), color(col.elevation), palette("magma"), title("magma"))),
beside(plot(data(maunga_whau), layer(zone, bin(16), mean), x(col.east),
y(col.north), color(col.elevation), palette("inferno"),
title("inferno")),
plot(data(maunga_whau), layer(zone, bin(16), mean), x(col.east),
y(col.north), color(col.elevation), palette("plasma"),
title("plasma")))),
beside(plot(data(maunga_whau), layer(zone, bin(16), mean), x(col.east),
y(col.north), color(col.elevation), palette("cividis"),
title("cividis")),
plot(data(maunga_whau), layer(zone, bin(16), mean), x(col.east),
y(col.north), color(col.elevation), palette("gray"), title("gray"))))“Given Maunga Whau: zones derived by bin into 16 and mean, x is east, y is north, color by elevation, with the viridis palette, beside the same with magma; above the same pair with inferno and plasma; above the same pair with cividis and gray.”
That is the same sentence six times with one word changed. It is written out in full because a loop or a variable is written differently in each of the book’s four languages.
Those six plots show four things, one in each paragraph.
gray runs from light to dark, and the other five run from dark to light. In the gray plot the summit is dark, while in the other five it is bright. Both directions are correct. gog’s own ramps (gray and the default blue) run light to dark, because with one hue a darker color reads as a larger value, and a photocopier keeps those lightness differences. The viridis-family ramps run dark to light because their designers made them that way, and reversing them would give a ramp named magma that no longer looks like magma. In any plot, the legend shows which end is high: its top label names the value at that end of the ramp.
Of the five viridis-family ramps, magma and inferno end on a near-white. magma’s last stop is nearly the same color as the panel behind it, so the very top of the scale almost disappears. You can see this in the pale, flat summits in the magma and inferno plots. The viridis family ends on very light colors by design. blue and gray never reach white, so their pale end stays visible against the panel, and that is why blue is the default rather than one of these.
cividis uses the fewest hues of the five, and that is deliberate: it uses only the two that stay distinct under the common forms of color blindness. The other four run through many more, so they separate neighboring values by hue as well as by lightness. cividis separates them mostly by lightness, and that loss is acceptable whenever the ramp has to stay readable for a colorblind reader.
gray is for the figure that will be photocopied. It is a ramp only, and there is no categorical gray palette. Grays differ only in lightness, so readers can distinguish only a few of them. For categories in a print figure, use shape on points and pattern on bars and lines, which separate them and leave the color channel free. gog refuses palette("gray") on a categorical column, and the refusal says why:
data(gapminder_2007) + point + x(gdp) + y(life) +
color(continent) + palette("gray")(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
color(col.continent) + palette("gray"))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent) +
palette("gray")plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.continent), palette("gray"))Error:
! gog: `palette("gray")` is a sequential ramp for numbers, but `color` is bound to a text column. Use a categorical palette — `gog`, `okabe`, or `soft` — or list one color per category. For categories in a figure that has to print in black and white, reach for `shape(<column>)` instead — it tells them apart without spending color.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
23.8.3 Diverging ramps
A sequential ramp means more. A diverging ramp means away from a center, in two directions. A change, a residual, a correlation and an anomaly all fall on both sides of zero. gog provides two:
| Name | What it is | Use it when |
|---|---|---|
"blue_red" |
blue → neutral → red | the usual choice |
"brown_teal" |
brown → neutral → teal | the sign of the value is what matters, and readers may be colorblind |
The name reads low to high, so blue_red paints the smallest values blue and the largest red. Both pass through the same gray at the center of the scale.
The palette does not decide which value gets the neutral color. That value is the middle of the scale’s domain, which limits sets. Without limits, the middle falls on the data’s own midpoint. Here delta runs from -32 to 120, so the midpoint is 44. That value is painted gray, and zero is painted blue.
| step | delta | total |
|---|---|---|
| Opening | 120 | TRUE |
| Sales | 45 | FALSE |
| Refunds | -18 | FALSE |
| Costs | -32 | FALSE |
| Tax | -14 | FALSE |
data(cashflow) + point + x(step) + y(delta) +
color(delta) + palette("blue_red") + style(size = 9) +
title("44 is painted gray, not 0")(data(cashflow) + point + x(col.step) + y(col.delta) +
color(col.delta) + palette("blue_red") + style(size = 9) +
title("44 is painted gray, not 0"))data(cashflow) + point + x(:step) + y(:delta) + color(:delta) +
palette("blue_red") + style(size = 9) +
title("44 is painted gray, not 0")plot(data(cashflow), point, x(col.step), y(col.delta), color(col.delta),
palette("blue_red"), style({ size: 9 }),
title("44 is painted gray, not 0"))State a symmetric domain and zero becomes the center of the scale, so zero is the value painted gray. Read the “Closing” point, which is exactly zero:
data(cashflow) + point + x(step) + y(delta) +
color(delta, limits = c(-120, 120)) + palette("blue_red") + style(size = 9) +
title("Symmetric limits paint zero gray")(data(cashflow) + point + x(col.step) + y(col.delta) +
color(col.delta, limits = [-120, 120]) + palette("blue_red") + style(size = 9) +
title("Symmetric limits paint zero gray"))data(cashflow) + point + x(:step) + y(:delta) +
color(:delta, limits = [-120, 120]) + palette("blue_red") +
style(size = 9) + title("Symmetric limits paint zero gray")plot(data(cashflow), point, x(col.step), y(col.delta),
color(col.delta, { limits: [-120, 120] }), palette("blue_red"),
style({ size: 9 }), title("Symmetric limits paint zero gray"))“Given the cashflow table: points, x is step, y is delta, color by delta from -120 to 120, with the blue red palette.”
Two things follow from centering the ramp with limits. The ramp stays linear: equal distance in color is equal distance in the data on both sides of the center. Some scales set a midpoint and then rescale each side to the data’s own ends, and a scale built that way is no longer linear. And limits removes rows outside it, so the domain has to be wide enough to hold the data: c(-50, 50) here would drop the opening balance, and gog would say so.
Symmetric limits have one drawback. If a column runs from -2 to 100, limits of -100 to 100 leave almost all of the blue half unused, because the data reaches only a little way below zero. Accept that when the sign is what matters, since zero has to be painted gray. When the sign does not matter, a sequential ramp such as blue uses its whole range.
23.8.4 Stops of your own
Sometimes you need a ramp between two particular colors, and no named ramp runs between them. Pass those colors to palette(): they become the stops, and gog fills in the colors between them:
data(gapminder_2007) + point + x(gdp) + y(life) +
color(life) + palette(c("white", "navy")) + style(size = 6) +
title("Custom stops")(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
color(col.life) + palette(["white", "navy"]) + style(size = 6) +
title("Custom stops"))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:life) +
palette(["white", "navy"]) + style(size = 6) + title("Custom stops")plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.life), palette(["white", "navy"]), style({ size: 6 }),
title("Custom stops"))“Given gapminder 2007: points, x is gdp, y is life, color by life, with a palette of white and navy.”
gog refuses a palette that does not match the column, rather than guessing what you meant. A categorical palette cannot serve a continuous column:
data(gapminder_2007) + point + x(gdp) + y(life) +
color(life) + palette("okabe")(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
color(col.life) + palette("okabe"))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:life) +
palette("okabe")plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.life), palette("okabe"))Error:
! gog: `palette("okabe")` hands out one color per category, but `color` is bound to a numeric column. Use a ramp — `blue`, `viridis`, `magma`, `inferno`, `plasma`, `cividis`, or `gray` run one way, `blue_red` or `brown_teal` diverge from a center — or give `palette()` your own stops, the colors themselves, such as "white" and "navy".
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
23.8.5 A ramp on a skewed column
No ramp reads well on a skewed column, and changing the palette does not fix it. A linear ramp spreads its colors evenly across the domain, so when most rows fall in a narrow part of it they all receive almost the same color. When you color by population, nearly every country falls at the light end:
data(gapminder_2007) + point + x(gdp) + y(life) + color(population) +
x_label("GDP per person")(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + color(col.population) +
x_label("GDP per person"))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:population) +
x_label("GDP per person")plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.population), x_label("GDP per person"))The two dark points are China and India, whose populations are several times larger than any other country’s. The cause is the data, not the palette, so the fix is a log scale on the channel, and on x as well, since GDP per person is skewed the same way:
data(gapminder_2007) + point + x(gdp, scale = "log") + y(life) +
color(population, scale = "log") +
x_label("GDP per person")(data(gapminder_2007) + point + x(col.gdp, scale = "log") + y(col.life) +
color(col.population, scale = "log") +
x_label("GDP per person"))data(gapminder_2007) + point + x(:gdp, scale = "log") + y(:life) +
color(:population, scale = "log") + x_label("GDP per person")plot(data(gapminder_2007), point, x(col.gdp, { scale: "log" }),
y(col.life), color(col.population, { scale: "log" }),
x_label("GDP per person"))On the log scale the colors spread along the whole ramp, so countries of different sizes get different colors.
color, size and opacity all take scale =, because all three answer “how far along?”. gog refuses it on shape, pattern and group: they answer “which one?”.
23.8.6 Custom colors
Often the colors are decided before you begin: a journal asks for them, or an earlier figure already used them.
Pass the colors yourself: hex values, CSS color names such as firebrick, or a mix of the two:
data(gapminder_2007) + point + x(gdp) + y(life) +
color(continent) +
palette(c("#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00"))(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
color(col.continent) +
palette(["#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00"]))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent) +
palette(["#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00"])plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.continent),
palette(["#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00"]))The same call takes CSS color names, which are easier to recognize when you read the code later:
data(gapminder_2007) + point + x(gdp) + y(life) +
color(continent) +
palette(c("firebrick", "steelblue", "seagreen", "rebeccapurple", "darkorange"))(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
color(col.continent) +
palette(["firebrick", "steelblue", "seagreen", "rebeccapurple", "darkorange"]))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent) +
palette(["firebrick", "steelblue", "seagreen", "rebeccapurple", "darkorange"])plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.continent),
palette(["firebrick", "steelblue", "seagreen", "rebeccapurple",
"darkorange"]))gog refuses a color it cannot recognize, and offers the nearest match. Otherwise the misspelled name would go into the plot, and a browser draws an unknown color as black:
data(gapminder_2007) + point + x(gdp) + y(life) +
color(continent) + palette(c("firebrick", "stelblue"))(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
color(col.continent) + palette(["firebrick", "stelblue"]))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent) +
palette(["firebrick", "stelblue"])plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.continent), palette(["firebrick", "stelblue"]))Error:
! gog: `palette()` entry "stelblue" is not a color. Did you mean "steelblue"? Use a CSS color name, or a hex value like "#4e79a7".
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
Colors are assigned in first-appearance order: the first unique value gets the first palette color, the second gets the second, and so on. If the column declares its order, that order is used instead. Either way, the same rows in the same order always get the same colors.
One mistake to know about: a single color is not a palette. palette() reads a single word as a palette name, and "red" is not one. What palette("red") attempts is one constant color, which is style(color = "red"):
data(gapminder_2007) + point + x(gdp) + y(life) +
color(continent) + palette("red")(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
color(col.continent) + palette("red"))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent) +
palette("red")plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.continent), palette("red"))Error:
! gog: `palette("red")` is not a known palette. Named palettes are gog, okabe, soft for categories; blue, viridis, magma, inferno, plasma, cividis, gray for numbers; blue_red, brown_teal for numbers that diverge from a center. `"red"` is a color, not a palette. To paint every mark one color use `style(color = "red")`; to give each category its own color, give `palette()` several colors, "red" among them.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
23.8.7 Colors by name
Colors passed without names are assigned in first-appearance order, so a category’s color depends on where it falls in that order. Sorting the rows can change a category’s color, and so can another table that lists the categories in a different order. Two plots placed side by side in a report then disagree about which color a continent is. To assign a color to a category, write the category’s name beside the color:
data(gapminder_2007) + point + x(gdp) + y(life) + color(continent) +
palette(c(Africa = "seagreen", Americas = "goldenrod", Asia = "tomato",
Europe = "steelblue", Oceania = "orchid"))(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + color(col.continent) +
palette({"Africa": "seagreen", "Americas": "goldenrod", "Asia": "tomato", "Europe": "steelblue", "Oceania": "orchid"}))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent) +
palette(["Africa" => "seagreen", "Americas" => "goldenrod", "Asia" => "tomato", "Europe" => "steelblue", "Oceania" => "orchid"])plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.continent),
palette({ Africa: "seagreen", Americas: "goldenrod", Asia: "tomato",
Europe: "steelblue", Oceania: "orchid" }))“Given gapminder 2007: points, x is gdp, y is life, color by continent, with a palette naming Africa seagreen, Americas goldenrod, Asia tomato, Europe steelblue and Oceania orchid.”
Each continent now takes the color written beside its name, whatever order the rows arrive in. The legend still lists the continents in first-appearance order, with Asia first. Once colors are named, the names and the categories must match. A name that is not a category would color nothing. A category without a name would get a color nobody chose. gog refuses both. So a palette that names all five continents is refused on a table that holds only three, unless the column’s declared order includes all five. Usually a mismatch is a misspelled name, and the message suggests the category you probably meant:
data(gapminder_2007) + point + x(gdp) + y(life) + color(continent) +
palette(c(Africa = "seagreen", Americas = "goldenrod", Asai = "tomato",
Europe = "steelblue", Oceania = "orchid"))(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + color(col.continent) +
palette({"Africa": "seagreen", "Americas": "goldenrod", "Asai": "tomato", "Europe": "steelblue", "Oceania": "orchid"}))data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent) +
palette(["Africa" => "seagreen", "Americas" => "goldenrod", "Asai" => "tomato", "Europe" => "steelblue", "Oceania" => "orchid"])plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
color(col.continent),
palette({ Africa: "seagreen", Americas: "goldenrod", Asai: "tomato",
Europe: "steelblue", Oceania: "orchid" }))Error:
! gog: `palette()` names "Asai", and `continent` has no level called that, so its color would go to nothing. Did you mean "Asia"? The levels of `continent` are "Asia", "Europe", "Africa", "Americas", and "Oceania".
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
23.9 Legend placement
Every plot above that maps a refining channel drew a legend automatically, to the right of the panel. The panel shrinks by itself to make room, so there is no margin to set. To move all of a plot’s legends to another side, write theme(legend = ) with "bottom", "top" or "left". Above or below the panel, a legend’s entries are placed side by side, and they continue on a new line when the plot is too narrow. The panel then gets shorter instead of narrower. The legend of a continuous color is drawn horizontally there, with the smallest value on the left. Style draws a plot with its legend below the panel. A legend too big for the plot is left out rather than drawn outside the image. gog says so, and names the theme() width or height that would give it room.
23.9.1 Leaving a legend out
A legend decodes a channel, and sometimes an axis already decodes the same channel. Here color and x map the same column, so the axis already names the continent under every bar. A legend would repeat those names. Write legend = FALSE on the channel and the legend is left out:
data(gapminder_2007) + bar * count + x(continent) +
color(continent, legend = FALSE)(data(gapminder_2007) + bar * count + x(col.continent) +
color(col.continent, legend = False))data(gapminder_2007) + bar * count + x(:continent) +
color(:continent, legend = false)plot(data(gapminder_2007), layer(bar, count), x(col.continent),
color(col.continent, { legend: false }))“Given gapminder 2007: bars derived by count, x is continent, color by continent, without a legend.”
The bars keep their colors, because the channel still maps the column. Only the legend is gone, and the panel takes the legend’s space. legend belongs to the five refining channels, the ones that draw a legend. x, y and z are read from their axes, and group, label and play draw no legend. gog refuses legend on all six:
data(gapminder_2007) + bar * count + x(continent, legend = FALSE)data(gapminder_2007) + bar * count + x(col.continent, legend = False)data(gapminder_2007) + bar * count + x(:continent, legend = false)plot(data(gapminder_2007), layer(bar, count),
x(col.continent, { legend: false }))Error:
! gog: `x(continent)` is given `legend` — `x` is read off its axis, so there is no legend to show or leave out. `legend` belongs on the five channels that draw one: `color`, `size`, `shape`, `pattern` and `opacity`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
A legend is one kind of guide, the part of a plot that decodes a channel. A position’s guide is its axis, and axis = FALSE on the position leaves it out, as Scales shows.
23.10 In short
A channel takes a column, never a value: gog refuses color("red") and points to style(). The five refining channels get a legend, and x, y and z get an axis; legend = FALSE and axis = FALSE leave them out. group and label get neither, and play gets a strip that names the frame. A mark’s geometry decides which channels it accepts. palette() chooses the colors: a categorical palette for a categorical column, a ramp for a continuous one.
23.11 What comes next
Every channel here reads a column, so every plot answers a question about the data. Setting vs mapping covers the opposite case: a value you choose yourself, which reads no column and gets no legend.