15  Ribbon

How wide is the band the values fall in, across a span? ribbon draws a filled band from a low boundary to a high one across x: a confidence band, a spread band, the shaded region around a trend. It is area fed by interval’s machinery: area’s single filled region, but closed on a second data boundary instead of a baseline at zero. Where an area fills from the data down to the ground, a ribbon fills between two lines the data draws.

15.1 A band from a range

The two boundaries come from a transform, exactly as an interval’s two extents do. range reduces y to its minimum and maximum within each x, and the ribbon fills between them. Here, the spread of life expectancy across five Asian countries, year by year:

data(gapminder_asia) + ribbon * range + x(year) + y(life) +
  y_label("Life expectancy") +
  title("The min–max spread across five Asian countries, per year")
(data(gapminder_asia) + ribbon * range + x(col.year) + y(col.life) +
  y_label("Life expectancy") +
  title("The min–max spread across five Asian countries, per year"))
data(gapminder_asia) + ribbon * range + x(:year) + y(:life) +
  y_label("Life expectancy") +
  title("The min–max spread across five Asian countries, per year")
plot(data(gapminder_asia), layer(ribbon, range), x(col.year), y(col.life),
  y_label("Life expectancy"),
  title("The min–max spread across five Asian countries, per year"))
1960 1980 2000 40 50 60 70 80 The min–max spread across five Asian countries, per year Life expectancy Year

“Given gapminder Asia: a ribbon derived by range, x is year, y is life.”

Every year, the band’s lower edge is the shortest-lived country and its upper edge the longest-lived; the band is the region that contains all five. Read it as interval * range drawn as a continuous fill rather than a whisker at each x: the same low/high pair, one geometry apart.

15.2 The mean and its band

On its own a band shows spread but not center. The band’s natural partner is a line through the middle, and superposition, not a new option on either mark, is how you compose them. A ribbon * range for the spread, a line * mean for the trend, sharing one x and y:

data(gapminder_asia) + x(year) + y(life) +
  ribbon * range + style(color = "steelblue", opacity = 0.25) +
  line * mean + style(color = "steelblue", size = 2) +
  y_label("Life expectancy") +
  title("Mean life expectancy, with its min–max band")
(data(gapminder_asia) + x(col.year) + y(col.life) +
  ribbon * range + style(color = "steelblue", opacity = 0.25) +
  line * mean + style(color = "steelblue", size = 2) +
  y_label("Life expectancy") +
  title("Mean life expectancy, with its min–max band"))
data(gapminder_asia) + x(:year) + y(:life) + ribbon * range +
  style(color = "steelblue", opacity = 0.25) + line * mean +
  style(color = "steelblue", size = 2) + y_label("Life expectancy") +
  title("Mean life expectancy, with its min–max band")
plot(data(gapminder_asia), x(col.year), y(col.life), layer(ribbon, range),
  style({ color: "steelblue", opacity: 0.25 }), layer(line, mean),
  style({ color: "steelblue", size: 2 }), y_label("Life expectancy"),
  title("Mean life expectancy, with its min–max band"))
1960 1980 2000 40 50 60 70 80 Mean life expectancy, with its min–max band Life expectancy Year

Each layer runs its own transform on the shared data: mean collapses each year to one point for the line, range to a low/high pair for the band. This is the archetypal ribbon plot, a trend with the uncertainty or spread drawn around it, and it is two marks the grammar already has, added with +.

15.3 Why a ribbon needs a transform

A low and a high are two numbers, and a plain y() supplies one. So (like interval, and unlike area, which closes on the zero the grammar already knows) a ribbon’s minimum syllable includes a range-producing transform. Ask for one without it and the engine says so rather than drawing an empty panel:

data(gapminder_asia) + ribbon + x(year) + y(life)
Error:
! gog: `ribbon` draws a span from a low value to a high one, but nothing here produces those extents. Add a range transform — `ribbon * range + x(t) + y(value)` draws a band between the min and max at each x, or `ribbon * bounds(lo, hi)` a pre-computed one.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

