19  Zone

Which region of the plot should the values stay inside? zone shades a rectangle. It is rule’s sibling one dimension up, and it works 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.

That “spans the axis it was not given” is the whole reason the mark exists, because everything else about a rectangle can already be drawn. What could not be drawn is a rectangle that reaches the panel.

19.1 A band across the panel

Were sales inside the target each year? Two numbers in a caption let a reader check, year by year. A band shaded behind the line shows it at once. 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, whatever the years happen to be:

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 150 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 spans of time rather than of value. The years a recession lasted are one, when the question is how sales moved through them. Swap which pair you give it and the rectangle stands up instead. 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 150 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 spans, 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 plot.

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 hand it, and any number big enough to reach the top would widen the axis to include it, changing the plot in order to decorate it.

The pair is called start/end rather than left/right on purpose. Left and right are facts about a screen. This grammar reads orientation off the bindings, so the same words keep working when the category axis becomes an angle.

19.3 Both pairs: a rectangle

Sometimes the region to highlight is bounded on both axes: these four 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 four 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 four 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 four 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 four years of sales"))
2005 2010 2015 2020 100 150 A box over four years of sales Sales Year

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

So one atom covers the three shapes people actually want (a horizontal band, a vertical band, and a box), and which one you get is decided by which pairs you name. There is no hband, no vband, no rect.

19.4 Which axis the pair bounds

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. lower and upper bound the measure axis. Every plot so far has measured along y, so every lower/upper band so far has run across the panel. Now put a category on y instead. The measure moves to x, and the same two columns draw the plot on its side:

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")
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.

The blocks are the zone, one row each. The columns lo and hi hold -n/2 and n/2, computed in R before the plot. The waterfall below does the same, computing its running total in the language you write the sentence in. The numbers inside the blocks are a second layer, drawn by text.

A funnel is a bar chart whose blocks are centered on zero. Centering is arithmetic on two columns. It is not a chart type.

The plot works because of how the pair is named. lower and upper describe the measure axis. They do not mean 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

Two bands in one red are two unnamed slumps. 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 150 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, color by slump, and also a line and points.”

A zone’s color also takes a number, reading the sequential ramp instead of a palette, where bar, area and ribbon keep to categories. A rectangle is large enough to read a ramp against. That is what a heatmap needs, so a heatmap cell is this mark rather than a new one. The heatmap section below is that plot.

19.6 Rectangles that are the data

Every zone so far has been a highlight: a rectangle behind a line, saying where to look. Nothing says it has to sit 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.

A waterfall is a running total, broken into the steps that made it. gog has no transform for that and the omission is deliberate: stack accumulates within a position, one pile per slot, and a waterfall accumulates across positions. So the running total is arithmetic, and arithmetic belongs with the data, 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 sits at k on a categorical axis
w$left  <- slot - 0.35             # so a side named at k ± 0.35 is inside its slot
w$right <- slot + 0.35

That is R because it is R’s job, and the other three answer it the same way: a running total belongs to the host language. What follows is the sentence, and it 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 bars that start at zero are not a special case. They are rows whose base is 0, a number the accounts supplied like every other.

Two parts of that sentence are worth pausing on, because both are this mark’s defaults being overruled 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: 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 bars. Given no sides, a zone fills its slot whole, where 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 bar’s width means nothing, so it should behave like a bar, and naming the sides is how to ask. The arithmetic works because a categorical axis puts category k at k, so the slot it owns is exactly [k−½, k+½] and k ± 0.35 sits inside it. Drop those two columns and the rectangles touch, which reads as one connected shape rather than six steps.

A candlestick is the same idea with the rectangle floating. Each session has four numbers: open, close, high and low. 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 still
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, color by dir, and also zones from body lo to body hi and from left to right, color by dir.”

color(dir) is said twice, once per layer, and that is scope working rather than repetition: a channel written after a mark belongs to that mark, so the whisker takes its session’s color along with the body. Both readings of the same column earn one legend, because it is one column.

