19  Zone

Which region of the plot should the values stay inside? zone shades a rectangle. It is rule’s sibling: a rule is a line and a zone is an area, and both work the same way. A rule takes one position and spans the axis it was not given. A zone takes a pair and spans the axis it was given no pair for. Give it pairs on both axes and it is a rectangle you placed.

Spanning the axis you gave no pair for is what first sets this mark apart. No other mark draws a rectangle that reaches the edge of the panel.

19.1 A band across the panel

Were sales inside the target each year? You could print the two target numbers in the caption. A reader would then have to check one year at a time. A band shaded behind the line shows every year together. The quarterly table holds one sales figure for each year, and target_band holds the two edges of a target band:

quarterly: first 5 of 19 rows
year sales
2005 95
2006 104
2007 118
2008 112
2009 88
target_band: its one row
lower upper
150 175

Here are the sales with the target band behind them, 150 to 175, across every year the table holds:

data(quarterly) + x(year) + y(sales) +
  data(target_band) + zone * bounds(lower, upper) + style(color = "seagreen") +
  data(quarterly) + line + point +
  y_label("Sales") + title("A target band, 150 to 175")
(data(quarterly) + x(col.year) + y(col.sales) +
  data(target_band) + zone * bounds(col.lower, col.upper) + style(color = "seagreen") +
  data(quarterly) + line + point +
  y_label("Sales") + title("A target band, 150 to 175"))
data(quarterly) + x(:year) + y(:sales) + data(target_band) +
  zone * bounds(:lower, :upper) + style(color = "seagreen") +
  data(quarterly) + line + point + y_label("Sales") +
  title("A target band, 150 to 175")
plot(data(quarterly), x(col.year), y(col.sales), data(target_band),
  layer(zone, bounds(col.lower, col.upper)), style({ color: "seagreen" }),
  data(quarterly), line, point, y_label("Sales"),
  title("A target band, 150 to 175"))
2005 2010 2015 2020 100 120 140 160 180 A target band, 150 to 175 Sales Year

“Given quarterly: x is year, y is sales, a target zone from lower to upper, and also a line and points.”

bounds(lower, upper) names the two columns holding the bottom and top. Nothing names a left or a right, so the zone takes those from the panel and runs edge to edge. The zone is written before the line so the data draws on top of it; a highlight belongs behind what it highlights.

19.2 A band up the panel

Some highlights are periods of time rather than ranges of value. The years of a recession are one such period, when the question is how sales changed during them. Name the other pair, start and end, and the rectangle runs up the panel instead of across it. The recessions table holds the two slumps, each with a start year and an end year:

recessions: all 2 rows
start end slump
2007.9 2009.5 Financial crisis
2020.0 2020.9 Pandemic
data(quarterly) + x(year) + y(sales) +
  data(recessions) + zone * bounds(start = start, end = end) +
    style(color = "indianred") +
  data(quarterly) + line + point +
  y_label("Sales") + title("Two slumps, from one table")
(data(quarterly) + x(col.year) + y(col.sales) +
  data(recessions) + zone * bounds(start=col.start, end=col.end) +
    style(color = "indianred") +
  data(quarterly) + line + point +
  y_label("Sales") + title("Two slumps, from one table"))
data(quarterly) + x(:year) + y(:sales) + data(recessions) +
  zone * bounds(start = :start, var"end" = :end) +
  style(color = "indianred") + data(quarterly) + line + point +
  y_label("Sales") + title("Two slumps, from one table")
plot(data(quarterly), x(col.year), y(col.sales), data(recessions),
  layer(zone, bounds({ start: col.start, end: col.end })),
  style({ color: "indianred" }), data(quarterly), line, point,
  y_label("Sales"), title("Two slumps, from one table"))
2005 2010 2015 2020 100 120 140 160 180 Two slumps, from one table Sales Year

“Given quarterly: x is year, y is sales, recession zones from start to end, and also a line and points.”

Two shaded periods, and only one zone in the sentence. One row is one rectangle, so a two-row table draws two bands, the same property that lets one rule table draw three thresholds. Add a row and a third band appears with no change to the sentence.

Look at where the red reaches: the top and bottom of the panel exactly. That is the thing a ribbon cannot do. A ribbon is bounded by its data, so it stops at whatever numbers you give it. Any number big enough to reach the top would widen the axis to include it, so the highlight would change the plot it was meant to mark.

The pair is called start/end rather than left/right on purpose. Left and right are facts about a screen. This grammar takes orientation from the bindings, so the same words still apply when the domain is drawn as an angle, in polar coordinates.

19.3 Both pairs: a rectangle

Sometimes the region to highlight is bounded on both axes: these five years, this range of sales, and nothing outside either. That is a box rather than a band. Name all four and neither axis comes from the panel:

sales_box: its one row
start end lower upper
2015 2019 145 190
data(quarterly) + x(year) + y(sales) +
  data(sales_box) + zone * bounds(lower, upper, start = start, end = end) +
    style(color = "goldenrod", opacity = 0.3) +
  data(quarterly) + line + point +
  y_label("Sales") + title("A box over five years of sales")
(data(quarterly) + x(col.year) + y(col.sales) +
  data(sales_box) + zone * bounds(col.lower, col.upper, start=col.start, end=col.end) +
    style(color = "goldenrod", opacity = 0.3) +
  data(quarterly) + line + point +
  y_label("Sales") + title("A box over five years of sales"))
data(quarterly) + x(:year) + y(:sales) + data(sales_box) +
  zone * bounds(:lower, :upper, start = :start, var"end" = :end) +
  style(color = "goldenrod", opacity = 0.3) + data(quarterly) + line +
  point + y_label("Sales") + title("A box over five years of sales")
plot(data(quarterly), x(col.year), y(col.sales), data(sales_box),
  layer(zone,
  bounds(col.lower, col.upper, { start: col.start, end: col.end })),
  style({ color: "goldenrod", opacity: 0.3 }), data(quarterly), line,
  point, y_label("Sales"), title("A box over five years of sales"))
2005 2010 2015 2020 100 120 140 160 180 A box over five years of sales Sales Year

“Given quarterly: x is year, y is sales, a sales box zone from lower to upper and from start to end, and also a line and points.”

So one atom covers the three shapes a highlight needs: a band across the panel, a band up it, and a box. The pairs you name decide which one you get, so there is no separate atom for each.

19.4 Which axis the pair bounds: the funnel

A funnel counts how many people are left at each stage, from website visits down to invoices sent. Its usual picture is a stack of centered blocks, one per stage, measured across the page rather than up it. Drawing it means moving the measurement to x, and a category on y is what moves it.

lower and upper bound the measure axis, the axis that carries the measured numbers. Every plot to this point has measured along y, so every lower/upper band has run across the panel. Now put a category on y instead. The measure moves to x, and the same two columns bound it there:

stage <- c("Website visit", "Downloads", "Potential customers",
           "Requested price", "Invoice sent")
n     <- c(39, 27.4, 20.6, 11, 2)
funnel <- data.frame(stage = factor(stage, levels = stage),
                     n = n, lo = -n / 2, hi = n / 2, mid = 0)
data(funnel) + zone * bounds(lo, hi) + y(stage) + style(opacity = 1) +
  text + label(n) + x(mid) +
  x_label("") + y_label("Stage") + title("A funnel, and no funnel atom")
(data(funnel) + zone * bounds(col.lo, col.hi) + y(col.stage) + style(opacity = 1) +
  text + label(col.n) + x(col.mid) +
  x_label("") + y_label("Stage") + title("A funnel, and no funnel atom"))
data(funnel) + zone * bounds(:lo, :hi) + y(:stage) + style(opacity = 1) +
  text + label(:n) + x(:mid) + x_label("") + y_label("Stage") +
  title("A funnel, and no funnel atom")
plot(data(funnel), layer(zone, bounds(col.lo, col.hi)), y(col.stage),
  style({ opacity: 1 }), text, label(col.n), x(col.mid), x_label(""),
  y_label("Stage"), title("A funnel, and no funnel atom"))
39 27.4 20.6 11 2 -20 -10 0 10 20 Invoice sent Requested price Potential customers Downloads Website visit A funnel, and no funnel atom Stage

“Given the funnel: zones from lo to hi, y is stage, and also text, x is mid, label by n.”

That is a funnel chart. No atom in the sentence is a funnel.

Each block is a zone. The table gives one row per block. The columns lo and hi hold -n/2 and n/2, computed in R before the plot. The numbers inside the blocks are a second layer, drawn by text. The waterfall later in this chapter computes the two ends of each block the same way.

A funnel is a bar chart whose blocks are centered on zero. A bar grows from a baseline and refuses bounds, so the centered blocks are zones with both ends named.

The plot works because lower and upper name the measure axis and not the bottom and the top of a screen. gog never asks which way the screen runs; it asks what each axis carries. Here y carries a category, so y holds slots, and the measurement can only go on x. This is the rule bar already follows: one categorical position, one measured position, and their types decide the orientation. It is why there is nothing like ggplot2’s coord_flip() to write.

19.5 Coloring the zones

The two recession bands in the sales plot are both the same red, which leaves them unnamed. Color each by its name and the legend says which is which, with no note on the plot. Each row is its own rectangle, so color maps per row. recessions carries a name for each slump:

data(quarterly) + x(year) + y(sales) +
  data(recessions) + zone * bounds(start = start, end = end) + color(slump) +
  data(quarterly) + line + point +
  y_label("Sales") + title("Named, and keyed by the legend")
(data(quarterly) + x(col.year) + y(col.sales) +
  data(recessions) + zone * bounds(start=col.start, end=col.end) + color(col.slump) +
  data(quarterly) + line + point +
  y_label("Sales") + title("Named, and keyed by the legend"))
data(quarterly) + x(:year) + y(:sales) + data(recessions) +
  zone * bounds(start = :start, var"end" = :end) + color(:slump) +
  data(quarterly) + line + point + y_label("Sales") +
  title("Named, and keyed by the legend")