This is the same design choice that keeps the channel vocabulary small. The alternative, ymin/ymax aesthetics, would add two channels meaningful to a single mark. Instead the boundaries are invented by a transform, exactly as bin invents a count, so the same low/high-rows mechanism feeds the whisker, the box, and the band alike; see Transforms for the range and confidence statistics.

Read the requirement as where are my two boundaries? rather than as a list of three transforms. There is a fourth answer, and it looks nothing like the other three: density over a category gives a ribbon its boundaries by reflection. That is the violin, at the end of this chapter.

15.4 A confidence band

range shows the full spread. For the uncertainty of the mean, the shaded band of a regression or a group mean, use confidence, which computes the mean’s t-interval per x and fills between its bounds:

data(gapminder_asia) + ribbon * confidence(0.95) + x(year) + y(life) +
  y_label("Life expectancy") +
  title("95% confidence band for mean life expectancy")
(data(gapminder_asia) + ribbon * confidence(0.95) + x(col.year) + y(col.life) +
  y_label("Life expectancy") +
  title("95% confidence band for mean life expectancy"))
data(gapminder_asia) + ribbon * confidence(0.95) + x(:year) + y(:life) +
  y_label("Life expectancy") +
  title("95% confidence band for mean life expectancy")
plot(data(gapminder_asia), layer(ribbon, confidence(0.95)), x(col.year),
  y(col.life), y_label("Life expectancy"),
  title("95% confidence band for mean life expectancy"))
1960 1980 2000 40 50 60 70 80 95% confidence band for mean life expectancy Life expectancy Year

The band is narrow because the five countries agree closely on the mean in each year; a wider level draws a wider band. Which statistic you ask for chooses the band’s meaning, the observed spread (range) or the uncertainty of the mean (confidence), with no new mark to learn, the same rule the interval family follows.

15.5 A pre-computed band

range and confidence compute the band from raw values in the plot. But most confidence bands are not computed at the plot at all: a model’s standard error, a psychometric standard error of measurement, a bootstrap interval are all worked out upstream, and you arrive with the bounds already in two columns. gog draws what you computed; it does not re-fit the model. So the ribbon reads those columns directly, through bounds(lower, upper):

data(score_band) + ribbon * bounds(lower, upper) + x(score) +
  y_label("Expected true score") + x_label("Raw score") +
  title("A pre-computed measurement band")
(data(score_band) + ribbon * bounds(col.lower, col.upper) + x(col.score) +
  y_label("Expected true score") + x_label("Raw score") +
  title("A pre-computed measurement band"))
data(score_band) + ribbon * bounds(:lower, :upper) + x(:score) +
  y_label("Expected true score") + x_label("Raw score") +
  title("A pre-computed measurement band")
plot(data(score_band), layer(ribbon, bounds(col.lower, col.upper)),
  x(col.score), y_label("Expected true score"), x_label("Raw score"),
  title("A pre-computed measurement band"))
0 5 10 15 20 0 10 20 A pre-computed measurement band Expected true score Raw score

bounds is range’s counterpart that computes nothing: it reshapes the two columns you hand it into the same low/high pair range produces, so the band draws with no new machinery and no y() (the two columns are the extents). It is the common case the summary transforms cannot serve: a band whose bounds came from somewhere gog will never be.

Its natural companion is the curve the band surrounds. Here the scoring model’s expected true score runs through the middle, drawn as a dashed line with a marker at each integer raw score (the band, the trend, and the score points, composed with +):

data(score_band) + x(score) + y(expected) +
  ribbon * bounds(lower, upper) + style(color = "steelblue", opacity = 0.18) +
  line + style(color = "steelblue", size = 1.4, pattern = "dashed") +
  point + style(color = "steelblue", size = 2.4) +
  y_label("Expected true score") + x_label("Raw score") +
  title("Expected score with its 95% CSEM band")
