| country | continent | year | life | population | gdp |
|---|---|---|---|---|---|
| China | Asia | 1952 | 44.00000 | 556263527 | 400.4486 |
| China | Asia | 1957 | 50.54896 | 637408000 | 575.9870 |
| China | Asia | 1962 | 44.50136 | 665770000 | 487.6740 |
| China | Asia | 1967 | 58.38112 | 754550000 | 612.7057 |
| China | Asia | 1972 | 63.11888 | 862030000 | 676.9001 |
15 Ribbon
How wide is the band of values at each point along x? 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 takes area’s single filled region and gives it interval’s low and high pair. The region then closes on a second data boundary instead of on a baseline at zero. Where an area fills from the data down to zero, a ribbon fills between two boundaries the data supplies.
15.1 A band from a range
Did the five countries move closer together over the years, or further apart? The gap between the lowest and the highest in each year answers that. A band drawn between the two shows it as one shape. 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"))“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 pair is the same; only the geometry differs.
15.2 The mean and its band
On its own a band shows spread but not center. A band is usually drawn with a line through the middle. You compose the two by layering, not by adding an option to either mark. 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"))“Given gapminder Asia: x is year, y is life, a ribbon derived by range and also a line derived by mean.”
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 most common ribbon plot: a trend with its uncertainty or spread drawn around it. It is two marks the grammar already has, added with +.
15.3 A fan chart of nested bands
gm_all is the whole gapminder table: every country, in every year, 142 countries in all. A min–max band over it shows what every country did, but only the two extreme countries set its edges. Naming two quantiles instead gives a band that contains most of the countries. Layer two bands of different widths and you have a fan chart:
| 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) + x(year) + y(life) +
ribbon * range(0.1, 0.9) + style(color = "steelblue", opacity = 0.2) +
ribbon * range(0.25, 0.75) + style(color = "steelblue", opacity = 0.35) +
line * median + style(color = "steelblue", size = 2) +
y_label("Life expectancy") +
title("Life expectancy across 142 countries, 1952 to 2007")(data(gm_all) + x(col.year) + y(col.life) +
ribbon * range(0.1, 0.9) + style(color = "steelblue", opacity = 0.2) +
ribbon * range(0.25, 0.75) + style(color = "steelblue", opacity = 0.35) +
line * median + style(color = "steelblue", size = 2) +
y_label("Life expectancy") +
title("Life expectancy across 142 countries, 1952 to 2007"))data(gm_all) + x(:year) + y(:life) + ribbon * range(0.1, 0.9) +
style(color = "steelblue", opacity = 0.2) + ribbon * range(0.25, 0.75) +
style(color = "steelblue", opacity = 0.35) + line * median +
style(color = "steelblue", size = 2) + y_label("Life expectancy") +
title("Life expectancy across 142 countries, 1952 to 2007")plot(data(gm_all), x(col.year), y(col.life),
layer(ribbon, range(0.1, 0.9)),
style({ color: "steelblue", opacity: 0.2 }),
layer(ribbon, range(0.25, 0.75)),
style({ color: "steelblue", opacity: 0.35 }), layer(line, median),
style({ color: "steelblue", size: 2 }), y_label("Life expectancy"),
title("Life expectancy across 142 countries, 1952 to 2007"))“Given all the gapminder years: x is year, y is life, a ribbon derived by range from 0.1 to 0.9, and also a ribbon derived by range from 0.25 to 0.75, and also a line derived by median.”
The pale band contains the middle 80% of countries. The darker one contains the middle half, and the line is the median. The plot then says something the min–max version cannot. The median rose from 45 years to 72, and the 90th percentile from 67 to 80, so the distance between them fell from 22 years to 8.
The lower edges of the two bands do not rise steadily. Both lower edges, the 10th percentile and the 25th, decline between 1992 and 2002 while the median rises throughout. A min–max band cannot show this, because its lower edge is one country.
Each band is one more layer, so a third pair adds a third. Write the widest band first, because a later layer is painted over an earlier one.
15.4 Why a ribbon needs a transform
A low and a high are two numbers, and a plain y() supplies one. An area closes on zero, which the grammar supplies. A ribbon has no such second number, so like interval its minimum syllable includes a range-producing transform. Ask for one without it and the engine says so rather than drawing an empty panel, as What it refuses shows.
This is the same design choice that keeps the channel vocabulary small. The alternative would add ymin and ymax as two more channels meaningful to a single mark. Instead the boundaries are invented by a transform, exactly as bin invents a count. The same low and high rows then serve the whisker, the box and the band alike. See Transforms for the range, confidence and deviation statistics.
Read the requirement as where do the two boundaries come from? rather than as a list of transforms. One answer looks nothing like the others: density over a category gives a ribbon its boundaries by reflection. That is the violin, at the end of this chapter.
15.5 A confidence band
How well is the mean life expectancy known in each year? That is a different question from how far the five countries spread. range shows the full spread. For the uncertainty of the mean, use confidence. It computes a t-interval for the mean at each x, and the ribbon fills between its two bounds. The interval widens when the group is small:
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"))“Given gapminder Asia: a ribbon derived by confidence at 95%, x is year, y is life.”
The band is wide because each mean comes from only five countries. Those five differ by more than fifteen years, so the interval around their mean is wide. A higher level draws a wider band still. The statistic you ask for chooses the band’s meaning: the observed spread with range, the uncertainty of the mean with confidence. There is no new mark to learn, which is the rule the interval family follows.
15.6 A pre-computed band
range, confidence and deviation compute the band from raw values in the plot. But most confidence bands are not computed in the plot at all. A model’s standard error, a standard error of measurement, a bootstrap interval: each is computed before the plot, and its bounds arrive as two columns. gog draws what you computed; it does not re-fit the model. The score_band table holds one row per raw test score, with the score a model predicts for it and the two edges of the band around that prediction:
| score | expected | lower | upper |
|---|---|---|---|
| 0 | 0 | -1.610304 | 1.610304 |
| 1 | 1 | -1.865557 | 3.865557 |
| 2 | 2 | -1.944424 | 5.944424 |
| 3 | 3 | -1.694803 | 7.694803 |
| 4 | 4 | -1.259232 | 9.259232 |
The ribbon reads those edge 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"))“Given the score band: a ribbon from lower to upper, x is score.”
bounds is range’s counterpart, and it computes nothing. It reshapes the two columns you give it into the same low and high pair range produces. The band needs no y(), because the two columns are the boundaries. It serves the case range, confidence and deviation cannot: a band computed before the data reached the plot.
It is usually drawn with the curve the band surrounds. Here that curve is the expected true score, the score the model predicts for each raw score. A dashed line draws it, with a point at every raw score. The band, the line and the points are three layers 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 measurement error 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 measurement error 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 measurement error 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 measurement error band"))“Given the score band: x is score, y is expected, a ribbon from lower to upper, and also a line and points.”
The dashed line is style(pattern = ): on a stroke the texture is a dash, so it applies to the line here, not to the band. The band takes the same setting, with different values. On a fill the texture is a hatch: "hatch", "crosshatch", "grid" or "dots", never "dashed".
15.7 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. That is the unfilled band many journals prefer, and with style(pattern = ) it becomes the dashed pair of curves they often draw:
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"))“Given the score band: x is score, y is expected, lines from lower to upper, and also a line and points.”
line * bounds is one layer that draws both edges, splitting the pair into a low curve and a high one. It therefore needs no second y. Two plain line layers can draw two columns you already have. They cannot split a pair that range computes, because that pair never becomes a column you can name. line * bounds reads any pair the same way: line * range traces a min–max envelope, line * confidence the two edges of a confidence interval, and step * bounds draws them as staircases. Filled or unfilled, solid or dashed, it is one pair and a choice of mark, never a new option added to the band.
15.8 One band per group
How does the spread of one continent compare with another’s, year by year? One band per continent puts the three ranges in one panel. 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:
| 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_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"))“Given the gapminder continents: ribbons derived by range, x is year, y is life, color by continent.”
The bands overlap where two continents cover the same range of life expectancy. A split area stays opaque, and stack is its answer. A ribbon instead 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. Overlapping bands are read through transparency, never stacked, which is why ribbon * stack is refused and the refusal names style(opacity = ).
15.9 One region, one fill
A ribbon is a single filled region, so opacity is a setting here, not a channel, exactly as on area. Each row of the table is one corner of a boundary, not a piece of the fill. No part of the band belongs to one row, so a column cannot make one row lighter or darker than another. What it refuses shows the refusal.
And size cannot even be set: a band’s extent comes from its x and its two boundaries, so there is no width to choose. Its edge, if you want one, is a line layered along a boundary (ribbon + line, the composition the pre-computed band drew above), never an outline option on the mark.
15.10 A category on the domain
The spread of each continent and its center can be drawn as one band across the categories. It is then read as a shape, the way an area’s filled profile is. A ribbon takes a category on x, the same way an area does, and fills the spread across the categories in axis order:
| country | continent | year | life | population | gdp |
|---|---|---|---|---|---|
| Afghanistan | Asia | 2007 | 43.828 | 31889923 | 974.5803 |
| Albania | Europe | 2007 | 76.423 | 3600523 | 5937.0295 |
| Algeria | Africa | 2007 | 72.301 | 33333216 | 6223.3675 |
| Angola | Africa | 2007 | 42.731 | 12420476 | 4797.2313 |
| Argentina | Americas | 2007 | 75.320 | 40301927 | 12779.3796 |
data(gapminder_2007) + 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"))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. A ribbon instead slopes from one category to the next, across positions where no rows exist. The grammar does not decide that for you: both sentences are well formed, and which one fits 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"))“Given gapminder 2007: intervals derived by range, x is continent, y is life.”
15.11 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 room for that shape: 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"))“Given gapminder 2007: ribbons derived by density, x is continent, y is life.”
Nothing new was added to the grammar to draw it. A ribbon closes on a second boundary the data supplies, and a violin closes on the mirror image of its own curve. density is the transform that estimates a distribution, and this plot 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 summarize. Africa’s violin has two peaks, which its 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"))“Given gapminder 2007: boxes, x is continent, y is life.”
15.11.1 What the width means
Every group’s density integrates to 1 on its own. Left at that, the widths would say nothing about how many rows each group has. A continent of two countries would be drawn as wide 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 holds two countries, so its violin is drawn narrow.
compare = "shape" removes that weighting and draws every violin to the same area. It is the right reading when the question is about the shapes alone. It is the wrong one when a small group’s narrow, tall estimate sets the width for every other violin:
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"))“Given gapminder 2007: ribbons derived by density compared by shape, x is continent, y is life.”
Two countries now fill as much space as fifty-two. Two nearby values make a tall narrow estimate, so Oceania sets the width and every other violin is drawn thinner. Both plots are honest about something different, which is why the choice is a word you write rather than a default nobody stated.
15.11.2 Sideways, and halved
The category can go on either axis, and the violins turn to follow it. The orientation is read off the bindings, exactly as a bar’s or a box’s is. A category on y 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"))An area draws the same estimate closed on the slot’s center line instead of on its own reflection. One side rather than two 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"))“Given gapminder 2007: areas derived by density, x is continent, y is life.”
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"))Nothing was added for it either. It is the half violin’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 named chart is simply one of them.
Read the four plots together and you can see what orthogonality buys. 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.11.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 stays empty. That is a bar’s rule, and for a bar’s reason: the gap is what says the categories are separate rather than a divided continuum.
The ridgeline as it is usually drawn (the form some call a joyplot) does not stay inside its slot. Its ridges are tall enough to reach into the row above, and that overlap is part of the design. 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"))“Given gapminder 2007: areas derived by density reaching 2.5 slots, x is life, y is continent.”
The number is measured from the line a category sits on to the shape’s furthest point. That is why the number means the same thing to both marks. A ribbon reaches that far on each side, an area on one side only. The half violin therefore stays exactly half of the violin, at any reach. 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 the ridges. 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 stand in its slot, so the axis adds the room it needs. Without that room the panel edge would cut the top ridge off, and you would see the cut.
The edge is a layer here, as it is for every filled band in this chapter. A ribbon takes no border setting, and neither does the violin. A line given the same transform traces the outline of the fill. That is the filled, or two dashed edges rule again, read 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"))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"))The color(continent) is written after the area, so it belongs to that layer alone: a plot-scoped one would color 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.12 What you can set
A setting changes how the band looks without reading a column, and each mark takes its own. These are a ribbon’s, with the values each accepts:
| Setting | Value |
|---|---|
style(color = ) |
any CSS color name or hex |
style(opacity = ) |
0 to 1 |
style(pattern = ) |
solid, hatch, crosshatch, grid, dots |
A ribbon takes area’s list, and for area’s reasons. There is no size, because the band’s extent comes from the two boundaries its transform supplies. There is no border, because the edge is a line traced along the same pair.
Here is the textured band with a line along both of its 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")(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"))“Given the score band: x is score, a ribbon from lower to upper, with pattern grid, and also lines from lower to upper, with pattern dashed.”
Both marks read the same bounds(lower, upper) there, exactly as in the ridge section, so the edges stay on the band. 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.
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.
15.13 What it refuses
The measure stays numeric. A band from range spans a low value to a high one, and both are quantities, so a category on y is refused. The violins above put a category on y, but there density makes it a slot rather than the measure:
data(gapminder_2007) + ribbon * range + x(life) + y(continent)data(gapminder_2007) + ribbon * range + x(col.life) + y(col.continent)data(gapminder_2007) + ribbon * range + x(:life) + y(:continent)plot(data(gapminder_2007), layer(ribbon, range), x(col.life),
y(col.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.
A bare ribbon is the second refusal. A plain y() supplies one number, and a band needs two boundaries, as Why a ribbon needs a transform explained. The engine says so rather than drawing an empty panel:
data(gapminder_asia) + ribbon + x(year) + y(life)data(gapminder_asia) + ribbon + x(col.year) + y(col.life)data(gapminder_asia) + ribbon + x(:year) + y(:life)plot(data(gapminder_asia), ribbon, x(col.year), y(col.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.
A mapped opacity is the third. Each row is one corner of a boundary, so no part of the band belongs to a single row, as One region, one fill explained. Opacity is set with style(opacity = ) and never mapped:
data(gapminder_asia) + ribbon * range + x(year) + y(life) + opacity(gdp)data(gapminder_asia) + ribbon * range + x(col.year) + y(col.life) + opacity(col.gdp)data(gapminder_asia) + ribbon * range + x(:year) + y(:life) +
opacity(:gdp)plot(data(gapminder_asia), layer(ribbon, range), x(col.year), y(col.life),
opacity(col.gdp))Error:
! gog: `opacity` cannot be bound to `ribbon` — a ribbon has no opacity feature. Remove the `gdp` mapping from `opacity`, or use a mark that has one.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
ribbon completes the low/high family: interval draws the pair as a whisker at each x, line and step trace its two boundaries, and ribbon fills between them. One range, four marks: No Exceptions made visible. See Transforms for the range, confidence and deviation statistics all four share.