plot(data(quarterly), x(col.year), y(col.sales), data(recessions),
  layer(zone, bounds({ start: col.start, end: col.end })),
  color(col.slump), data(quarterly), line, point, y_label("Sales"),
  title("Named, and keyed by the legend"))
2005 2010 2015 2020 100 120 140 160 180 Named, and keyed by the legend Sales Year Slump Financial crisis Pandemic

“Given quarterly: x is year, y is sales, recession zones from start to end colored by slump, and also a line and points.”

A zone’s color also takes a number, drawn from a continuous ramp of colors, while bar, area and ribbon accept only categories. A rectangle is large enough for the eye to compare its shade with the legend. That is what a heatmap needs, so a heatmap cell is this mark rather than a new one. Cells cut from the data draws that plot, after the sections on rectangles you place yourself.

19.6 Rectangles that are the data

The funnel’s blocks were the data, but every other zone so far has been a highlight: a rectangle behind a line, saying where to look. Nothing requires it to be behind anything. Give a zone a pair on the measure axis and a categorical position. Each category’s rectangle then runs from one number to another, with no plot underneath. Two common finance charts are made of exactly that.

19.6.1 The waterfall

A waterfall is a running total, broken into the steps that made it. Each block starts at the total before it, so the length of a block is the change in its step, not the total. The cumulative transform draws each total from zero, so it cannot draw a block that starts at the total before it. Where each block starts and ends is arithmetic on the table, done in the host language. The cashflow table holds one row per step of a cash statement: a delta for the step, and a total flag marking the subtotals:

cashflow: first 5 of 6 rows
step delta total
Opening 120 TRUE
Sales 45 FALSE
Refunds -18 FALSE
Costs -32 FALSE
Tax -14 FALSE
w <- cashflow
w$top  <- cumsum(w$delta)          # where each step ends
w$base <- c(0, head(w$top, -1))    # and where it started: the step before
w$base[w$total] <- 0               # except the subtotals, which start at zero
w$dir  <- ifelse(w$total, "total", ifelse(w$delta >= 0, "gain", "loss"))

slot <- seq_len(nrow(w)) - 1       # category k is at k, counting from 0
w$left  <- slot - 0.35             # so a side named at k ± 0.35 is inside its slot
w$right <- slot + 0.35

That chunk is R; the other three bindings compute the same columns in their own language. The sentence has four spellings like every other:

data(w) + zone * bounds(base, top, start = left, end = right) +
  x(step) + color(dir) + style(opacity = 1) +
  palette(c("steelblue", "seagreen", "indianred")) +
  y_label("Cash (thousands)") + title("Where the money went")
(data(w) + zone * bounds(col.base, col.top, start=col.left, end=col.right) +
  x(col.step) + color(col.dir) + style(opacity = 1) +
  palette(["steelblue", "seagreen", "indianred"]) +
  y_label("Cash (thousands)") + title("Where the money went"))
data(w) + zone * bounds(:base, :top, start = :left, var"end" = :right) +
  x(:step) + color(:dir) + style(opacity = 1) +
  palette(["steelblue", "seagreen", "indianred"]) +
  y_label("Cash (thousands)") + title("Where the money went")
plot(data(w),
  layer(zone,
  bounds(col.base, col.top, { start: col.left, end: col.right })),
  x(col.step), color(col.dir), style({ opacity: 1 }),
  palette(["steelblue", "seagreen", "indianred"]),
  y_label("Cash (thousands)"), title("Where the money went"))
Opening Sales Refunds Costs Tax Closing 0 50 100 150 Where the money went Cash (thousands) Step Dir total gain loss

“Given the w table: zones from base to top and from left to right, x is step, color by dir, with opacity 1.”

A few lines of host arithmetic and one sentence. The blocks that start at zero are not a special case. They are rows whose base is 0, a number the table supplies like every other.

Two parts of that sentence need explaining, because both override one of this mark’s defaults on purpose.

style(opacity = 1) is there because a zone whose sides you named is drawn translucent, at 20%. A rectangle someone chose is nearly always a highlight, with something drawn over it. Here nothing is drawn over it: the rectangle is the measurement. A cut zone (a heatmap cell) is already opaque for the same reason, and this is how you say the named one is data too.

The left/right pair makes the gap between the blocks. Without start and end, a zone fills its slot whole, while a bar takes four fifths of it and leaves one fifth empty, which is what separates the categories. A zone’s width normally means something: it is the region the category owns. A waterfall block’s width means nothing, so it should leave a gap the way bar does, and naming the sides is how to ask for one. The arithmetic works because a categorical axis puts its categories at 0, 1, 2 and so on. Category k owns the slot from k−½ to k+½, and k ± 0.35 is inside it. Drop those two columns and the rectangles touch, which reads as one connected shape rather than six steps.

19.6.2 The candlestick

A candlestick shows how a price moved in each trading session. Each session has four numbers: the price at the start (open) and the end (close), and the highest (high) and lowest (low) prices in between. gog needs no new channels for them, because a rectangle’s sides read the pair a ribbon and an interval already read. Low to high is a whisker; open to close is a rectangle; that is two layers over one table:

sessions: first 5 of 14 rows
session open close high low
01 100.39 100.45 100.66 99.98
02 99.41 99.72 99.93 98.66
03 101.02 103.11 104.19 100.53
04 102.09 102.01 102.98 101.60
05 105.03 104.85 105.71 103.86
s <- sessions
s$body_lo <- pmin(s$open, s$close)
s$body_hi <- pmax(s$open, s$close)
s$dir     <- ifelse(s$close >= s$open, "up", "down")

slot <- seq_len(nrow(s)) - 1       # the same slot arithmetic the waterfall used
s$left  <- slot - 0.3              # a candle body is narrower than a waterfall block
s$right <- slot + 0.3
data(s) + x(session) +
  interval * bounds(low, high) + color(dir) +
  zone * bounds(body_lo, body_hi, start = left, end = right) +
    color(dir) + style(opacity = 1) +
  palette(c("seagreen", "indianred")) +
  y_label("Price") + title("Fourteen sessions")
(data(s) + x(col.session) +
  interval * bounds(col.low, col.high) + color(col.dir) +
  zone * bounds(col.body_lo, col.body_hi, start=col.left, end=col.right) +
    color(col.dir) + style(opacity = 1) +
  palette(["seagreen", "indianred"]) +
  y_label("Price") + title("Fourteen sessions"))
data(s) + x(:session) + interval * bounds(:low, :high) + color(:dir) +
  zone * bounds(:body_lo, :body_hi, start = :left, var"end" = :right) +
  color(:dir) + style(opacity = 1) + palette(["seagreen", "indianred"]) +
  y_label("Price") + title("Fourteen sessions")
plot(data(s), x(col.session), layer(interval, bounds(col.low, col.high)),
  color(col.dir),
  layer(zone,
  bounds(col.body_lo, col.body_hi, { start: col.left, end: col.right })),
  color(col.dir), style({ opacity: 1 }),
  palette(["seagreen", "indianred"]), y_label("Price"),
  title("Fourteen sessions"))
01 02 03 04 05 06 07 08 09 10 11 12 13 14 100 105 110 115 Fourteen sessions Price Session Dir up down

“Given the s table: x is session, intervals from low to high colored by dir, and also zones from body lo to body hi and from left to right colored by dir.”

color(dir) is said twice, once per layer, and both are needed because of scope. A channel written after a mark belongs to that mark, so the whisker takes its session’s color along with the body. Both uses of the column share one legend, because it is one column.

There is no candlestick atom, and the grammar does not need one. The chart is made of things that already exist, which is the claim the whole grammar makes. Chart names lists it beside the pie and the rose, which are also sentences rather than words. Each candlestick row carries four numbers: two for the whisker and two for the body. ribbon and interval already read a low and a high, and a rectangle’s bottom and top are the same pair. That is why neither finance chart needs a new channel.

19.7 Cells cut from the data: the heatmap

So far every zone has been a rectangle you chose, its sides named by columns you already had. There is a second way to get sides, and it needs no new mark: let bin cut them.

Gapminder has 1704 country-years. Drawn as points, the crowded middle becomes one dark mass, because a thousand dots in one place look exactly like two hundred. Cut the plane into cells and count the rows in each instead:

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

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

bin is not the zone’s own transform. What the mark changes is how many axes the cut runs along:

Mark derived by bin Shape
bar * bin one axis cut, drawn as the bars of a histogram
zone * bin two axes cut, drawn as the cells above
surface * bin the same two axes, with the count raised into the cube

Read the dark cells that rise from lower left to upper right: wealth and life expectancy rise together. The darkest cell says how many country-years are at that combination, which overlapping dots cannot show.

The same cells can show their counts as heights instead. Change the mark to bar and add space(), and each cell becomes a separate bar rising in the cube; the surface * bin row of the table joins the heights into one sheet instead. The 3-D histogram draws the heatmap and the bars one after the other and compares what each one shows.

Three things in the heatmap’s sentence need explaining.

bin cut both axes, and nothing asked it to. It is the same transform that makes a histogram, and how many axes it cuts is taken from the mark rather than requested. A bar leaves one axis free to measure a height along, so bar * bin cuts the other one. A zone’s cell needs an extent on both axes before it can be a rectangle at all, so zone * bin cuts every axis that has a width to cut, which here is both of them. One transform, one word, readings decided by what the mark needs. There is no bin2d. Note the words has a width to cut: the mixed mesh is where they matter, because a categorical axis arrives already cut, and bin does not cut it.

The count became the color, although no color() was written. Both positions were taken, so the measurement went to the one channel left that can carry a number, and the legend is titled Count. This is what bar * bin already does for the y axis: a transform that writes a column also says where it goes.

The column it writes is called count, and you may name it out loud. That is not a different plot. It is the same sentence with nothing left implied:

short <- data(gm_all) + zone * bin(24) + x(gdp, scale = "log") + y(life)
long  <- data(gm_all) + zone * bin(24) + x(gdp, scale = "log") + y(life) +
           color(count)

drawn <- function(plot) sub(' aria-label="[^"]*"', "", render_svg(plot))
identical(drawn(short), drawn(long))
[1] TRUE
import re

short = data(gm_all) + zone * bin(24) + x(col.gdp, scale = "log") + y(col.life)
long = (data(gm_all) + zone * bin(24) + x(col.gdp, scale = "log") + y(col.life) +
          color(col.count))

def drawn(plot):
    return re.sub(r' aria-label="[^"]*"', "", render_svg(plot), count=1)

print(drawn(short) == drawn(long))
True
short = data(gm_all) + zone * bin(24) + x(:gdp, scale = "log") + y(:life)
long  = data(gm_all) + zone * bin(24) + x(:gdp, scale = "log") + y(:life) +
          color(:count)

drawn(plot) = replace(render_svg(plot), r" aria-label=\"[^\"]*\"" => ""; count = 1)
println(drawn(short) == drawn(long))
true
const short = plot(data(gm_all), layer(zone, bin(24)), x(col.gdp, { scale: "log" }),
  y(col.life));
const long = plot(data(gm_all), layer(zone, bin(24)), x(col.gdp, { scale: "log" }),
  y(col.life), color(col.count));

const drawn = (p) => render_svg(p).replace(/ aria-label="[^"]*"/, "");
console.log(drawn(short) === drawn(long));
true

render_svg() returns the picture as text. That text also holds a short description of the plot for screen readers, written from the sentence. The long form’s description ends with “color by count”, and the short form’s does not. The drawn function removes that description, and the rest of the two texts is the same, so the two pictures are the same.

Naming count has one use: a binding is where a scale goes, and the short form has nowhere to put one. Counts are strongly skewed here: the fullest cell holds forty country-years while half of them hold four or fewer. On a linear ramp nearly every cell is the same pale blue. Name the channel and you can say scale = "log" on it, the same way you already did on x:

data(gm_all) + zone * bin(24) + x(gdp, scale = "log") + y(life) +
  color(count, scale = "log") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("The same cells, counted on a log ramp")
(data(gm_all) + zone * bin(24) + x(col.gdp, scale = "log") + y(col.life) +
  color(col.count, scale = "log") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("The same cells, counted on a log ramp"))
data(gm_all) + zone * bin(24) + x(:gdp, scale = "log") + y(:life) +
  color(:count, scale = "log") + x_label("GDP per person") +
  y_label("Life expectancy") +
  title("The same cells, counted on a log ramp")
plot(data(gm_all), layer(zone, bin(24)), x(col.gdp, { scale: "log" }),
  y(col.life), color(col.count, { scale: "log" }),
  x_label("GDP per person"), y_label("Life expectancy"),
  title("The same cells, counted on a log ramp"))
1K 10K 100K 40 60 80 The same cells, counted on a log ramp Life expectancy GDP per person Count 40 6.32 1

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

Now the cells with few rows get distinct shades, and the dark diagonal shows a gradient rather than one flat patch of color. The legend’s middle label is 6.32, not 20.5: on a log ramp the middle color stands for the geometric mean of the two ends.

The white gaps are cells where no country-year fell. They are left as panel, not painted the pale end of the ramp, because that end stands for a count of one. The uneven edge you get instead is the region the data actually covers, which is information rather than a gap the renderer failed to fill.

bin(24) sets the mesh, the grid of cells the cut makes, exactly as it does on a histogram, and one number cuts both axes into that many bins. A coarser mesh shows less detail, and claims less precision:

data(gm_all) + zone * bin(8) + x(gdp, scale = "log") + y(life) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("The same data, eight bins a side")
(data(gm_all) + zone * bin(8) + x(col.gdp, scale = "log") + y(col.life) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("The same data, eight bins a side"))
data(gm_all) + zone * bin(8) + x(:gdp, scale = "log") + y(:life) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("The same data, eight bins a side")
plot(data(gm_all), layer(zone, bin(8)), x(col.gdp, { scale: "log" }),
  y(col.life), x_label("GDP per person"), y_label("Life expectancy"),
  title("The same data, eight bins a side"))
1K 10K 100K 40 60 80 The same data, eight bins a side Life expectancy GDP per person Count 212 106.5 1

Notice that the log scale still applies. The cells are cut in log space, so they are even on the page rather than even in dollars. That is what you want when the variable ranges from a few hundred dollars to a hundred thousand.

These cells are also drawn solid rather than translucent, for the reason the waterfall gave: here the zone is the data, with nothing behind it to show through.

19.8 Hexagons: the other mesh

Does the grid pattern in a heatmap come from the data or from the mesh? The cells above are rectangles, and that is a choice rather than a fact about binning. Say tiling = "hex" and the plane is cut a different way:

data(gm_all) + zone * bin(20, tiling = "hex") + x(gdp, scale = "log") + y(life) +
  palette("viridis") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("The same country-years, hexagonal cells")
(data(gm_all) + zone * bin(20, tiling = "hex") + x(col.gdp, scale = "log") + y(col.life) +
  palette("viridis") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("The same country-years, hexagonal cells"))
data(gm_all) + zone * bin(20, tiling = "hex") + x(:gdp, scale = "log") +
  y(:life) + palette("viridis") + x_label("GDP per person") +
  y_label("Life expectancy") +
  title("The same country-years, hexagonal cells")
plot(data(gm_all), layer(zone, bin(20, { tiling: "hex" })),
  x(col.gdp, { scale: "log" }), y(col.life), palette("viridis"),
  x_label("GDP per person"), y_label("Life expectancy"),
  title("The same country-years, hexagonal cells"))
1K 10K 100K 40 60 80 The same country-years, hexagonal cells Life expectancy GDP per person Count 43 22 1

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

Two things changed there, and only one of them is the mesh. palette("viridis") replaces the default sequential ramp, and it is the right choice for a count. Viridis is built so equal steps in the number are equal steps in perceived brightness. On a plot whose entire message is how many, that is the difference between reading the diagonal and guessing. The palette is a free choice on either mesh, though, so do not read it as something hexagons require.

The hexagonal mesh is chosen for a reason, not for its looks: the rectangular mesh has a defect. A square grid lines up its cell centers in rows and columns, and the eye is very good at seeing rows and columns: it reads that alignment as if it were structure in the data. Wilkinson states it directly: rectangular bins “lead the eye to align bin centers and to see regularity where there is none” (Wilkinson, 2005). That is why Carr and colleagues devised hexagon binning in 1987 (Carr et al., 1987). A hexagonal mesh staggers alternate rows, so there is no aligned lattice left to mistake for a finding.

To see that on its own, keep the palette the same and change only the mesh:

data(gm_all) + zone * bin(20) + x(gdp, scale = "log") + y(life) +
  palette("viridis") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Rectangular cells, same palette")
(data(gm_all) + zone * bin(20) + x(col.gdp, scale = "log") + y(col.life) +
  palette("viridis") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Rectangular cells, same palette"))
data(gm_all) + zone * bin(20) + x(:gdp, scale = "log") + y(:life) +
  palette("viridis") + x_label("GDP per person") +
  y_label("Life expectancy") + title("Rectangular cells, same palette")
plot(data(gm_all), layer(zone, bin(20)), x(col.gdp, { scale: "log" }),
  y(col.life), palette("viridis"), x_label("GDP per person"),
  y_label("Life expectancy"), title("Rectangular cells, same palette"))
1K 10K 100K 40 60 80 Rectangular cells, same palette Life expectancy GDP per person Count 45 23 1

Look along the sparse edges of the two plots. The rectangular cells line up in straight rows and columns, so the edge of the data is made of horizontal and vertical pieces. The hexagons do not line up, so the edge follows the data rather than the mesh.

19.8.1 Why the mesh belongs to bin

In the rest of this book, a transform computes and the mark decides how the result is drawn. bar * bin, line * bin and step * bin are one histogram drawn three ways. So it would be reasonable to expect the hexagon to be a mark, or a setting on one. It is neither, and the reason is in the numbers themselves.

Those three marks all receive the same numbers. A hexagonal mesh staggers alternate rows of cells, which changes which cell each observation falls in, so the counts themselves differ. You cannot draw a hexagonal plot from data binned into rectangles, whatever the mark does with it. The mesh is decided before anything is drawn, so it belongs to the transform that does the cutting.

That is also how Wilkinson organizes it. His bin is not one method but a family of ways to partition a plane, with rect and hex among the members. The mesh both shapes the cells and tags each row with the cell it fell in. One operation, both effects.

One consequence follows for the other marks: a tiling means nothing to a bin that cuts one axis, because there the cells are intervals and an interval has no shape. So bar * bin(tiling = "hex") is refused, as What it refuses shows.

19.8.2 Hexagons and the shape of the panel

The hexagonal cells are not quite regular, meaning their six sides are not all the same length. They are regular in the space the mesh is cut in, because that cut gives both axes the same number of steps. The panel then stretches that space by whatever its own proportions are, so on a wide panel the cells are drawn slightly wide. Look closely at the hexagonal cells and you can see it.

This is what the original hexbin package exposes as its shape argument. A panel kept at equal proportions removes it. theme(ratio = 1) asks for that panel, and the hexagons are then regular.

19.9 Cells the data estimated: zone * density

Both meshes so far have counted the rows that fell in each cell. A count is exact, and when the table is small it is also ragged: the 150 flowers spread over a mesh would leave most cells holding one or two, or none.

bin counts what landed in each cell. There is a second thing worth measuring at a cell, and it needs no new mark either: how dense the points are there. That is density, the transform that gives a line its smooth curve. A zone reads it across both axes, for the reason it reads bin across both, and so paints the field that path traces as contours.

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) + zone * density +
  x(sepal_length) + y(petal_length) +
  title("Where the flowers cluster")
