22  Channels and legends

Which visual property should carry which column? Channels animate a mark by binding a data column to a visual property, and they do not all play the same part. x, y and z are the vowels: without one a mark is silent. The five below that earn a legend 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 follows the same pattern:

channel(field)

No exceptions. x and y are channels too: the same rule applies everywhere.

The word for this is not settled outside gog, so you may know it as something else. Bertin set the vocabulary down first, in Sémiologie graphique (Bertin, 1967), separating the plane’s two dimensions from six retinal variables (size, value, texture, color, orientation, shape) on the grounds that the eye does not read them the same way. Wilkinson and ggplot2 both say aesthetic, which is why you write aes() there and nothing like it here. The visualization literature settled instead on channel, paired with the mark it modifies (Munzner, 2014), and that is the word gog takes. A channel is something a signal travels down, which is what this is, and Plain Names asks for the everyday word that says the job. Encoding is the same idea named as an act rather than a thing, and both uses survive here: a channel is what you write, an encoding is what it does, and 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 categorical or continuous
size(field) point radius 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 is bound to a column, with no configuration required. x and y earn an axis instead, and group earns neither: it splits a mark without encoding anything, so there is nothing for a key to decode.


22.1 color

color(field) maps a column to color, and a legend appears automatically. It takes either kind of column, and the kind decides what color means.

It is spelled color, and only color. gog writes American English throughout and accepts no second spelling, so there is no colour here as there is in ggplot2; writing one earns a refusal that names the fix (see Law 3).

A text column asks “which one?” Each category gets its own hue 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))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Continent Asia Europe Africa Americas Oceania

A numeric column asks “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 }))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Life 82.60 61.11 39.61

Notice the legend: color gets one continuous strip while size and opacity get three sampled examples. A legend should show the scale, and color is the only continuous channel whose whole range fits in a fixed space; there is no way to draw a continuum of circles. Same rule, different shapes. The difference matters most for a multi-hue ramp: given three swatches of viridis a reader cannot guess what lies between them, whereas three circles obviously interpolate.

The default ramp is one hue, not a rainbow, and that too is deliberate. A sequential ramp encodes magnitude, and lightness is what a reader perceives as more: one hue running light→dark is readable at a glance, survives grayscale printing and color blindness, and does not compete with 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. See Ramps below for the multi-hue alternatives, the diverging ramps, and custom stops.

color also implies grouping for line marks. Each unique value gets its own connected line:

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 +
  point
plot(data(gapminder_asia), x(col.year), y(col.life), color(col.country),
  line, point)
1960 1980 2000 40 50 60 70 80 Life Year Country China India Indonesia Japan Korea, Rep.

Position decides which layers a channel reaches. color(country) is written before any mark 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))
1960 1980 2000 40 50 60 70 80 Life Year Country China India Indonesia Japan Korea, Rep. Population 20.9M 669.8M 1.3B

Channels bind forward, never backward. See Encoding scope.


22.2 size

size(field) scales point radius linearly from 3 px (min) to 12 px (max). A size legend shows three representative circles.

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))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Population 199.6K 659.4M 1.3B

Combine color and size for a classic bubble chart:

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))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Continent Asia Europe Africa Americas Oceania Population 199.6K 659.4M 1.3B

22.3 shape

shape(field) maps a categorical column to one of five glyphs: circle, square, triangle, diamond, cross. Useful for distinguishing groups in black-and-white prints.

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))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Continent Asia Europe Africa Americas Oceania

color and shape can be bound to the same field. This double-encodes the grouping, improving accessibility:

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))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Continent Asia Europe Africa Americas Oceania Continent Asia Europe Africa Americas Oceania

22.4 pattern

pattern(field) is shape’s twin one geometry class over: it maps a categorical column to a texture rather than a glyph, realized by the mark’s geometry: a hatch on the fills (bar, box, area, ribbon), a dash on the strokes (line, step, interval). Textures read without hue, so a plot mapped by pattern stays legible in grayscale or with color-blindness. The first category is always the plain one (solid), the rest textured:

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)"))
setosa versicolor virginica 0 2 4 Mean petal length (cm) Species Species setosa versicolor virginica

The point of the channel is redundant encoding: the same column on color and pattern, so series are told apart by hue and texture at once (the accessibility best practice). gog draws these as one merged legend, not two:

sales <- data.frame(
  quarter = rep(c("Q1", "Q2", "Q3"), each = 2),
  channel = rep(c("Retail", "Online"), 3),
  revenue = c(4, 3, 5, 6, 4, 8)
)
data(sales) + bar * sum * dodge + x(quarter) + y(revenue) + color(channel) +
  pattern(channel) + title("Grouped bars, told apart by hue and hatch")