There is no candlestick atom, and the grammar does not need one. The chart is a composition of things that already exist, which is the whole claim of the grammar: Chart names lists it beside the pie and the rose, which are also sentences rather than words. What the two charts share is the shape of the answer. Four numbers a session: two of them a span, two of them a rectangle’s sides. The pair a band mark already reads turns out to be exactly the pair a rectangle needs. That is why neither chart needed 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. Draw them as points and the crowded middle becomes one dark mass, because a thousand dots in one place look exactly like two hundred:

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 capita") + 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 capita") + 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 capita") + 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 capita"), 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 capita Count 40.00 20.50 1.00

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

Read the band of dark cells running up the middle: wealth and life expectancy rise together. The darkest cell says how many country-years sit at that combination, which overlapping dots cannot.

Three things in that sentence are worth slowing down on.

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 read off 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 measures nothing by height, and 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 below is where they matter: an axis whose values are categories arrived already cut, and bin leaves it alone.

The count became the color, and nothing bound it. Both positions were taken, so the measurement went to the one channel left that can carry a number, and the legend named itself. This is what bar * bin already does for the y axis: a transform that invents a column also says where it goes.

The column it invents 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)

identical(render_svg(short), render_svg(long))
[1] TRUE

So why write it? Because a binding is where a scale goes, and the short form has nowhere to put one. Counts are badly skewed here: the fullest cells hold about forty country-years while most hold one or two. 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 capita") + 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 capita") + 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 capita") +
  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 capita"), 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 capita Count 40.00 6.32 1.00

“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 sparse tail separates and the ridge is a gradient rather than a blot. Look at the legend’s middle label: 6.32, not 20.5, because the midpoint of a log ramp is the geometric mean, so the strip names the color it actually paints there.

The white gaps are not zeroes. A cell with no country-years in it is left as panel rather than painted the pale end of the ramp, because painting it would claim a measurement nobody made. The ragged edge you get instead is the region the data actually covers, which is information rather than an unfinished job.

bin(24) sets the mesh, 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 capita") + 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 capita") + 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 capita") + 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 capita"), 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 capita Count 212.0 106.5 1.00

Notice the log scale carried through untouched. The cells are cut in log space, so they are even on the page rather than even in dollars, which is what you want when the variable spans from a few hundred dollars to a hundred thousand.

A binned zone also drops the translucency: here the zone is the data, with nothing behind it to show through.

19.8 Hexagons: the other mesh

Do the rows and columns in a heatmap belong to the data or to 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 capita") + 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 capita") + 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 capita") +
  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 capita"), 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 capita Count 43.00 22.00 1.00

“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 ridge and guessing at it. The palette is a free choice on either mesh, though, so do not read it as something hexagons require.

The mesh itself is not decoration, and the reason to prefer it is a defect in the rectangular one rather than a preference about shapes. A square grid lines its cell centers up 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 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, hold the palette still and change only the mesh:

data(gm_all) + zone * bin(20) + x(gdp, scale = "log") + y(life) +
  palette("viridis") +
  x_label("GDP per capita") + 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 capita") + 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 capita") +
  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 capita"),
  y_label("Life expectancy"), title("Rectangular cells, same palette"))
1K 10K 100K 40 60 80 Rectangular cells, same palette Life expectancy GDP per capita Count 45.00 23.00 1.00

Look along the sparse edges of the two. The rectangular cells line up into faint rows and columns that continue past the edge of the data. The hexagons do not, and what is left at the edge is the shape of the data rather than the shape of the mesh.

19.8.1 Why the mesh belongs to bin

Everywhere else in this book, a transform computes and the mark decides how the result is drawn: bar * bin, line * bin and step * bin are one histogram painted 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 matters.

Those three marks all receive the same numbers. A hexagonal mesh staggers its rows, which changes which rows land in which cell, so the counts themselves come out different. You cannot draw a hexagonal plot from rectangularly binned data, 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.

The consequence you can see: 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 One caveat

The hexagons are regular in the space the mesh is cut in, which normalizes both axes to the same number of steps. The panel then stretches that space by whatever its own proportions are, so on a wide panel the cells come out slightly wide. Look closely at the plot above and you can see it.

This is what the original hexbin package exposes as its shape argument. The fix would be a panel told to keep equal proportions, which is what a polar plot needs for round circles. gog has no setting for that, so a roughly square panel gives roughly regular hexagons.

19.9 Cells the data estimated: zone * density