(data(iris_flowers) + zone * density +
  x(col.sepal_length) + y(col.petal_length) +
  title("Where the flowers cluster"))
data(iris_flowers) + zone * density + x(:sepal_length) +
  y(:petal_length) + title("Where the flowers cluster")
plot(data(iris_flowers), layer(zone, density), x(col.sepal_length),
  y(col.petal_length), title("Where the flowers cluster"))
4 6 8 0 2 4 6 8 Where the flowers cluster Petal Length Sepal Length Density 0.163 0.0816 0

“Given the iris flowers: zones derived by density, x is sepal length, y is petal length.”

Most of what the heatmap showed is true here too. Both axes were cut without being asked, the measurement went to color, and color(density) names it out loud and draws the identical plot. zone * bin and zone * density are one plot measured two ways, counted and estimated.

The white gaps are the one difference between the two plots. This plot has none. A count of zero says no row fell in that cell, so bin leaves it unpainted. An estimate exists at every point of the plane, so the estimator returns a value for every cell and the mesh is painted edge to edge. What it paints is a real density rather than an arbitrary color value. The cell values times the cell areas sum to one, the same property that makes the curve’s y a density.

palette() chooses the ramp, exactly as it does for a binned heatmap, and the argument for viridis on a count is the argument for it here:

data(iris_flowers) + zone * density +
  x(sepal_length) + y(petal_length) + palette("viridis") +
  title("Filled density, viridis")
(data(iris_flowers) + zone * density +
  x(col.sepal_length) + y(col.petal_length) + palette("viridis") +
  title("Filled density, viridis"))
data(iris_flowers) + zone * density + x(:sepal_length) +
  y(:petal_length) + palette("viridis") + title("Filled density, viridis")
plot(data(iris_flowers), layer(zone, density), x(col.sepal_length),
  y(col.petal_length), palette("viridis"),
  title("Filled density, viridis"))
4 6 8 0 2 4 6 8 Filled density, viridis Petal Length Sepal Length Density 0.163 0.0816 0

Look at the dark area away from the two clusters: it is even, and the cells meet with no gaps. gog draws each cell’s edges on whole screen pixels. Smoothed edges would leave thin lines of panel between the cells, and on a dark ramp they would look like a grid laid over the data.

One parameter a painted field takes is adjust, the multiplier on the bandwidth the estimator computed from the data. It is a multiplier rather than a width in either column’s units, which is why it is the one parameter that means the same thing on both axes:

data(iris_flowers) + zone * density(adjust = 1.6) +
  x(sepal_length) + y(petal_length) + palette("viridis") +
  title("The same flowers, smoothed more")
(data(iris_flowers) + zone * density(adjust = 1.6) +
  x(col.sepal_length) + y(col.petal_length) + palette("viridis") +
  title("The same flowers, smoothed more"))
data(iris_flowers) + zone * density(adjust = 1.6) + x(:sepal_length) +
  y(:petal_length) + palette("viridis") +
  title("The same flowers, smoothed more")
plot(data(iris_flowers), layer(zone, density({ adjust: 1.6 })),
  x(col.sepal_length), y(col.petal_length), palette("viridis"),
  title("The same flowers, smoothed more"))
4 6 8 0 5 10 The same flowers, smoothed more Petal Length Sepal Length Density 0.101 0.0505 0

“Given the iris flowers: zones derived by density adjusted by 1.6, x is sepal length, y is petal length.”

The same field traced rather than painted is path * density, the contour. The difference between them is the one between bar * bin and line * bin: the mark chooses the shape, and the transform stays the same.

19.10 Bands the data cut: zone * density(levels = )

A smooth ramp is hard to read a value from: two nearby cells differ by a shade the eye cannot name. Cut the field into a few steps and each band has a level a reader can point to. Ask for levels and the field stops being continuous. It is cut into that many steps, and a zone fills between them:

data(iris_flowers) + zone * density(levels = 8) +
  x(sepal_length) + y(petal_length) + palette("viridis") +
  title("Filled density bands")
(data(iris_flowers) + zone * density(levels = 8) +
  x(col.sepal_length) + y(col.petal_length) + palette("viridis") +
  title("Filled density bands"))
data(iris_flowers) + zone * density(levels = 8) + x(:sepal_length) +
  y(:petal_length) + palette("viridis") + title("Filled density bands")
plot(data(iris_flowers), layer(zone, density({ levels: 8 })),
  x(col.sepal_length), y(col.petal_length), palette("viridis"),
  title("Filled density bands"))
4 5 6 7 8 0 2 4 6 Filled density bands Petal Length Sepal Length Level 0.146 0.082 0.0182

“Given the iris flowers: zones derived by density at 8 levels, x is sepal length, y is petal length.”

That is the filled contour, and levels is the same parameter path takes. levels means one thing, cut the field into this many steps, and each mark then draws that cut its own way: a path traces the boundaries, a zone fills between them. So the two are not two features but one transform read by two marks, and a band’s edge falls exactly on the contour line:

data(iris_flowers) + path * density(levels = 8) +
  x(sepal_length) + y(petal_length) + palette("viridis") +
  title("The same eight levels, traced")
(data(iris_flowers) + path * density(levels = 8) +
  x(col.sepal_length) + y(col.petal_length) + palette("viridis") +
  title("The same eight levels, traced"))
data(iris_flowers) + path * density(levels = 8) + x(:sepal_length) +
  y(:petal_length) + palette("viridis") +
  title("The same eight levels, traced")
plot(data(iris_flowers), layer(path, density({ levels: 8 })),
  x(col.sepal_length), y(col.petal_length), palette("viridis"),
  title("The same eight levels, traced"))
4 5 6 7 8 0 2 4 6 The same eight levels, traced Petal Length Sepal Length Level 0.146 0.082 0.0182

“Given the iris flowers: paths derived by density at 8 levels, x is sepal length, y is petal length.”

That makes three readings of one transform: line * density is a curve, zone * density a painted field, and path * density a contour.

The bands lie one inside another. A denser region usually lies inside a less dense one, so gog paints the outermost band first and each inner band over it. A band can also have a hole. When points lie around a circle, the density in the middle is low, so each band has a hole there. gog cuts the hole out of the band, and the middle stays unpainted.

19.11 Cells the axes already made: the tile plot

So far the sides have been made in three ways: bounds named them, bin cut them, and density estimated them. The funnel already used a fourth way without naming it: a category’s slot, which needs no column at all.

Six weeks of daily orders is already a grid: weeks across, weekdays down, one count in each cell. The grid itself is the picture you want, and two categorical axes already divide the panel into its cells.

A categorical axis is already divided. Six weeks on the x axis is six slots. “Week 3” owns one of those slots, a place on the panel with a left edge and a right edge, and the axis fixes both when it lays out the categories. So a category bounds its own axis, and two categorical positions bound a zone completely, with no transform in the sentence:

six_weeks: first 5 of 42 rows
day orders weekday week
2024-03-01 20 Fri Week 1
2024-03-02 23 Sat Week 1
2024-03-03 25 Sun Week 1
2024-03-04 28 Mon Week 1
2024-03-05 30 Tue Week 1
data(six_weeks) + zone + x(week) + y(weekday) + color(orders) +
  title("Six weeks of orders, a day at a time")
(data(six_weeks) + zone + x(col.week) + y(col.weekday) + color(col.orders) +
  title("Six weeks of orders, a day at a time"))
data(six_weeks) + zone + x(:week) + y(:weekday) + color(:orders) +
  title("Six weeks of orders, a day at a time")
plot(data(six_weeks), zone, x(col.week), y(col.weekday),
  color(col.orders), title("Six weeks of orders, a day at a time"))
Week 1 Week 2 Week 3 Week 4 Week 5 Week 6 Sun Sat Fri Thu Wed Tue Mon Six weeks of orders, a day at a time Weekday Week Orders 34 23.5 13

“Given the six weeks table: zones, x is week, y is weekday, color by orders.”

That is a tile plot, here in the form called a calendar heatmap, and the whole of it is zone + x + y + color. What makes it worth drawing is that 42 daily counts carry two patterns at once. A list of numbers shows neither, while a grid separates them by direction. Read down a column for the week (week four is the pale one, the lowest week) and across a row for the weekday, where the Thursday row is the darkest overall and the Friday row the palest. The extremes are the cells where the two patterns agree: the darkest cell is Thursday of week six, and the palest are the Friday and Saturday of week four.

The rule underneath is short: a category owns a slot, a number is a point. A point has no width, so two continuous positions leave a zone with nothing to bound it until bin is written into the sentence. Nothing else changed, and nothing was added to the kernel: no mark, no transform, no channel.

19.11.1 bin cuts, count tallies

The calendar table has one row per cell already. Often a table does not: you hold the raw observations and want to know how many fell in each cell. The transform that tallies them is count, exactly as it is for a bar chart:

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

“Given the winds: zones derived by count, x is direction, y is season.”

That is the shape of a confusion matrix, the table that counts predicted classes against actual ones. A confusion matrix is the same sentence with different columns: zone * count + x(actual) + y(predicted).

Notice what was not needed there. bin cuts a continuous axis into cells before anything can be counted in them; a categorical axis arrives already cut, so there is nothing left to do but tally. bar * bin and bar * count follow the same rule on one axis; here both axes follow it. So each refusal names the other transform: give zone * count a continuous axis and it names bin, and give zone * bin two categorical axes and it names count.

A tile plot has two categorical positions where a bar chart has one. That second position changes only where the tally is written. A bar has a length to put it on, so bar * count writes its tally to the y axis. A tile plot’s two positions are both taken by categories, so zone * count writes its tally to color, and the legend is titled Count.

proportion is the same tally as a share of the whole, which is the plot to draw when the absolute numbers are not what you want to read:

data(winds) + zone * proportion + x(direction) + y(season) +
  palette("viridis") + title("The same observations, as shares")
(data(winds) + zone * proportion + x(col.direction) + y(col.season) +
  palette("viridis") + title("The same observations, as shares"))