Q1 Q2 Q3 0 2 4 6 8 Grouped bars, told apart by hue and hatch Revenue Quarter Channel Retail Online

On a stroke the same channel dashes each series instead: the color-free line chart:

trend <- data.frame(
  month = rep(1:6, 2),
  team  = rep(c("North", "South"), each = 6),
  sales = c(2, 3, 4, 5, 6, 7,  5, 4, 4, 3, 2, 2)
)
data(trend) + line + x(month) + y(sales) + color(team) + pattern(team) +
  title("Two series, by hue and dash")
1 2 3 4 5 6 2 3 4 5 6 7 Two series, by hue and dash Sales Month Team North South

The texture palette is deliberately small (five hatches on a fill, three dashes on a stroke), so pattern suits a handful of categories, not dozens; past the palette size the textures repeat. To fix one texture for a whole layer rather than map a column, that is the style(pattern = ) setting.


22.5 opacity

opacity(field) maps a continuous column to transparency. Useful when marks overlap and you want the crowded regions to read as denser.

data(gapminder_2007) + point + x(gdp) + y(life) + opacity(population) +
  title("Opacity: larger populations drawn more solidly")
(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + opacity(col.population) +
  title("Opacity: larger populations drawn more solidly"))
data(gapminder_2007) + point + x(:gdp) + y(:life) + opacity(:population) +
  title("Opacity: larger populations drawn more solidly")
plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  opacity(col.population),
  title("Opacity: larger populations drawn more solidly"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Opacity: larger populations drawn more solidly Life Gdp Population 199.6K 659.4M 1.3B

Mapped values 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. Marks with no opacity binding are drawn at 0.82, unless style(opacity = ) sets one.

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 rules out per-row size on a line, not a special case for opacity:

gog: `opacity` cannot be bound to `line` — a line has no opacity feature.
     Remove `opacity(dist)`, or use a mark that has one.

That one stroke still has an opacity, though, 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 }))
1960 1980 2000 40 50 60 70 80 Life Year

To distinguish lines by data, use color or group.

color is the one that does not follow size and opacity here, and the difference is worth being exact about, because it looks like an exception and is not. 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 a stroke mark 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 income")
(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 income"))
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 income")
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 income"))
1960 1980 2000 40 50 60 70 80 One line per country, colored by income Life expectancy Year Gdp 31.7K 3560.4 400.4

A category on color splits the mark into one stroke per group, as it always has. A measure varies the color along each stroke and earns the gradient key instead of a swatch per series. Same channel, same mark, two questions, and the column decides which one you asked. group still does the splitting either way, which is why the sentence above draws one line per country and ramps each. See Setting vs mapping.


22.6 group

group(field) connects line segments within the same group without adding a color distinction. Use it when you want a single color for all lines but still need them separated by category.

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))
1960 1980 2000 40 50 60 70 80 Life Year

When you want color and grouping, use color(field) alone: it implies grouping automatically.


22.7 Multiple channels

Channels compose freely. Every combination that makes visual sense is valid:

# Color + size + shape on the same plot
data(gapminder_2007) + point + x(gdp) + y(life) +
  color(continent) + size(population) + shape(continent) +
  title("GDP vs Life Expectancy, 2007")
(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
  color(col.continent) + size(col.population) + shape(col.continent) +
  title("GDP vs Life Expectancy, 2007"))
data(gapminder_2007) + point + x(:gdp) + y(:life) + color(:continent) +
  size(:population) + shape(:continent) +
  title("GDP vs 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 vs Life Expectancy, 2007"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 GDP vs Life Expectancy, 2007 Life Gdp Continent Asia Europe Africa Americas Oceania Continent Asia Europe Africa Americas Oceania Population 199.6K 659.4M 1.3B

22.8 Palettes

palette() chooses the colors the color channel hands out. There is no separate atom for ramps, which ggplot2 calls gradients. The same word covers both, because whether a color vector means one color per category or stops to interpolate between follows from the column color is bound to.

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 little to much, and a diverging ramp measures distance from a center in either direction. Which kinds a name may meet is decided by the column, so the sections below are in that order.

gog uses a 20-color default palette so you won’t run out of colors for typical datasets. When categories exceed the palette size, additional colors are generated automatically via the HSL color wheel, guaranteed unique for any number of categories.

22.8.1 Categorical palettes

Name Colors Best for
"gog" 20 (default) General use
"okabe" 8 base + 8 light variants Colorblind accessibility
"soft" 8, muted Paint that covers ground: 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:

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"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Continent Asia Europe Africa Americas Oceania

The default is tuned for points and lines, where a color occupies a few pixels and has to carry from across the page. A fill has the opposite problem. It already has the reader’s attention by covering ground, so the saturation that made a 4.5px dot findable is more weight than a bar needs. A row of them competes with the numbers they are there to show. soft is the same set of jobs done at lower saturation. Compare these two, which differ only in the palette:

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"))
Asia Europe Africa Americas Oceania 0 20 40 The default, over filled bars Count Continent Continent Asia Europe Africa Americas Oceania
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\")"))
Asia Europe Africa Americas Oceania 0 20 40 palette("soft") Count Continent Continent Asia Europe Africa Americas Oceania