A count per cell is honest and ragged when the table is small. 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 thick the cloud is there. That is density, the transform that draws a smooth curve on a line, read in two dimensions for exactly the reason bin was.

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.16 0.08 0

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

Every sentence from the heatmap above is true here word for word. Both axes were cut and nothing asked for it, because a zone measures nothing by height and needs an extent on both before it can be a rectangle at all. The measurement went to color with nothing bound, and the column it invents is called density, so 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 difference, and here there are none. A count of zero says the data did not go to that cell, so bin leaves it unpainted. An estimate exists at every point of the plane. The estimator returns a value for every cell, so 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.16 0.08 0

Look at the dark area away from the two clusters: it is even, and the cells meet cleanly. Blending each cell’s edge into the background would leave a few percent of the panel showing at every seam. A pale ramp hides that; this one would draw it as a fine lattice over the whole field, the same false structure the hexagon section describes, arriving from the renderer rather than from the mesh. So a cut cell’s edges land on whole pixels instead.

The parameter a painted field takes is adjust, the multiplier on the bandwidth the estimator chose for itself. It is dimensionless, 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 cloud, smoothed harder")
(data(iris_flowers) + zone * density(adjust = 1.6) +
  x(col.sepal_length) + y(col.petal_length) + palette("viridis") +
  title("The same cloud, smoothed harder"))
data(iris_flowers) + zone * density(adjust = 1.6) + x(:sepal_length) +
  y(:petal_length) + palette("viridis") +
  title("The same cloud, smoothed harder")
plot(data(iris_flowers), layer(zone, density({ adjust: 1.6 })),
  x(col.sepal_length), y(col.petal_length), palette("viridis"),
  title("The same cloud, smoothed harder"))
4 6 8 0 5 10 The same cloud, smoothed harder Petal Length Sepal Length Density 0.10 0.05 0

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

The same field traced rather than painted is path * density, the contour, and the division between them is the one bar * bin and line * bin already made: the mark chooses the geometry, the transform stays constant.

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.15 0.08 0.02

“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 it is the same parameter path takes. levels means one thing (cut the field into this many) and then each mark does what it does: a path traces the boundaries, a zone fills between them. So the two are not two features but one sentence 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.15 0.08 0.02

“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, and the mark decides every time: line * density is a curve, zone * density a painted field, and levels turns the field into level sets that path strokes and zone fills.

The bands nest, and that is why plain filled shapes are enough to draw them. A denser region is always inside a less dense one, so gog paints the outermost band first and lets each inner one cover it; nothing has to be cut out. The exception is a crater: a ring-shaped cluster with a hollow middle, points scattered around a circle. There the hollow really is a hole, and painting the band over it fills it in. gog has no mark for a shape with a hole, so a crater is the one field zone * density draws wrongly. path * density traces it correctly.

19.11 Cells the axes already made: the tile plot

Three sources of sides so far, and every one of them put the sides in a column: bounds named them, bin cut them, density(levels = ) traced them. There is a fourth, and it 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. A categorical axis is already divided. Six weeks on the x axis is six slots. The slot “Week 3” owns is a place on the panel with a left edge and a right edge, both fixed by the axis when it laid the categories out. 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.00 23.50 13.00

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

That is 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 rhythms at once. A column of numbers shows neither, while a grid separates them by direction. Read down a column for the week (four is the pale one, the lowest week) and across a row for the weekday, where Thursdays are consistently darkest and Fridays lightest. The extremes are the cells where the two rhythms agree: the darkest cell is Thursday of week six, the palest the Friday in week four.

The rule underneath is one sentence: a category owns a slot, a number is a point. A point has no width, which is why two continuous positions still leave a zone with nothing to bound it, and why bin had to be asked to cut them into cells first. 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 had one row per cell already. When it does not, when you hold the raw observations and want to know how many fell in each cell, the transform that tallies 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.00 22.00 6.00

“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, and 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. That is the same division bar * bin and bar * count have always had, now with both axes in it, and it is why each one’s refusal names the other. Ask a tile plot for a continuous axis and it names bin. Ask a heatmap for two categorical ones, which leave it nothing to cut, and it names count.

The second dimension changes only where the answer goes. A bar has a length to put it on, so bar * count writes its tally to the y axis; a zone measures nothing by length, so zone * count writes it to color and the legend names itself. Both positions were taken, so there was nowhere else for it to go.