data(winds) + zone * proportion + x(:direction) + y(:season) +
  palette("viridis") + title("The same observations, as shares")
plot(data(winds), layer(zone, proportion), x(col.direction),
  y(col.season), palette("viridis"),
  title("The same observations, as shares"))
N NE E SE S SW W NW Winter Summer The same observations, as shares Season Direction Proportion 0.144 0.0833 0.0227

“Given the winds: zones derived by proportion, x is direction, y is season.”

That share is of every row counted, not of a row or a column of the grid. A confusion matrix divided by the total of each true class needs a different denominator. proportion always divides by the whole table, so compute row shares in the host language and draw them as a tile plot.

proportion reads a cut mesh the same way. Compose it with bin and the heatmap’s counts come back as fractions of the whole, the relative-frequency histogram with a second axis:

data(gm_all) + zone * bin(20) * proportion + x(gdp, scale = "log") + y(life) +
  palette("viridis") + title("The heatmap, as shares of all 1704 rows")
(data(gm_all) + zone * bin(20) * proportion + x(col.gdp, scale = "log") + y(col.life) +
  palette("viridis") + title("The heatmap, as shares of all 1704 rows"))
data(gm_all) + zone * bin(20) * proportion + x(:gdp, scale = "log") +
  y(:life) + palette("viridis") +
  title("The heatmap, as shares of all 1704 rows")
plot(data(gm_all), layer(zone, bin(20), proportion),
  x(col.gdp, { scale: "log" }), y(col.life), palette("viridis"),
  title("The heatmap, as shares of all 1704 rows"))
1K 10K 100K 40 60 80 The heatmap, as shares of all 1704 rows Life Gdp Proportion 0.0264 0.0135 0.000587

proportion leaves the cells exactly where bin cut them, and the legend now reads in shares: the fullest one holds about three percent of the 1704 rows.

19.11.2 count tallies rows, mean reduces a column

The wind table records more than which cell each observation fell in. Each row also carries a speed, and the question “how fast, on average, in each cell?” is not a tally at all; it is a summary of one column.

The first sentence to try is a plain tile plot, and it is refused. A zone draws one opaque rectangle for each row, and here many rows share each cell. Their rectangles would be drawn one on top of another, so each cell would show only the color of its last row. gog counts the rows that would be hidden, and names the summary to write instead:

data(winds) + zone + x(direction) + y(season) + color(speed)
data(winds) + zone + x(col.direction) + y(col.season) + color(col.speed)
data(winds) + zone + x(:direction) + y(:season) + color(:speed)
plot(data(winds), zone, x(col.direction), y(col.season), color(col.speed))
Error:
! gog: a `zone` draws one opaque rectangle per row, and 16 of its 16 cells hold rows with different `color(speed)`, so each of those cells would show only its last row, and 248 rows would be hidden. For one value per cell, summarize the rows: `zone * mean + color(speed)` draws each cell's mean, and `median`, `sum`, `max` and `min` do the same.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

zone * mean draws one rectangle for each cell, colored by the mean speed of its rows:

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

“Given the winds: zones derived by mean, x is direction, y is season, color by speed.”

Compare that with zone * count in the section above. The sentence has the same shape, with mean for count and one more binding. count was handed no column, so it counts rows and labels the result count. mean was handed one, and color(speed) says which column it reduced. All six summaries read a mesh this way (sum, mean, median, max, min and quantile), and each writes its answer back into the column it reduced.

The first heatmap said that the measurement goes to color when both positions are taken. The rule has a second half: color is also the channel that names the column, because a summary writes its result back into the column it read. On an ordinary two-axis bar chart, bar * mean + x(continent) + y(life) names life with y and puts the mean back on y; the source and the destination are one binding. A zone measures by color, so color is both the source and the destination.

The whole rule is short. A summary reduces the column on the channel that carries the measurement: y on a bar, color on a zone. It groups by every position that does not carry it. A bar measures with y, so it groups by x. A zone measures with nothing positional, so it groups by both. In the cube a bar measures with z, so it groups by the pair as well.

A summary composes with proportion too: each cell’s summary becomes a share of the total over all cells. The titanic table already holds counts: each row is a group of people aboard, and n is the size of the group. So sum adds up the people in each cell, and proportion turns each sum into a share of everyone aboard. Counting rows would be wrong here: zone * proportion gives every cell 0.125, because each cell holds 4 of the table’s 32 rows.

titanic: first 5 of 32 rows
class sex age survived n
1st Male Adult Yes 57
1st Male Adult No 118
1st Male Child Yes 5
1st Male Child No 0
1st Female Adult Yes 140
data(titanic) + zone * sum * proportion + x(class) + y(survived) + color(n) +
  title("Everyone aboard, as shares: class by survival")
(data(titanic) + zone * sum * proportion + x(col["class"]) + y(col.survived) + color(col.n) +
  title("Everyone aboard, as shares: class by survival"))
data(titanic) + zone * sum * proportion + x(:class) + y(:survived) +
  color(:n) + title("Everyone aboard, as shares: class by survival")
plot(data(titanic), layer(zone, sum, proportion), x(col.class),
  y(col.survived), color(col.n),
  title("Everyone aboard, as shares: class by survival"))
1st 2nd 3rd Crew No Yes Everyone aboard, as shares: class by survival Survived Class Proportion 0.306 0.18 0.0536

“Given titanic: zones derived by sum and proportion, x is class, y is survived, color by n.”

The legend is titled Proportion, because its numbers are now shares of everyone aboard. color(n) still names the column that sum adds up. The darkest cell holds the crew who did not survive: 0.306, about three in ten of everyone aboard.

Say mean with nothing for it to reduce and the engine asks for the column rather than guessing, as What it refuses shows.

A summary also needs cells to summarize into. The six summaries measure without cutting, so each axis must arrive already divided. What it refuses shows the refusal, which names both fixes.

19.11.3 Cells cut, then summarized: the summary heatmap

The last two sections let the axes supply the cells. This section and the next put bin back in the sentence. How does life expectancy depend on income and on time together? Both are continuous, so neither axis arrives divided into cells. bin cuts them, and mean measures inside each:

data(gm_all) + zone * bin(15) * mean + x(year) + y(gdp, scale = "log") +
  color(life) + title("Mean life expectancy, by era and income")
(data(gm_all) + zone * bin(15) * mean + x(col.year) + y(col.gdp, scale = "log") +
  color(col.life) + title("Mean life expectancy, by era and income"))
data(gm_all) + zone * bin(15) * mean + x(:year) + y(:gdp, scale = "log") +
  color(:life) + title("Mean life expectancy, by era and income")
plot(data(gm_all), layer(zone, bin(15), mean), x(col.year),
  y(col.gdp, { scale: "log" }), color(col.life),
  title("Mean life expectancy, by era and income"))
1960 1980 2000 1K 10K 100K Mean life expectancy, by era and income Gdp Year Life 79.97 58.32 36.67

bin(15) cuts the plane into cells, mean reduces life inside each, and color both names that column and carries the answer, exactly as it does on the tile plot above. Read the panel from bottom left to top right and you can see both patterns. Life expectancy rises with income up the panel, and rises again with time across it.

bin(15) cuts the same cells with or without mean. What changes is that it stops reporting how many rows fell in each, because mean now supplies the measurement. So the summary heatmap and zone * bin(15) + x(year) + y(gdp, scale = "log") tile the panel identically. Read them against each other: the count says how many observations fell in each cell, and the mean says what those observations were like.

The same composition works when one axis is a category. Cut the income axis, and leave the continents in their slots:

data(gm_all) + zone * bin(12) * mean + x(gdp, scale = "log") + y(continent) +
  color(life) + title("Mean life expectancy, by income band and continent")
(data(gm_all) + zone * bin(12) * mean + x(col.gdp, scale = "log") + y(col.continent) +
  color(col.life) + title("Mean life expectancy, by income band and continent"))
data(gm_all) + zone * bin(12) * mean + x(:gdp, scale = "log") +
  y(:continent) + color(:life) +
  title("Mean life expectancy, by income band and continent")
plot(data(gm_all), layer(zone, bin(12), mean),
  x(col.gdp, { scale: "log" }), y(col.continent), color(col.life),
  title("Mean life expectancy, by income band and continent"))
1K 10K 100K Oceania Americas Africa Europe Asia Mean life expectancy, by income band and continent Continent Gdp Life 80.16 59.9 39.63

One axis was cut and the other arrived in slots already, which is the mixed mesh the next section states as a rule.

19.11.4 One of each: the mixed mesh

How does the spread of life expectancy differ by continent? Five histograms answer it, in five panels the reader must compare one by one. Read the rule one axis at a time and it already covers the case in the middle: one axis continuous, one categorical. Nothing new has to be decided for it. bin cuts the axis that has a width to cut, and not the one that arrived already cut, so the result is a row of cells per category:

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

That is a distribution shown as shade rather than as height, which is what makes five of them fit in the space one histogram would take. Read a row and it is one continent’s histogram. Read the plot and you see the shape of each continent’s spread. Africa’s mass is at the far left and Europe’s at the far right. Asia spreads the widest and Europe the narrowest of the four large rows, with Africa and the Americas in between. Oceania has only two countries in this table, which is why its row is the shortest.

Two things make it a mesh and not five plots side by side. Every row is cut on the same edges: the layout is computed once from the whole life column, never per category. So each column of cells covers the same range of life expectancy for every continent, and the boundaries line up all the way down. And an empty cell stays empty. Oceania’s row starts where its data starts, rather than being painted the palest end of the ramp. The uneven left edge shows where each continent’s values begin. Painting the empty cells would suggest life expectancies as low as 25 in Oceania, where the lowest is 69.

Swap the axes and each distribution becomes a column instead of a row:

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

Nothing was added for either. Three cases, one rule read one axis at a time: bin cuts both axes in the heatmap and one in this plot, and the tile plot needs no bin at all.