Nothing is wrong with the first one, and on a scatter of the same data it is the better choice. The difference is what the ink is doing: a point asks to be found, a bar is already found and only has to be told apart from its neighbors.

22.8.2 Ramps

For a numeric color column, palette() names a ramp instead. These run one way, from little to much, and lightness is what carries the ordering.

Name What it is Use it when
"blue" the default: one hue, light→dark almost always
"viridis" perceptually uniform, multi-hue dense data where fine levels must be told apart
"magma" viridis’s family: black through purple to cream a dark, high-contrast field
"inferno" the same family, hotter as above, warmer
"plasma" the same family, blue through magenta to yellow as above, cooler at the low end
"cividis" the family’s colorblind-optimized member the ramp itself has to survive dichromacy
"gray" one hue, no hue figures that will be printed in black and white

A table is a poor way to choose a ramp. Here are six of the seven on one field, the Maungawhau crater cut into cells and colored by mean height, which is the kind of dense continuous surface a ramp is picked for. palette() is a property of the plot, so six ramps means six plots, and | and / put them on one page:

((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"))))
200 400 600 200 400 600 800 viridis North East Elevation 191.5 142.8 94.00 200 400 600 200 400 600 800 magma North East Elevation 191.5 142.8 94.00 200 400 600 200 400 600 800 inferno North East Elevation 191.5 142.8 94.00 200 400 600 200 400 600 800 plasma North East Elevation 191.5 142.8 94.00 200 400 600 200 400 600 800 cividis North East Elevation 191.5 142.8 94.00 200 400 600 200 400 600 800 gray North East Elevation 191.5 142.8 94.00

That is the same sentence six times with one word changed, written out rather than wrapped in a helper, because a helper would be an R function and this book draws every sentence in four languages.

Three things are worth reading off that page.

gray runs the other way. Its crater is dark at the summit while the other five are bright there. That is not a mistake in either: gog’s own ramps (gray and the default blue) run light to dark, because for a single hue more ink reads as more, which is also how the figure survives a photocopier. The viridis family runs dark to light, because that is what those ramps are, and re-pitching them would leave you with something called magma that is not magma. The legend is what settles it in any given plot, and it is the reason a mapped channel always gets one: read the top label and you know which end is which.

The four imported ramps end on a near-white. magma’s last stop holds barely any contrast against the panel, so the very top of the scale nearly disappears, which you can see in the pale flat summits above. That is the price of perceptual uniformity rather than an oversight, and it is why blue is the default rather than one of these. blue and gray are pitched to keep their pale end on the page.

cividis is the quiet one, and deliberately: it spends its range on the two hues that survive the common forms of color blindness, so it has less contrast to give than inferno does. That is the trade, and it is the right one whenever the ramp itself has to be readable rather than merely pretty.

gray is for the figure that has to survive photocopying. It is a ramp only: there is no categorical gray palette, because grays stop being tellable apart after about four and the grammar already has a better answer for categories in a print figure. That answer is pattern, which separates them without spending color at all, and asking for palette("gray") on a text column says so:

data(gapminder_2007) + point + x(gdp) + y(life) +
  color(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 `pattern(<column>)` instead — it tells them apart without spending color.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

22.8.3 Diverging ramps

A sequential ramp means more. A diverging ramp means away from a center, in two directions, which is what a change, a residual, a correlation and an anomaly all are. Two ship:

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 the whole point, and readers may be colorblind

The name reads low to high, so blue_red paints the smallest values blue and the largest red. Both turn through the same gray, and that gray is the color of “nothing here”.

Which value gets the neutral is not a property of the palette. It is the middle of the scale’s domain, so limits is what puts zero in the middle, and without it the middle falls wherever the data’s own midpoint happens to land. Here the cash flow runs from -32 to 120, so the midpoint is 44: that is what gets painted gray, and zero comes out blue.

data(cashflow) + point + x(step) + y(delta) +
  color(delta) + palette("blue_red") + style(size = 9) +
  title("The neutral lands at 44, not at 0")
(data(cashflow) + point + x(col.step) + y(col.delta) +
  color(col.delta) + palette("blue_red") + style(size = 9) +
  title("The neutral lands at 44, not at 0"))
data(cashflow) + point + x(:step) + y(:delta) + color(:delta) +
  palette("blue_red") + style(size = 9) +
  title("The neutral lands at 44, not at 0")
plot(data(cashflow), point, x(col.step), y(col.delta), color(col.delta),
  palette("blue_red"), style({ size: 9 }),
  title("The neutral lands at 44, not at 0"))
Opening Sales Refunds Costs Tax Closing 0 50 100 The neutral lands at 44, not at 0 Delta Step Delta 120.0 44.00 -32.00

State a symmetric domain and it lands where it means something. 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("limits = c(-120, 120) puts zero on the neutral")
(data(cashflow) + point + x(col.step) + y(col.delta) +
  color(col.delta, limits = [-120, 120]) + palette("blue_red") + style(size = 9) +
  title("limits = c(-120, 120) puts zero on the neutral"))
data(cashflow) + point + x(:step) + y(:delta) +
  color(:delta, limits = [-120, 120]) + palette("blue_red") +
  style(size = 9) +
  title("limits = c(-120, 120) puts zero on the neutral")
plot(data(cashflow), point, x(col.step), y(col.delta),
  color(col.delta, { limits: [-120, 120] }), palette("blue_red"),
  style({ size: 9 }),
  title("limits = c(-120, 120) puts zero on the neutral"))
Opening Sales Refunds Costs Tax Closing 0 50 100 limits = c(-120, 120) puts zero on the neutral Delta Step Delta 120.0 0 -120.0

Two things follow from doing it this way. The ramp stays linear: equal distance in color is equal distance in the data on both arms, which is not true of a scale that pins a midpoint and then stretches each side to reach the data’s own ends. 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 say so. The cost is the other side of the same coin, and it is worth knowing before you use this. A signed column that is lopsided, -2 to 100 say, spends almost none of its blue arm, because a symmetric domain is mostly empty on the short side.

A vector of colors becomes the stops, interpolated between:

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"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Custom stops Life Gdp Life 82.60 61.11 39.61

Asking for a palette of the wrong kind is refused rather than guessed at. A categorical palette cannot serve a numeric column:

data(gapminder_2007) + point + x(gdp) + y(life) +
  color(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 your own stops, e.g. `palette(c("white", "navy"))`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

No ramp survives a skewed column, and swapping palettes will not help. A linear ramp spends most of its range on the values you have most of: color population and nearly every country lands at the light end, because two countries are an order of magnitude larger than the rest. That is the data, not the palette, so the fix is a log scale on the channel:

data(gapminder_2007) + point + x(gdp, scale = "log") + y(life) +
  color(population, scale = "log") +
  x_label("GDP per capita")
(data(gapminder_2007) + point + x(col.gdp, scale = "log") + y(col.life) +
  color(col.population, scale = "log") +
  x_label("GDP per capita"))
data(gapminder_2007) + point + x(:gdp, scale = "log") + y(:life) +
  color(:population, scale = "log") + x_label("GDP per capita")
plot(data(gapminder_2007), point, x(col.gdp, { scale: "log" }),
  y(col.life), color(col.population, { scale: "log" }),
  x_label("GDP per capita"))
1K 10K 40 50 60 70 80 Life GDP per capita Population 1.3B 16.2M 199.6K

color, size and opacity all take scale =, because all three answer “how far along?”. shape and group do not: they answer “which one?”.

22.8.4 Custom colors

Pass a character vector (hex values, CSS color names, or a mix):

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"]))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Continent Asia Europe Africa Americas Oceania
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"]))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Continent Asia Europe Africa Americas Oceania

A color gog cannot recognize is refused with the nearest match offered, rather than handed to the renderer to be painted black:

data(gapminder_2007) + point + x(gdp) + y(life) +
  color(continent) + palette(c("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 in the data (the first unique value gets the first palette color, the second unique value gets the second, and so on), so color assignment is deterministic and predictable. (Unless the column declares its order, whose levels win.)

One near-miss to know about: a single color is not a palette. palette() takes a palette name or a vector of colors, and palette("red") is neither; it is an attempt to set one constant color, which is style(color = "red"):

data(gapminder_2007) + point + x(gdp) + y(life) +
  color(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 pass a vector: `palette(c("red", ...))`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

22.9 Legend placement

Legends are always placed to the right of the plot area. The plot area shrinks automatically to make room, no manual margin setting. Legend suppression (show_legend = FALSE) and manual legend position (legend_position) are not built yet.