proportion is the same tally as a share of the whole, which is worth having when the absolute numbers are not the point:

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.14 0.08 0.02

“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 in each true class asks a different question, which margin normalizes, and gog does not answer it by giving one word two meanings.

It reads a cut mesh the same way: proportion divides whatever measurement the cells carry, whichever transform put it there. 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.03 0.01 0.00

19.11.2 count tallies rows, mean reduces a column

The wind table holds more than the 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 the other kind of statistic, the kind you point at a column:

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 two plots up. The sentence is the same shape with one more binding, and that binding is the whole difference: count was handed no column, so it counts rows and names its own answer; mean was handed one, so color(speed) says which. 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.

This is where the rule stated under the first heatmap gets its second half. Color is where the answer went, and color is therefore the channel that names the column too, because a summary reduces in place. On a flat bar, 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 plays both parts.

Which gives the rule in one sentence, and it is the same rule the heatmap’s dimensions come from: a summary groups by every position the mark does not measure with, and reduces the column named on the position it measures with. 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.

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

And a summary still needs a cell to summarize into. These six measure without cutting, so they need axes that arrive already divided: a number is a point, and a point owns no cell to summarize into. That refusal gives both ways out, a category on each position or bin on the continuous axis first.

19.11.3 Cells cut, then summarized: the summary heatmap

How does life expectancy depend on income and on time together? Both are continuous, so neither owns a slot, and a mean needs a cell to compute inside. A category owns a slot, and that is one way an axis can arrive already divided. Cutting a continuous axis is the other, and bin is what cuts. Compose the two and each supplies half of what a cell needs:

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 the two patterns separate. Life expectancy rises with income up the panel, and rises again with time across it.

This is the same mesh a histogram of those two columns would cut. Nothing about bin changes when it is composed. It only stops reporting how many rows fell in each cell, because mean now supplies the measurement. So the plot above and zone * bin(15) + x(year) + y(gdp, scale = "log") tile the panel identically, and can be read against each other: one says how many observations there are in a cell, the other says what they were like.

The mixed case follows without a new rule, because the two halves are still stated one axis at a time. Cut the income axis, 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.90 39.63

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. The pair above was stated one axis at a time, which leaves a 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 leaves alone the one that arrived already cut, so what comes out 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.00 46.00 1.00

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 a histogram lying on its side. Read the plot and the shape of each continent’s spread is what stands out: Africa’s mass sits far left and Europe’s far right, Asia and the Americas are wide where Africa and Europe are concentrated, and Oceania is two countries and says so.

Two things about it are worth naming, because they are what make it a mesh and not five plots side by side. Every row is cut on the same edges, and the layout is computed once from the whole column, never per category, so a column of cells is a straight comparison 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 ragged left edge shows where each distribution reaches, not a claim that anyone lived to 25 there.

The mirror is the same sentence with the axes swapped, and it is the reading that stacks the distributions upright:

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.00 46.00 1.00

Nothing was added for either. Three cases, one rule read one axis at a time: cut both axes and it is the heatmap, cut neither and it is the tile plot, cut one and it is this.

19.11.5 Half a mesh: bounded by one slot

Two of the eight directions are the ones the wind comes from most often, and a reader of the strip plot should see those two columns marked. The rule was stated one axis at a time, so read it that way and a zone bounded on one categorical axis follows. It takes that slot and spans the panel on the other, which is what this mark has done from the beginning. 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 10 20 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 table: 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 is the test that the fourth source is a rule rather than a special case for one chart.

The two-row table is doing the work 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 translucent golds stacked into eight solid columns. That is the rule behaving exactly as stated, and not the plot you wanted.

One difference to see rather than be told: the column highlight is translucent and the calendar heatmap is not, for the reason the settings section states in full. style(opacity = ) overrides either.

19.12 Sides the data drew: the choropleth

Every section above finds a zone’s sides in the same place: the row being drawn. bounds names them, bin and density cut them, and a category owns a slot. There is a fifth source, and it is the only one that spans 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 hundreds of 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° 100° -50° 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 lives in the map space, where the two positions are longitude and latitude. The mark did not change and no atom was added: this mark was never about rectangles. It has always been about naming an extent.

19.13 What you can set