19.11.5 One slot: a column highlight

The wind comes from two of the eight directions most often. A strip plot of wind speeds should shade those two directions. Reading the rule one axis at a time leaves one more case: a category on one axis and nothing on the other. A zone bounded on one categorical axis takes that slot and spans the panel on the other, which is what this mark does whenever an axis is left unsaid. One row is still one rectangle, so the table you give it is the list of slots to shade:

prevailing_winds: all 2 rows
direction
SW
W
data(winds) + point * jitter + x(direction) + y(speed) +
  data(prevailing_winds) + zone + x(direction) + style(color = "goldenrod") +
  data(winds) + point * jitter + x(direction) + y(speed) +
  y_label("Speed") + title("The two directions the wind actually comes from")
(data(winds) + point * jitter + x(col.direction) + y(col.speed) +
  data(prevailing_winds) + zone + x(col.direction) + style(color = "goldenrod") +
  data(winds) + point * jitter + x(col.direction) + y(col.speed) +
  y_label("Speed") + title("The two directions the wind actually comes from"))
data(winds) + point * jitter + x(:direction) + y(:speed) +
  data(prevailing_winds) + zone + x(:direction) +
  style(color = "goldenrod") + data(winds) + point * jitter +
  x(:direction) + y(:speed) + y_label("Speed") +
  title("The two directions the wind actually comes from")
plot(data(winds), layer(point, jitter), x(col.direction), y(col.speed),
  data(prevailing_winds), zone, x(col.direction),
  style({ color: "goldenrod" }), data(winds), layer(point, jitter),
  x(col.direction), y(col.speed), y_label("Speed"),
  title("The two directions the wind actually comes from"))
N NE E SE S SW W NW 5 10 15 20 25 The two directions the wind actually comes from Speed Direction

“Given the winds: points derived by jitter, x is direction, y is speed; then given the prevailing winds: zones, x is direction; then given the winds: points derived by jitter, x is direction, y is speed.”

Nothing was built for that. It is the same sentence as the tile plot with one axis left unsaid, which shows that a slot is a general way to get sides, not a special case for one chart.

The two-row table does what rule’s table does, and for the same reason: a zone’s position is a column, so one table shades as many slots as it has rows. Hand it the whole winds table instead and you get one rectangle per observation, hundreds of them in translucent gold, stacked into eight solid columns. That is the rule behaving exactly as stated, and not the plot you wanted.

One difference is easy to see. The column highlight is translucent and the calendar heatmap is not. A zone that spans the panel on one axis is a highlight, and gog draws it at 20%. A zone whose cells come from the data on both axes is the data itself, and gog draws it opaque. style(opacity = ) overrides either.

19.12 Sides the data drew: the choropleth

In every section above, each drawn shape takes its sides from one row: a row of your table, or a row a transform made. bounds names the sides, bin cuts them, density estimates them, and a category owns a slot. There is a fifth way, where one shape takes its outline from many rows.

A map with every country shaded is a plot most readers have seen, and a country’s shape is not a rectangle. A boundary is a list of points around the edge of a region: a coastline, a border, the outline of a sales territory. It arrives as one row per point, so a single shape needs many rows, and the sentence needs a word for which rows belong together. That word is group:

world_borders: first 5 of 4150 rows
lon lat country continent piece
66.519 37.363 Afghanistan Asia p001
69.196 37.151 Afghanistan Asia p001
70.807 38.486 Afghanistan Asia p001
71.845 36.738 Afghanistan Asia p001
73.260 37.495 Afghanistan Asia p001
data(world_borders) + zone + x(lon) + y(lat) + group(country) +
  color(continent) + map()
(data(world_borders) + zone + x(col.lon) + y(col.lat) + group(col.country) +
  color(col.continent) + map())
data(world_borders) + zone + x(:lon) + y(:lat) + group(:country) +
  color(:continent) + map()
plot(data(world_borders), zone, x(col.lon), y(col.lat),
  group(col.country), color(col.continent), map())
-100° 0° 100° -50° 0° 50° Lat Lon Continent Asia Europe Africa South America Oceania North America Seven seas (open ocean)

“Given the world borders: zones, x is lon, y is lat, grouped by country, color by continent, in the map space.”

That is the choropleth, and it is drawn in the map space, where the two positions are longitude and latitude. The mark is the same zone, and the kernel needs no new atom. A zone is usually a rectangle, but its shape comes from whatever gives its sides, and here a boundary gives them.

19.13 What you can set

A highlight has to stay in the background, and a heatmap cell has to be solid. gog gives each kind its own default, and a value in style() overrides it for the whole layer. Each mark takes its own settings. These are a zone’s, with the values each accepts:

Setting Value
style(color = ) any CSS color name or hex
style(opacity = ) 0 to 1
style(pattern = ) solid, hatch, crosshatch, stripes, grid, dots
style(border_color = ) any CSS color name or hex
style(border_size = ) pixels

A zone is a filled region, so those five are exactly what a fill takes, and no more. One of them has a default that matters. opacity is 0.2 for a placed zone: one whose sides you named with bounds, or one that spans the panel on an axis. It is 1 when the data gives every side: cells bin cut, cells density estimated, the slots two categories own, and the regions a boundary drew. A placed zone is a highlight. It stays pale so the gridlines, and any layer written before it, show through. A zone of the second kind is the data, with nothing behind it to reveal.

The next plot draws three bands behind the sales of the quarterly table, shown in A band across the panel. Each band, from a table of its own, adds one setting: a color, then an opacity, then a hatch:

span_early: its one row
start end
2006 2009
span_middle: its one row
start end
2011 2014
span_late: its one row
start end
2017 2020
data(quarterly) + x(year) + y(sales) +
  data(span_early) + zone * bounds(start = start, end = end) +
    style(color = "seagreen") +
  data(span_middle) + zone * bounds(start = start, end = end) +
    style(color = "indianred", opacity = 0.55) +
  data(span_late) + zone * bounds(start = start, end = end) +
    style(color = "steelblue", pattern = "hatch", opacity = 0.7) +
  data(quarterly) + line + point +
  y_label("Sales") + title("Color, then opacity, then a hatch")
(data(quarterly) + x(col.year) + y(col.sales) +
  data(span_early) + zone * bounds(start=col.start, end=col.end) +
    style(color = "seagreen") +
  data(span_middle) + zone * bounds(start=col.start, end=col.end) +
    style(color = "indianred", opacity = 0.55) +
  data(span_late) + zone * bounds(start=col.start, end=col.end) +
    style(color = "steelblue", pattern = "hatch", opacity = 0.7) +
  data(quarterly) + line + point +
  y_label("Sales") + title("Color, then opacity, then a hatch"))
data(quarterly) + x(:year) + y(:sales) + data(span_early) +
  zone * bounds(start = :start, var"end" = :end) +
  style(color = "seagreen") + data(span_middle) +
  zone * bounds(start = :start, var"end" = :end) +
  style(color = "indianred", opacity = 0.55) + data(span_late) +
  zone * bounds(start = :start, var"end" = :end) +
  style(color = "steelblue", pattern = "hatch", opacity = 0.7) +
  data(quarterly) + line + point + y_label("Sales") +
  title("Color, then opacity, then a hatch")
plot(data(quarterly), x(col.year), y(col.sales), data(span_early),
  layer(zone, bounds({ start: col.start, end: col.end })),
  style({ color: "seagreen" }), data(span_middle),
  layer(zone, bounds({ start: col.start, end: col.end })),
  style({ color: "indianred", opacity: 0.55 }), data(span_late),
  layer(zone, bounds({ start: col.start, end: col.end })),
  style({ color: "steelblue", pattern: "hatch", opacity: 0.7 }),
  data(quarterly), line, point, y_label("Sales"),
  title("Color, then opacity, then a hatch"))
2005 2010 2015 2020 100 120 140 160 180 Color, then opacity, then a hatch Sales Year

“Given quarterly: x is year, y is sales, an early zone from start to end colored seagreen, and also a middle zone from start to end colored indianred, with opacity 0.55, and also a late zone from start to end colored steelblue, with pattern hatch and opacity 0.7, and also a line and points.”

The default opacity keeps the first band pale with nothing set, so the line and points stay the strongest marks on the page. Raise it when the zone is the subject of the plot rather than its background, as the red band does here.

Use the hatch when a plot will be printed in gray, or when two overlapping zones need more than color to separate them. Give it more opacity than a flat fill needs, as the blue band does: a hatch is a set of lines with gaps between them, so fading it to 0.2 leaves very little to see. It takes the fill textures, not a stroke’s dashes; style(pattern = "dashed") on a zone is refused, pointing at the six fill textures instead.

One setting you might expect is refused: style(size = ), because a zone’s extent is its bounds and a size would be a second, contradictory answer to how big it is.

There are two good answers for a zone’s border, and both are available. A highlight behind a line reads better with no border. If you want its edges drawn, two rules do it better than a border: they can be dashed, and each is at a real value on the axis. The target band comes from the target_band table, shown in A band across the panel. The target_edges table holds its two edges:

target_edges: all 2 rows
sales
150
175
data(quarterly) + x(year) + y(sales) +
  data(target_band) + zone * bounds(lower, upper) + style(color = "seagreen") +
  data(target_edges) + rule + style(color = "seagreen", pattern = "dashed") +
  data(quarterly) + line + point +
  y_label("Sales") + title("A zone for the region, rules for its edges")
(data(quarterly) + x(col.year) + y(col.sales) +
  data(target_band) + zone * bounds(col.lower, col.upper) + style(color = "seagreen") +
  data(target_edges) + rule + style(color = "seagreen", pattern = "dashed") +
  data(quarterly) + line + point +
  y_label("Sales") + title("A zone for the region, rules for its edges"))
data(quarterly) + x(:year) + y(:sales) + data(target_band) +
  zone * bounds(:lower, :upper) + style(color = "seagreen") +
  data(target_edges) + rule +
  style(color = "seagreen", pattern = "dashed") + data(quarterly) + line +
  point + y_label("Sales") +
  title("A zone for the region, rules for its edges")