(data(score_band) + x(col.score) + y(col.expected) +
  ribbon * bounds(col.lower, col.upper) + style(color = "steelblue", opacity = 0.18) +
  line + style(color = "steelblue", size = 1.4, pattern = "dashed") +
  point + style(color = "steelblue", size = 2.4) +
  y_label("Expected true score") + x_label("Raw score") +
  title("Expected score with its 95% CSEM band"))
data(score_band) + x(:score) + y(:expected) +
  ribbon * bounds(:lower, :upper) +
  style(color = "steelblue", opacity = 0.18) + line +
  style(color = "steelblue", size = 1.4, pattern = "dashed") + point +
  style(color = "steelblue", size = 2.4) +
  y_label("Expected true score") + x_label("Raw score") +
  title("Expected score with its 95% CSEM band")
plot(data(score_band), x(col.score), y(col.expected),
  layer(ribbon, bounds(col.lower, col.upper)),
  style({ color: "steelblue", opacity: 0.18 }), line,
  style({ color: "steelblue", size: 1.4, pattern: "dashed" }), point,
  style({ color: "steelblue", size: 2.4 }),
  y_label("Expected true score"), x_label("Raw score"),
  title("Expected score with its 95% CSEM band"))
0 5 10 15 20 0 10 20 Expected score with its 95% CSEM band Expected true score Raw score

The dashed line is style(pattern = ): on a stroke the texture is a dash, so it rides the line here, not the band. The band can take the same setting too: there it is a hatch (a fill texture), not a dash.

15.6 Filled, or two dashed edges

A band is a low/high pair, and the mark chooses how to draw it: the same No Exceptions rule that gives one bin a bar, a line, and a step histogram. ribbon fills the pair; a line traces its two boundaries, the unfilled band publications often prefer, and with style(pattern = ), the classic dashed ± curves:

data(score_band) + x(score) + y(expected) +
  line * bounds(lower, upper) + style(color = "gray", pattern = "dashed", size = 1.2) +
  line + style(color = "steelblue", size = 1.5) +
  point + style(color = "steelblue", size = 2.4) +
  y_label("Expected true score") + x_label("Raw score") +
  title("The same band, unfilled: two dashed edges")
(data(score_band) + x(col.score) + y(col.expected) +
  line * bounds(col.lower, col.upper) + style(color = "gray", pattern = "dashed", size = 1.2) +
  line + style(color = "steelblue", size = 1.5) +
  point + style(color = "steelblue", size = 2.4) +
  y_label("Expected true score") + x_label("Raw score") +
  title("The same band, unfilled: two dashed edges"))
data(score_band) + x(:score) + y(:expected) +
  line * bounds(:lower, :upper) +
  style(color = "gray", pattern = "dashed", size = 1.2) + line +
  style(color = "steelblue", size = 1.5) + point +
  style(color = "steelblue", size = 2.4) +
  y_label("Expected true score") + x_label("Raw score") +
  title("The same band, unfilled: two dashed edges")
plot(data(score_band), x(col.score), y(col.expected),
  layer(line, bounds(col.lower, col.upper)),
  style({ color: "gray", pattern: "dashed", size: 1.2 }), line,
  style({ color: "steelblue", size: 1.5 }), point,
  style({ color: "steelblue", size: 2.4 }),
  y_label("Expected true score"), x_label("Raw score"),
  title("The same band, unfilled: two dashed edges"))
0 5 10 15 20 0 10 20 The same band, unfilled: two dashed edges Expected true score Raw score

line * bounds is one layer that draws both edges, splitting the pair into a low locus and a high one. It therefore needs no second y. That is why the two boundaries come out as separate curves, where two hand-written line layers could not manage it. It reads any pair the same way: line * range traces a min–max envelope, line * confidence the CI edges, and step * bounds draws them as staircases (stepped control limits). Filled or unfilled, solid or dashed, it is one pair and a choice of mark, never a new option bolted onto the band.