A highlight has to let the data show through, and a heatmap cell has to be solid. gog picks one of the two by default, and a setting overrides it without reading a column. Each mark takes its own settings. These are a zone’s, generated from the engine’s own legality table, so this page cannot differ from what style() accepts:

Setting Value
style(color = ) any CSS color name or hex
style(opacity = ) 0 to 1
style(pattern = ) solid, hatch, crosshatch, 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 worth knowing: opacity is 0.2 for a zone you placed and 1 for one whose cells a mesh made. That covers cells bin cut, cells density estimated, and the slots two categories own. A placed zone is drawn under your data and has to let it show through; a cut zone is the data, with nothing behind it to reveal.

All three at once, one per band:

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 150 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 is what makes the first band readable without your saying anything: a highlight that hides the data is not a highlight. Raise it when the zone is the subject of the plot rather than its background, as the red band does here.

The hatch is worth using 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 wants, as the blue band does: a hatch is line-work with gaps between the lines, 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 five fill textures instead.

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

A border has two good answers, and both are available. A zone highlighting a region behind a line reads better with no border around it. Two rules draw better edges: dashed, colored per row, and real positions rather than decoration:

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 150 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 (bounds(lower, upper)), while a rule reads whichever of the plot’s own position columns its table carries, so the edges have to arrive as a sales column. Building that column from target_band is what keeps the two from drifting apart 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 stops being an answer: a region has four sides, a mosaic has as many regions as the table has rows, and two rules per cell hand-placed is not a sentence anyone writes. So a zone takes style(border_color = , border_size = ) like every other closed-glyph fill:

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 150 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 readings stay available, and which one is right depends on whether the zone is the background or the subject. A zone you do not ask for a border draws no stroke: with no border_color and no border_size there is none at all.

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. A zone with nothing to bound it has no sides:

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. Four 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; and `density`, which cuts the same cells and estimates a value at each. 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.

And half a pair is not a side. The refusal names the half you gave, because that is nearly always a typo rather than a misunderstanding:

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 band spans the measure axis at each position, 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.

A zone says where its cells are and what is in them, and a transform can supply either or both. bounds names the sides and measures nothing. count tallies into the cells your categories already are, and says nothing about where those are. density estimates a field and samples it. bin cuts cells out of a continuous axis and supplies both, which is why it is also the one that can supply just one of them. proportion answers neither question: it divides whatever measurement is there by its total, so it stands after any of them. Two transforms answering the same half is a contradiction, so a rectangle whose sides you named, one per row, has no mesh for a summary to group 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 band the data computed rather than one you chose, the mark that spans a statistic is ribbon: ribbon * range fills between the minimum and maximum at each x. The division is the same one rule draws: a zone shades where you say, a ribbon shades what the data says.

Two measurements is the same contradiction on the other half. count invents one and mean reduces the column you named, and a cell holds a single number. count cuts nothing, so with mean measuring the cells it has no work left:

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

Asking for both extents at once is refused rather than resolved by picking one, since the two would disagree the moment the mesh moved:

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: a category is one slot, with no width. The refusal does not stop there, though: a cell per pair of categories is the tile plot, which does no binning at all, so it names the transform that does tally 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)` is categorical, and so is the other one. 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: `zone * bin` cuts an axis into cells, and there is no axis here it can cut — `y(week)` is categorical, and so is the other one. 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 above. density is where the two differ, and it is not an oversight. 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. It is the which margin normalizes question again, and the answer here is the one proportion gives everywhere: a share is of the whole table, and a per-slot denominator is a different plot that has to be asked for. 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. hex interleaves two lattices, weighing a step up against a step across, so it needs a distance on both axes; a category’s slots are an order and no more. A mixed mesh has two axes and still no plane, and its cells are rectangles by construction:

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.

A mark that cuts one axis gives a tiling even less to be regular against. bar * bin cuts intervals, and an interval has no shape, so the refusal sends the tiling back to zone:

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.

And the mirror of the first refusal, which is the same rule read the other way. A tally needs cells that exist; a number is a point and owns none, so count sends you back to 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)` is 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: `zone * count` tallies rows into the cells that two *categorical* axes already make, and `y(life)` is 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, so the refusal offers both ways out, 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 mesh gog does not cut 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 section above 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.