plot(data(quarterly), x(col.year), y(col.sales), data(target_band),
  layer(zone, bounds(col.lower, col.upper)), style({ color: "seagreen" }),
  data(target_edges), rule,
  style({ color: "seagreen", pattern: "dashed" }), data(quarterly), line,
  point, y_label("Sales"),
  title("A zone for the region, rules for its edges"))
2005 2010 2015 2020 100 120 140 160 180 A zone for the region, rules for its edges Sales Year

“Given quarterly: x is year, y is sales, a target zone from lower to upper, and also rules from the edges, and also a line and points.”

The two marks read their positions differently, which is why the edge table is derived rather than reused. A zone is told which columns hold its sides, with bounds(lower, upper). A rule reads whichever of the plot’s own position columns its table carries, so the edges must arrive as a sales column. target_edges is built from target_band, so the edges move when the band moves.

But the mark is not only a highlight. It draws the waterfall, the heatmap’s cells, the icicle and the mosaic, and in every one of those the zone is the data rather than a background behind it. There the composition above cannot help. A rule spans the whole panel, or makes a short tick at its edge, so it cannot outline one cell of a mosaic. So a zone takes style(border_color = , border_size = ), like bar, box, point and surface. The plot outlines the two slumps of the recessions table, shown in A band up the panel:

data(quarterly) + x(year) + y(sales) +
  data(recessions) + zone * bounds(start = start, end = end) +
    style(border_color = "black") +
  data(quarterly) + line +
  y_label("Sales") + title("A zone with a border of its own")
(data(quarterly) + x(col.year) + y(col.sales) +
  data(recessions) + zone * bounds(start=col.start, end=col.end) +
    style(border_color = "black") +
  data(quarterly) + line +
  y_label("Sales") + title("A zone with a border of its own"))
data(quarterly) + x(:year) + y(:sales) + data(recessions) +
  zone * bounds(start = :start, var"end" = :end) +
  style(border_color = "black") + data(quarterly) + line +
  y_label("Sales") + title("A zone with a border of its own")
plot(data(quarterly), x(col.year), y(col.sales), data(recessions),
  layer(zone, bounds({ start: col.start, end: col.end })),
  style({ border_color: "black" }), data(quarterly), line,
  y_label("Sales"), title("A zone with a border of its own"))
2005 2010 2015 2020 100 120 140 160 180 A zone with a border of its own Sales Year

“Given quarterly: x is year, y is sales, recession zones from start to end, with border color black, and also a line.”

Both choices stay available, and which one is right depends on whether the zone is the background or the subject. By default a zone has no border: with no border_color and no border_size, it draws no outline.

The grid of every mark and every setting shows which other marks share this list. What a mark maps rather than sets is its row on the companion grid.

19.14 What it refuses

A point or a line draws from the plot’s own x and y, so a bare zone over the same table is the natural first attempt. Two continuous positions give a rectangle no width and no height, so it is refused:

data(quarterly) + x(year) + y(sales) + zone + line
data(quarterly) + x(col.year) + y(col.sales) + zone + line
data(quarterly) + x(:year) + y(:sales) + zone + line
plot(data(quarterly), x(col.year), y(col.sales), zone, line)
Error:
! gog: `zone` shades a rectangle, but nothing here says where its sides are. Five things can: a categorical position, whose category owns a slot — `zone + x(method) + y(dataset) + color(score)` fills every cell where two categories cross; `bounds`, which names the sides from columns you hold — `zone * bounds(lo, hi)` on the measure axis, `zone * bounds(start = a, end = b)` on the domain axis, all four for a box, and the axis you leave out spans the panel; `bin`, which cuts them out of two continuous axes and counts the rows in each cell; `density`, which cuts the same cells and estimates a value at each; and a boundary, many rows around one region, named by `group` in `map()` or `globe()` — `zone + x(lon) + y(lat) + group(<region>) + map()`. A continuous position on its own is none of them — a number is a point, and a point has no width.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

A category on one axis and a number on the other do not make a cell either. The category bounds its own axis, but the number gives the zone no width on the other axis. Each zone would span the whole panel, and the number would be dropped. gog refuses the sentence, and the message names three ways to fix it. Cut the number into ranges with zone * bin * mean, as in the summary heatmap. Or declare the column’s categories before plotting. A cell that then holds more than one row needs zone * mean, or the rows in one cell are drawn on top of each other, and you see only the last. Or leave the position out of the zone, so each slot spans the panel, as in the column highlight:

data(gm_all) + zone + x(year) + y(continent) + color(life)
data(gm_all) + zone + x(col.year) + y(col.continent) + color(col.life)
data(gm_all) + zone + x(:year) + y(:continent) + color(:life)
plot(data(gm_all), zone, x(col.year), y(col.continent), color(col.life))
Error:
! gog: `x(year)` is a number, and on a `zone` a number is a point with no width, so it gives the zone no sides on `x`: each zone would span the panel and `year` would be dropped. To cut `year` into ranges, write `zone * bin * mean + x(year) + color(<column>)`, which colors each cell by a mean. For one cell per value, store `year` as text or categories before plotting, and write `zone * mean` if a cell would hold more than one row. To shade each `continent` slot across the whole panel, leave `x` out of the zone: a position written before the marks reaches every layer, so if another layer needs `x(year)`, write it after that layer's mark.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

One column is not a pair. The refusal names the column you gave, because a missing half is nearly always a typo:

data(quarterly) + x(year) + y(sales) +
  data(target_band) + zone * bounds(lower = lower) +
  line
(data(quarterly) + x(col.year) + y(col.sales) +
  data(target_band) + zone * bounds(lower=col.lower) +
  line)
data(quarterly) + x(:year) + y(:sales) + data(target_band) +
  zone * bounds(lower = :lower) + line
plot(data(quarterly), x(col.year), y(col.sales), data(target_band),
  layer(zone, bounds({ lower: col.lower })), line)
Error:
! gog: `zone` was given `lower` but not the other half of any pair. A rectangle needs both ends of a side: `bounds(lower, upper)`, `bounds(start, end)`, or all four.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The start/end pair belongs to a rectangle and to nothing else. A ribbon spans the measure axis at each position along the domain, so it has no extent along the domain to bound:

slump: its one row
year sales start end
2008 100 2007.5 2009.5
data(slump) + x(year) + y(sales) +
  ribbon * bounds(start = start, end = end)
(data(slump) + x(col.year) + y(col.sales) +
  ribbon * bounds(start=col.start, end=col.end))
data(slump) + x(:year) + y(:sales) +
  ribbon * bounds(start = :start, var"end" = :end)
plot(data(slump), x(col.year), y(col.sales),
  layer(ribbon, bounds({ start: col.start, end: col.end })))
Error:
! gog: `bounds(start, end)` bounds a rectangle along the domain axis, and a `ribbon` has no extent there — it spans the measure axis at each position. Keep `bounds(lower, upper)`, or use `zone` to shade a rectangle.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

On a categorical axis, a number in a pair is a position among the slots: 0 is the center of the first category’s slot, 1 of the second, and so on. The waterfall’s left and right are numbers of this kind. Write the waterfall’s base and top in the start/end pair instead. Most of its blocks would then be far past the sixth slot, outside the panel, so gog refuses the sentence:

data(w) + zone * bounds(start = base, end = top) + x(step)
data(w) + zone * bounds(start=col.base, end=col.top) + x(col.step)
data(w) + zone * bounds(start = :base, var"end" = :top) + x(:step)
plot(data(w), layer(zone, bounds({ start: col.base, end: col.top })),
  x(col.step))
Error:
! gog: `bounds(start = base, end = top)` places the zone on `x`, and `x` holds the 6 categories of `step`, so the pair's numbers are slot positions: 0 is the first category's slot and 5 the last's. 4 of its 6 rows lie outside every slot (the first of them runs from 120 to 165), so those rectangles would be drawn off the panel. If `base` and `top` are values on `y`, they bound the measure axis: write `bounds(base, top)`. If they are slot positions, keep them between -0.5 and 5.5, the outer edges of the first and the last slot.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The message suggests the other pair, bounds(base, top), which bounds the measure axis.

A zone needs two answers: where its cells are, and what is in each. bounds answers only where. count answers only what, inside cells your categories already made. density answers both. bin answers both, and it can also answer only where, which is how bin * mean works. proportion gives the tally as shares of the whole. Two transforms that give the same answer contradict each other. A summary such as mean answers what, inside the cells the positions make, but a rectangle whose sides you named is one row on its own, so there are no cells for it to group rows into:

data(quarterly) + x(year) + y(sales) +
  data(target_band) + zone * bounds(lower, upper) * mean +
  line
(data(quarterly) + x(col.year) + y(col.sales) +
  data(target_band) + zone * bounds(col.lower, col.upper) * mean +
  line)
data(quarterly) + x(:year) + y(:sales) + data(target_band) +
  zone * bounds(:lower, :upper) * mean + line
plot(data(quarterly), x(col.year), y(col.sales), data(target_band),
  layer(zone, bounds(col.lower, col.upper), mean), line)
Error:
! gog: `zone * bounds * mean` says what this rectangle is twice — `bounds` names its sides from columns you hold, one rectangle per row, and `mean` summarizes a column within the cells your *positions* make. Keep whichever you meant: `zone * bounds(...)` to shade a region you chose, or `zone * mean + x(<a>) + y(<b>) + color(<column>)` to summarize one within every cell two categories cross. To shade a band the data computed, `ribbon * range` is the mark that spans a statistic.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

To shade a region the data computed rather than one you chose, use ribbon, the mark that fills between two computed values: ribbon * range fills between the minimum and maximum at each x. A zone shades where you say; a ribbon shades what the data says.

Asking for two measurements is also a contradiction. count writes one measurement and mean writes another, but a cell holds a single number. count cuts nothing, so with mean measuring the cells there is nothing left for it to do:

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

