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: without one a mark does not render. 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 wrote the vocabulary down first, in Sémiologie graphique (Bertin, 1967). He separated the plane’s two dimensions from six retinal variables: size, value, 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 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 a path a signal travels down, which is what a color or a size is here. gog takes the everyday word for the job, as its Plain Names law asks. 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 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 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.


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. 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 categorical column asks “which one?” Each category gets its own hue from the palette:

gapminder_2007: first 5 of 142 rows
country continent year life population gdp
Afghanistan Asia 2007 43.828 31889923 974.5803
Albania Europe 2007 76.423 3600523 5937.0295
Algeria Africa 2007 72.301 33333216 6223.3675
Angola Africa 2007 42.731 12420476 4797.2313
Argentina Americas 2007 75.320 40301927 12779.3796
data(gapminder_2007) + point + x(gdp) + y(life) +
  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

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

A continuous 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. The rule is the same for every channel; only the shape of the legend changes. The difference matters most for a multi-hue ramp. Three sampled colors from viridis say nothing about the colors between them, while three circles of growing size do.

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 to dark is read quickly, stays readable in grayscale and under color blindness, and stays distinct from 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:

gapminder_asia: first 5 of 60 rows
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 +
  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.

“Given gapminder Asia: x is year, y is life, color by country, lines and also points.”

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

“Given gapminder Asia: x is year, y is life, lines, color by country, and also points, size by population.”

Channels bind forward, never backward. See Encoding scope.


23.2 size

With x and y spent on income and life expectancy, a third number such as population still needs a place on the plot.

size(field) scales point radius linearly from 3px (min) to 12px (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

23.3 shape

shape(field) maps a categorical column to one of five glyphs: circle, square, triangle, diamond, cross. This is what separates groups in a figure printed in black and white. Five is the whole set. Do not map shape to a column with more categories: the sixth starts over at the circle, and two categories then share a glyph. The Shape section of the point chapter lists the ways past five.

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

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

color and shape can be bound to the same column. Saying one column twice 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))
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

23.4 pattern

pattern(field) does for textures what shape does for glyphs: it maps a categorical column to a texture. The mark’s geometry decides which one: a hatch on the fills (bar, box, area, ribbon, zone), a dash on the strokes (line, step, interval, path, rule). 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:

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) + 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

“Given the iris flowers: bars derived by mean, x is species, y is petal length, pattern by species.”

The point 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. gog draws these as one merged legend, not two:

channel_sales: first 5 of 6 rows
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"))
Q1 Q2 Q3 0 2 4 6 8 Grouped bars, separated by hue and hatch Revenue Quarter Channel Retail Online

“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, so these two lines are separated by hue and by dash:

team_trend: first 5 of 12 rows
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"))
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: four hatches beside solid on a fill, and two dashes beside solid on a stroke. So pattern suits a few categories rather than dozens, and past that count the textures repeat. To fix 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 solid.

opacity(field) maps a continuous column to transparency. Use it when marks overlap, so the crowded regions look darker than the empty ones.

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

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

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 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 has no opacity feature. Remove the `population` mapping from `opacity`, or use a mark that has one.
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 }))
1960 1980 2000 40 50 60 70 80 Life Year

“Given gapminder Asia: lines, x is year, y is life, grouped by country, with opacity 0.4.”

To distinguish lines by data, use color or group.

color behaves differently from size and opacity here. The difference looks like an exception and is not, so it is worth stating exactly. 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

“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 earns the ramp legend instead of a swatch per series. One channel, one mark, two behaviors, and the column’s type decides which one 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

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.


23.7 Multiple channels

Two positions and one refining channel carry three columns. A table usually has a fourth you want on the page.

Channels compose freely, so you can bind several of them on one mark:

# Color + size + shape on the same plot
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"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 GDP and life expectancy, 2007 Life Gdp Continent Asia Europe Africa Americas Oceania Continent Asia Europe Africa Americas Oceania Population 199.6K 659.4M 1.3B

23.8 Palettes

palette() chooses the colors the color channel assigns. There is no separate atom for ramps, which ggplot2 calls gradients. One word covers both, because the column decides. A categorical column reads a set of colors as one color per category. A continuous column 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 of the three kinds you may name, so the sections below follow that order.

gog uses a 20-color default palette, so most tables never use them all. When a column has more categories than that, gog builds the extra colors from the HSL color wheel, and no two categories share a color.

23.8.1 Categorical palettes

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

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 must stay visible from across the page. A fill is large, so it is already easy to see, and the saturation that makes a small point findable is too strong on a bar. A row of saturated bars draws more attention than the numbers the bars report. soft is the same eight colors 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

“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\")"))
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 color is doing: a point has to be findable, while a bar is already visible and only has to differ from its neighbors.

23.8.2 Ramps

For a continuous color column, palette() names a ramp instead. These run one way, from low values to high, and lightness is what carries the ordering.

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 fine levels must stay distinct
"magma" viridis’s family: black through purple to cream a dark plot with strong contrast
"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 two-color vision (dichromacy)
"gray" no hue at all: light gray to near-black 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, all but the default blue, on the Maunga Whau crater. The crater is cut into cells and colored by mean height, the 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:

maunga_whau: first 5 of 1364 rows
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"))))
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

“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 shortcut here would be R code, and every sentence in this book is drawn in four languages.

Four things are worth noticing in those six plots.

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 with one hue a darker color reads as a larger value, and that also holds after photocopying. The viridis family runs dark to light, because that is what those ramps are, and reversing them would give a ramp named magma that no longer looks like 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 holds the largest values.

Of the five imported ramps, magma and inferno 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. A ramp with equal color steps ends pale by design, so that is not an oversight, and it is why blue is the default rather than one of these. blue and gray stop short of white, so their pale end stays visible.

cividis has the least contrast, and deliberately: it uses only the two hues that survive the common forms of color blindness. Less contrast is what that costs, and it is worth accepting whenever the ramp itself has to stay readable.

gray is for the figure that has to survive photocopying. It is a ramp only, and there is no categorical gray palette. Readers cannot tell more than about four grays apart, and the grammar 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 categorical column says so:

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

23.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. 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 turn through the same gray, and that gray marks the center of the scale.

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. Without limits, the middle falls on the data’s own midpoint. 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.

cashflow: first 5 of 6 rows
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("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 the neutral lands on zero. 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

“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 doing it this way. The ramp stays linear: equal distance in color is equal distance in the data on both sides of the center. A scale that fixes a midpoint and then rescales each side to the data’s own ends loses that. 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. Symmetric limits have one drawback, worth knowing before you set them. If a signed column runs from -2 to 100, almost none of the blue half is used, because the data reaches only a little way below zero.

The colors you pass become the stops, and gog interpolates 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"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Custom stops Life Gdp Life 82.60 61.11 39.61

“Given gapminder 2007: points, x is gdp, y is life, color by life, with a palette of white and navy.”

Asking for a palette of the wrong kind is refused rather than guessed at. 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 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 gives most of its colors to the range where most rows sit. Color population and nearly every country falls at the light end, because two countries are several times larger than any other. 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, pattern and group do not: they answer “which one?”.

23.8.4 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"]))
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"))
(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 in the data (the first unique value gets the first palette color, the second unique value gets the second, and so on), so the same table always gets the same colors. (Unless the column declares its order; then that order sets the sequence.)

One mistake to know about: a single color is not a palette. palette() takes a palette name or the colors themselves, 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")
(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 pass a vector: `palette(c("red", ...))`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

23.9 Legend placement

gog places every legend to the right of the plot area. The plot area shrinks by itself to make room, so there is no margin to set, no position to choose, and no way to hide a legend.