15.7 One band per group

A categorical column splits a ribbon into one band per group, the same split area and line make. Here each continent gets its own band, the range of its countries’ life expectancy over time:

data(gm_continents) + ribbon * range + x(year) + y(life) + color(continent) +
  y_label("Life expectancy") +
  title("Life expectancy spread, three continents")
(data(gm_continents) + ribbon * range + x(col.year) + y(col.life) + color(col.continent) +
  y_label("Life expectancy") +
  title("Life expectancy spread, three continents"))
data(gm_continents) + ribbon * range + x(:year) + y(:life) +
  color(:continent) + y_label("Life expectancy") +
  title("Life expectancy spread, three continents")
plot(data(gm_continents), layer(ribbon, range), x(col.year), y(col.life),
  color(col.continent), y_label("Life expectancy"),
  title("Life expectancy spread, three continents"))
1960 1980 2000 40 60 80 Life expectancy spread, three continents Life expectancy Year Continent Asia Europe Americas

The bands overlap where the continents do, and unlike a split area, which stays opaque and points you at stack, a ribbon draws its split translucent by default, so a band behind another still shows through. That difference is principled: an area measures a height from the baseline, so its groups can be piled into a total; a ribbon already spans a low to a high and measures no such height, so there is nothing to stack. Transparency, not accumulation, is a band’s honest answer to overlap, which is why ribbon * stack is refused with direction toward style(opacity = ).

15.8 One region, one fill

A ribbon is a single filled region, so, exactly as on area, opacity is a setting, not a channel: a row is a vertex of a boundary, not a region of its own, so there is nothing for a per-row opacity to vary along.

render_svg(data(gapminder_asia) + ribbon * range + x(year) + y(life) + opacity(gdp))
Error:
! gog: `opacity` cannot be bound to `ribbon` — a ribbon has no opacity feature. Remove `opacity(gdp)`, or use a mark that has one.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

And size cannot even be set: a band’s extent is pinned by its x and its two boundaries, with no width left to choose. Its edge, if you want one, is a line layered along a boundary (ribbon + line, the composition the previous section already drew), never an outline option on the mark.

15.9 A category on the domain

A ribbon takes a category on x, the same way an area does, and fills the spread across the categories in axis order:

data(gapminder_2007) + ribbon * range + line * mean + x(continent) + y(life) +
  y_label("Life expectancy") +
  title("ribbon * range + line * mean: spread and center, per continent")
(data(gapminder_2007) + ribbon * range + line * mean + x(col.continent) + y(col.life) +
  y_label("Life expectancy") +
  title("ribbon * range + line * mean: spread and center, per continent"))
data(gapminder_2007) + ribbon * range + line * mean + x(:continent) +
  y(:life) + y_label("Life expectancy") +
  title("ribbon * range + line * mean: spread and center, per continent")
plot(data(gapminder_2007), layer(ribbon, range), layer(line, mean),
  x(col.continent), y(col.life), y_label("Life expectancy"),
  title("ribbon * range + line * mean: spread and center, per continent"))
Asia Europe Africa Americas Oceania 40 50 60 70 80 ribbon * range + line * mean: spread and center, per continent Life expectancy Continent

For most such data an interval is the better sentence. It draws a separate whisker at each category and claims nothing about the space between them, where the band’s edges slope across ground that holds no observations. The grammar does not decide that for you: both expressions are well formed, and which one is honest about your data is a question about the data.

data(gapminder_2007) + interval * range + x(continent) + y(life) +
  y_label("Life expectancy") +
  title("interval * range: the same numbers, claiming less")
(data(gapminder_2007) + interval * range + x(col.continent) + y(col.life) +
  y_label("Life expectancy") +
  title("interval * range: the same numbers, claiming less"))
data(gapminder_2007) + interval * range + x(:continent) + y(:life) +
  y_label("Life expectancy") +
  title("interval * range: the same numbers, claiming less")