bin * mean in the summary heatmap has the same shape and is not a contradiction: bin supplies the cells, and mean supplies the measurement inside them.

Say mean with no column to reduce and gog asks for one rather than guessing. A zone measures by color, so color is the binding the refusal names:

data(winds) + zone * mean + x(direction) + y(season)
data(winds) + zone * mean + x(col.direction) + y(col.season)
data(winds) + zone * mean + x(:direction) + y(:season)
plot(data(winds), layer(zone, mean), x(col.direction), y(col.season))
Error:
! gog: `zone * mean` reduces a column within each cell, but nothing says which column — `zone` measures by `color`, and no `color()` is bound. Name it: `zone * mean + x(<a>) + y(<b>) + color(<column>)`. To count the rows in each cell instead of reducing a column, `count` needs no such binding: `zone * count + x(<a>) + y(<b>)`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

Naming the sides and cutting them in one sentence is refused, not settled by picking one, because the named sides and the cut cells would not agree:

banded: its one row
gdp life lower upper
1000 50 45 55
data(banded) + x(gdp) + y(life) +
  zone * bounds(lower, upper) * bin
(data(banded) + x(col.gdp) + y(col.life) +
  zone * bounds(col.lower, col.upper) * bin)
data(banded) + x(:gdp) + y(:life) + zone * bounds(:lower, :upper) * bin
plot(data(banded), x(col.gdp), y(col.life),
  layer(zone, bounds(col.lower, col.upper), bin))
Error:
! gog: `zone * bounds * bin` says where the sides are twice — `bounds` names them from columns you have, `bin` cuts them from the data. Keep whichever you meant: `zone * bounds(...)` to shade a rectangle you chose, `zone * bin` to tile the panel with measured cells.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

A bin needs something to cut, and two categorical axes leave it nothing: each category is one slot, and a slot cannot be cut smaller. The refusal goes further: a cell per pair of categories is the tile plot, which does no binning at all, so it names the transform that tallies into cells you already have:

data(six_weeks) + zone * bin + x(weekday) + y(week)
data(six_weeks) + zone * bin + x(col.weekday) + y(col.week)
data(six_weeks) + zone * bin + x(:weekday) + y(:week)
plot(data(six_weeks), layer(zone, bin), x(col.weekday), y(col.week))
Error:
! gog: `zone * bin` cuts an axis into cells, and there is no axis here it can cut — `x(weekday)` and `y(week)` are both categorical. A category is one slot, with no width to cut. To tally rows into the cells two categorical axes already make, `count` is the transform that does it: `zone * count + x(<a>) + y(<b>)` draws a cell per pair, colored by how many rows fell there. (With *one* categorical axis `zone * bin` draws the mixed mesh — the continuous axis cut into cells, one row of them per category.)
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

One categorical axis is allowed, and it draws. That is the mixed mesh. density differs from bin here, and the reason is the estimate itself. A field is estimated between the data points, so it needs somewhere to spread on both axes. A density per category would be normalized inside each slot on its own, which makes its cells incomparable across slots while the color ramp claims otherwise. A density per category is a different plot, so a field asks for two continuous axes and names what to use instead:

data(gm_all) + zone * density + x(life) + y(continent)
data(gm_all) + zone * density + x(col.life) + y(col.continent)
data(gm_all) + zone * density + x(:life) + y(:continent)
plot(data(gm_all), layer(zone, density), x(col.life), y(col.continent))
Error:
! gog: `zone * density` estimates a density over the *plane*, and `y(continent)` is categorical — a category is one slot, with no interval for the estimate to spread along. Both axes must be continuous. To compare one continuous distribution across categories, `line * density + color(continent)` draws a curve per group.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

A tiling is refused for a reason of its own, and that reason is distance. A hexagon is regular only when a step up and a step across can be compared, so hex needs a distance on both axes. A category’s slots have an order but no distance, so a mixed mesh always has rectangular cells:

data(gm_all) + zone * bin(tiling = "hex") + x(life) + y(continent)
data(gm_all) + zone * bin(tiling = "hex") + x(col.life) + y(col.continent)
data(gm_all) + zone * bin(tiling = "hex") + x(:life) + y(:continent)
plot(data(gm_all), layer(zone, bin({ tiling: "hex" })), x(col.life),
  y(col.continent))
Error:
! gog: `bin(tiling = "hex")` partitions a *plane*, and `y` here is categorical — its slots are an order, not a distance, so there is nothing for a hexagon to be regular against. This plot is the mixed mesh: one axis cut into cells, one row of them per category, and its cells are rectangles by construction. Drop the tiling, or bind both axes to numbers for `bin(tiling = "hex")` to have a plane to cut.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

When bin cuts only one axis, a tiling has nothing to shape. bar * bin cuts intervals, and an interval has no shape, so the refusal names zone as the mark a tiling belongs to:

data(gm_all) + bar * bin(tiling = "hex") + x(life)
data(gm_all) + bar * bin(tiling = "hex") + x(col.life)
data(gm_all) + bar * bin(tiling = "hex") + x(:life)
plot(data(gm_all), layer(bar, bin({ tiling: "hex" })), x(col.life))
Error:
! gog: `bin(tiling = )` says how to divide a *plane*, and a `bar` bins one axis — its cells are intervals, and an interval has no shape. Drop the tiling for a histogram, or use `zone * bin(tiling = "hex")` to cut both axes into cells and color each by its count.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The opposite case, count over continuous axes, is refused by the same rule. A tally needs cells that exist; a number is a point and owns none, so the refusal names bin:

data(gm_all) + zone * count + x(gdp) + y(life)
data(gm_all) + zone * count + x(col.gdp) + y(col.life)
data(gm_all) + zone * count + x(:gdp) + y(:life)
plot(data(gm_all), layer(zone, count), x(col.gdp), y(col.life))
Error:
! gog: `zone * count` tallies rows into the cells that two *categorical* axes already make, and `x(gdp)` and `y(life)` are both continuous — a number is a point, and a point owns no cell. To cut a continuous axis into cells first, `bin` is the transform that does it: `zone * bin` counts the rows in each, which is the heatmap.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The six summaries follow the same rule. mean reduces a column inside a cell, and a continuous position gives it no cell. The refusal names both fixes: a category on each position, or bin on the continuous axis first:

data(winds) + zone * mean + x(speed) + y(season) + color(bearing)
data(winds) + zone * mean + x(col.speed) + y(col.season) + color(col.bearing)
data(winds) + zone * mean + x(:speed) + y(:season) + color(:bearing)
plot(data(winds), layer(zone, mean), x(col.speed), y(col.season),
  color(col.bearing))
Error:
! gog: `zone * mean` summarizes `bearing` inside the cell each pair of categories owns, and `x(speed)` carries numbers — a number is a point, so it owns no cell to summarize into. Either put a category on both positions, or cut the numeric axis into cells first: `zone * bin * mean + x(<a>) + y(<b>) + color(bearing)` bins where your data lives and means `bearing` inside each cell.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

And the cells’ measure is already the count, so coloring by anything else asks for a column that binning has replaced:

data(gm_all) + zone * bin + x(gdp) + y(life) + color(continent)
data(gm_all) + zone * bin + x(col.gdp) + y(col.life) + color(col.continent)
data(gm_all) + zone * bin + x(:gdp) + y(:life) + color(:continent)
plot(data(gm_all), layer(zone, bin), x(col.gdp), y(col.life),
  color(col.continent))
Error:
! gog: `zone * bin` already measures each cell by how many rows fell in it, and color is where that measurement goes — so `color(continent)` has nothing to read: the transform replaced those rows with its own. Drop the binding and the measurement colors the cells, or say `color(count)` to name it out loud. To compare across a category, facet on it: `| continent`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

A tiling gog does not offer is refused with the list of the ones it does, rather than quietly drawing rectangles instead:

data(gm_all) + zone * bin(tiling = "octagon") + x(gdp) + y(life)
data(gm_all) + zone * bin(tiling = "octagon") + x(col.gdp) + y(col.life)
data(gm_all) + zone * bin(tiling = "octagon") + x(:gdp) + y(:life)
plot(data(gm_all), layer(zone, bin({ tiling: "octagon" })), x(col.gdp),
  y(col.life))
Error:
! gog: `octagon` is not a tiling. `bin(tiling = )` takes `"rect"` or `"hex"`. `"rect"` cuts equal-interval cells on each axis; `"hex"` staggers alternate rows, which stops the eye reading the mesh's own alignment as structure in the data.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

density’s bandwidth is refused here for a reason of its own. It is a width in one column’s own units, and a field spreads over two columns measuring different quantities, so one number cannot be a width in both. That is why adjust is the parameter the density section uses: a multiplier means the same thing on either axis.

data(iris_flowers) + zone * density(bandwidth = 0.5) +
  x(sepal_length) + y(petal_length)
(data(iris_flowers) + zone * density(bandwidth = 0.5) +
  x(col.sepal_length) + y(col.petal_length))
data(iris_flowers) + zone * density(bandwidth = 0.5) + x(:sepal_length) +
  y(:petal_length)
plot(data(iris_flowers), layer(zone, density({ bandwidth: 0.5 })),
  x(col.sepal_length), y(col.petal_length))
Error:
! gog: `density(bandwidth = )` is a width in one column's own units, and `zone * density` spreads over *two* columns measuring different quantities — one number cannot be a width in both. Use `density(adjust = )`, which scales the automatic bandwidth on each axis by the same dimensionless factor.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

19.15 In short

A zone’s sides come from five places: bounds, bin, density, a category’s slot, or a boundary named by group. Where nothing gives sides, the zone reaches the panel edge, which a ribbon cannot do. The pair you name decides which axis it bounds; the column types decide which one measures.

19.16 What comes next

Color is how a zone carries a measure, read back from a legend. Surface gives that measure a height instead, and the same field is raised into the cube as a sheet.