plot(data(gapminder_2007), layer(interval, range), x(col.continent),
  y(col.life), y_label("Life expectancy"),
  title("interval * range: the same numbers, claiming less"))
Asia Europe Africa Americas Oceania 40 50 60 70 80 interval * range: the same numbers, claiming less Life expectancy Continent

15.10 The violin: a distribution per category

A range on a category gives two numbers per group, the lowest and the highest. density gives a whole shape per group, and a ribbon has somewhere to put it: the estimate spreads along the measure and is drawn across the category’s slot, mirrored. That is the violin plot (Hintze & Nelson, 1998), and it is this chapter’s sentence with one word changed:

data(gapminder_2007) + ribbon * density + x(continent) + y(life) +
  y_label("Life expectancy") +
  title("ribbon * density: one distribution per continent")
(data(gapminder_2007) + ribbon * density + x(col.continent) + y(col.life) +
  y_label("Life expectancy") +
  title("ribbon * density: one distribution per continent"))
data(gapminder_2007) + ribbon * density + x(:continent) + y(:life) +
  y_label("Life expectancy") +
  title("ribbon * density: one distribution per continent")
plot(data(gapminder_2007), layer(ribbon, density), x(col.continent),
  y(col.life), y_label("Life expectancy"),
  title("ribbon * density: one distribution per continent"))
Asia Europe Africa Americas Oceania 40 60 80 ribbon * density: one distribution per continent Life expectancy Continent

Nothing new was added to the grammar to draw it. A ribbon is the mark that closes on a second data boundary, and a violin closes on its own reflection, which is one; density is the transform that estimates a distribution, and this is that estimate read per group. There is no violin word to learn, because a word for it would be four things at once (the mark, the statistic, the mirror, and the slot), and each of those is already said by something you know.

Read it against the box of the same two columns. A box gives you five numbers you can name; the violin gives you the shape those five numbers are a summary of, and shows the two humps a median hides.

data(gapminder_2007) + box + x(continent) + y(life) +
  y_label("Life expectancy") + title("box + x + y: the same distributions, summarized")
(data(gapminder_2007) + box + x(col.continent) + y(col.life) +
  y_label("Life expectancy") + title("box + x + y: the same distributions, summarized"))
data(gapminder_2007) + box + x(:continent) + y(:life) +
  y_label("Life expectancy") +
  title("box + x + y: the same distributions, summarized")
plot(data(gapminder_2007), box, x(col.continent), y(col.life),
  y_label("Life expectancy"),
  title("box + x + y: the same distributions, summarized"))
Asia Europe Africa Americas Oceania 40 50 60 70 80 box + x + y: the same distributions, summarized Life expectancy Continent

15.10.1 What the width means

Every group’s density integrates to 1 on its own, so left alone the widths would say nothing about how much data each group has: a continent of two countries would be drawn as boldly as a continent of fifty-two. So by default the estimate is weighted by the group’s row count, and width means the same thing across the whole panel. Oceania above is two countries, and it is drawn as two countries.

compare = "shape" takes that weighting back off and draws every violin to the same area. It is the right reading when the question is about the shapes and the groups are of unequal size on purpose, and the wrong one when a small group’s spike can take the panel:

data(gapminder_2007) + ribbon * density(compare = "shape") + x(continent) + y(life) +
  y_label("Life expectancy") +
  title('density(compare = "shape"): every violin the same area')
(data(gapminder_2007) + ribbon * density(compare = "shape") + x(col.continent) + y(col.life) +
  y_label("Life expectancy") +
  title('density(compare = "shape"): every violin the same area'))
data(gapminder_2007) + ribbon * density(compare = "shape") +
  x(:continent) + y(:life) + y_label("Life expectancy") +
  title("density(compare = \"shape\"): every violin the same area")
plot(data(gapminder_2007), layer(ribbon, density({ compare: "shape" })),
  x(col.continent), y(col.life), y_label("Life expectancy"),
  title("density(compare = \"shape\"): every violin the same area"))
Asia Europe Africa Americas Oceania 40 60 80 density(compare = "shape"): every violin the same area Life expectancy Continent

Two countries now occupy as much ink as fifty-two, and because two nearby values make a tall narrow estimate, Oceania takes the width and everything else is squeezed. Both plots are honest about something different, which is why the choice is a word you write rather than a default nobody stated.

15.10.2 Sideways, and halved

The category can go on either axis, and the violins turn to follow it. Read off the bindings, exactly as a bar’s or a box’s orientation is. It is the form with room for long category names:

data(gapminder_2007) + ribbon * density + x(life) + y(continent) +
  x_label("Life expectancy") + title("the same violins, lying down")
(data(gapminder_2007) + ribbon * density + x(col.life) + y(col.continent) +
  x_label("Life expectancy") + title("the same violins, lying down"))
data(gapminder_2007) + ribbon * density + x(:life) + y(:continent) +
  x_label("Life expectancy") + title("the same violins, lying down")
plot(data(gapminder_2007), layer(ribbon, density), x(col.life),
  y(col.continent), x_label("Life expectancy"),
  title("the same violins, lying down"))
40 60 80 Oceania Americas Africa Europe Asia the same violins, lying down Continent Life expectancy

And an area draws the same estimate closed on the slot’s center line instead of on its own reflection: one side rather than two, since that is the difference between the two marks everywhere else in the book:

data(gapminder_2007) + area * density + x(continent) + y(life) +
  y_label("Life expectancy") + title("area * density: the half violin")
(data(gapminder_2007) + area * density + x(col.continent) + y(col.life) +
  y_label("Life expectancy") + title("area * density: the half violin"))
data(gapminder_2007) + area * density + x(:continent) + y(:life) +
  y_label("Life expectancy") + title("area * density: the half violin")
plot(data(gapminder_2007), layer(area, density), x(col.continent),
  y(col.life), y_label("Life expectancy"),
  title("area * density: the half violin"))
Asia Europe Africa Americas Oceania 40 60 80 area * density: the half violin Life expectancy Continent

Two choices, made independently: which mark closes the region, and which axis carries the category. That is four plots, and the fourth one has a name of its own. Halve the violin and lay it down, and each category gets a filled curve on its own line above a shared measure axis, which is the ridgeline plot:

data(gapminder_2007) + area * density + x(life) + y(continent) +
  x_label("Life expectancy") + title("area * density + x(life): the ridgeline")
(data(gapminder_2007) + area * density + x(col.life) + y(col.continent) +
  x_label("Life expectancy") + title("area * density + x(life): the ridgeline"))
data(gapminder_2007) + area * density + x(:life) + y(:continent) +
  x_label("Life expectancy") +
  title("area * density + x(life): the ridgeline")
plot(data(gapminder_2007), layer(area, density), x(col.life),
  y(col.continent), x_label("Life expectancy"),
  title("area * density + x(life): the ridgeline"))
40 60 80 Oceania Americas Africa Europe Asia area * density + x(life): the ridgeline Continent Life expectancy

Nothing was added for it either. It is the previous chunk’s sentence with x and y exchanged. It arrives because the two choices are genuinely independent: nothing in the grammar ties where a region closes to which axis holds the category. All four combinations are sentences, and the one the world gave a name to is simply one of them.

Read the four together and the point of orthogonality is easier to see here than anywhere else in this chapter. A word for the violin and a word for the ridgeline would be two names for one idea, and the other two combinations would have no word at all.

15.10.3 Letting the ridges overlap

Each shape reaches four tenths of a slot by default, so two violins face to face fill four fifths of the space between their categories and the remaining fifth is air. That is a bar’s own rule, for a bar’s reason: the gap is what says the categories are separate rather than a divided continuum.

The joyplot as it is usually drawn is not like that. Its ridges are tall enough to run into the row above, and that overlap is the look. density(reach = ) says how far, in slots:

data(gapminder_2007) + area * density(reach = 2.5) + x(life) + y(continent) +
  x_label("Life expectancy") + title("reach = 2.5: each ridge two and a half slots tall")
(data(gapminder_2007) + area * density(reach = 2.5) + x(col.life) + y(col.continent) +
  x_label("Life expectancy") + title("reach = 2.5: each ridge two and a half slots tall"))
data(gapminder_2007) + area * density(reach = 2.5) + x(:life) +
  y(:continent) + x_label("Life expectancy") +
  title("reach = 2.5: each ridge two and a half slots tall")
plot(data(gapminder_2007), layer(area, density({ reach: 2.5 })),
  x(col.life), y(col.continent), x_label("Life expectancy"),
  title("reach = 2.5: each ridge two and a half slots tall"))
40 60 80 Oceania Americas Africa Europe Asia reach = 2.5: each ridge two and a half slots tall Continent Life expectancy

The number is measured from the line a category sits on to the shape’s furthest point. That is why it means the same thing to both marks: a ribbon reaches it each way and an area one way, so the half violin stays exactly half of the violin at any value. Past 0.5 the shapes leave their own slots. The grammar does not guard against that. It is the plot being asked for, and the later category simply draws over the earlier one.

The category axis grows to hold them. Half a slot of margin is right for a bar or a box, which stand in their slots. A ridge 2.5 slots tall does not, and without the extra room the frame would cut the top one off. That would be a mark drawn outside its own panel: a plot that is wrong in a way nothing but a reader would notice.

The edge is a layer, as it is for every filled band in this chapter: a ribbon takes no border setting, and neither does the violin, because line reads the same slot reading and traces what the fill fills. That is the “filled, or two edges” rule again, now against a slot:

data(gapminder_2007) + x(life) + y(continent) +
  area * density(reach = 2.5) + style(color = "steelblue", opacity = 0.9) +
  line * density(reach = 2.5) + style(color = "black", size = 1) +
  x_label("Life expectancy") + title("The same ridges, with their edges drawn")
(data(gapminder_2007) + x(col.life) + y(col.continent) +
  area * density(reach = 2.5) + style(color = "steelblue", opacity = 0.9) +
  line * density(reach = 2.5) + style(color = "black", size = 1) +
  x_label("Life expectancy") + title("The same ridges, with their edges drawn"))
data(gapminder_2007) + x(:life) + y(:continent) +
  area * density(reach = 2.5) +
  style(color = "steelblue", opacity = 0.9) +
  line * density(reach = 2.5) + style(color = "black", size = 1) +
  x_label("Life expectancy") +
  title("The same ridges, with their edges drawn")
plot(data(gapminder_2007), x(col.life), y(col.continent),
  layer(area, density({ reach: 2.5 })),
  style({ color: "steelblue", opacity: 0.9 }),
  layer(line, density({ reach: 2.5 })),
  style({ color: "black", size: 1 }), x_label("Life expectancy"),
  title("The same ridges, with their edges drawn"))
40 60 80 Oceania Americas Africa Europe Asia The same ridges, with their edges drawn Continent Life expectancy

Both layers read the same density(reach = 2.5), so the stroke cannot drift off the fill it belongs to, the reason an edge is a layer rather than a setting. Color each ridge by its own category and the plot is complete:

data(gapminder_2007) + x(life) + y(continent) +
  area * density(reach = 2.2) + color(continent) +
  line * density(reach = 2.2) + style(color = "black", size = 0.9) +
  x_label("Life expectancy") + title("One hue per ridge")
(data(gapminder_2007) + x(col.life) + y(col.continent) +
  area * density(reach = 2.2) + color(col.continent) +
  line * density(reach = 2.2) + style(color = "black", size = 0.9) +
  x_label("Life expectancy") + title("One hue per ridge"))
data(gapminder_2007) + x(:life) + y(:continent) +
  area * density(reach = 2.2) + color(:continent) +
  line * density(reach = 2.2) + style(color = "black", size = 0.9) +
  x_label("Life expectancy") + title("One hue per ridge")
plot(data(gapminder_2007), x(col.life), y(col.continent),
  layer(area, density({ reach: 2.2 })), color(col.continent),
  layer(line, density({ reach: 2.2 })),
  style({ color: "black", size: 0.9 }), x_label("Life expectancy"),
  title("One hue per ridge"))
40 60 80 Oceania Americas Africa Europe Asia One hue per ridge Continent Life expectancy Continent Asia Europe Africa Americas Oceania

The color(continent) is written after the area, so it belongs to that layer alone: a plot-scoped one would reach the line too, and a layer cannot both map color and set it. Scope is position, and this is the sentence where it matters.

step * density traces the same estimate as a staircase, for the same reason step * bin is the outline histogram: No Exceptions means a mark draws whatever a transform hands it, in the mark’s own way.

15.11 What you can set

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

And these vary per row if you map them to a column instead: color() (categories), pattern() (categories), group() (categories), play() (either).

A ribbon takes area’s list, and for area’s reasons: no size, because the band’s extent is pinned by the two boundaries a range transform supplies, and no border, because the edge is a line traced along the same pair.

data(score_band) + x(score) +
  ribbon * bounds(lower, upper) +
    style(color = "slateblue", pattern = "grid", opacity = 0.45) +
  line * bounds(lower, upper) + style(color = "slateblue", pattern = "dashed") +
  y_label("Expected true score") + title("A textured band, and its two edges")
(data(score_band) + x(col.score) +
  ribbon * bounds(col.lower, col.upper) +
    style(color = "slateblue", pattern = "grid", opacity = 0.45) +
  line * bounds(col.lower, col.upper) + style(color = "slateblue", pattern = "dashed") +
  y_label("Expected true score") + title("A textured band, and its two edges"))
data(score_band) + x(:score) + ribbon * bounds(:lower, :upper) +
  style(color = "slateblue", pattern = "grid", opacity = 0.45) +
  line * bounds(:lower, :upper) +
  style(color = "slateblue", pattern = "dashed") +
  y_label("Expected true score") +
  title("A textured band, and its two edges")
plot(data(score_band), x(col.score),
  layer(ribbon, bounds(col.lower, col.upper)),
  style({ color: "slateblue", pattern: "grid", opacity: 0.45 }),
  layer(line, bounds(col.lower, col.upper)),
  style({ color: "slateblue", pattern: "dashed" }),
  y_label("Expected true score"),
  title("A textured band, and its two edges"))
0 5 10 15 20 0 10 20 A textured band, and its two edges Expected true score Score

Both marks read the same bounds(lower, upper) there, so the dashed edges cannot drift from the band they belong to. Note the two pattern values in one sentence: "grid" is a fill texture on the ribbon and "dashed" a stroke dash on the line, the same setting realized once per geometry.

15.12 What it refuses

The measure stays numeric. A band spans from a low value to a high one, and the two extents are quantities, so a categorical y is refused:

render_svg(data(gapminder_2007) + ribbon * range + x(life) + y(continent))
Error:
! gog: `y(continent)` maps a categorical (text) column, but `y` on `ribbon` needs a continuous (numeric) column. On these marks `x` is the domain and `y` the measure, and a category is not a quantity to measure: a mean of category names is not a number, and a region has no categorical baseline to close on. Put the category on `x` instead — `line * mean + x(<category>) + y(<number>)` is the profile plot, and `area * mean` fills it. Unlike `bar`/`box`/`interval`, these marks do not read their orientation off the bindings, because their two axes do not have the same role.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

ribbon completes the low/high family: interval draws the pair as a whisker at each x, box as a five-number summary, and ribbon as a filled band across x. One range, three marks: No Exceptions made visible. See Transforms for the range and confidence statistics all three share.