| 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 |
7 Choosing a mark
Which visible form should your data take? A mark is that visible form: a dot, a bar, a line. Choosing the mark is the first decision in every plot. The answer depends on the types of your variables and the question you are asking.
There are fourteen marks, and they are not a list to learn before you draw. This chapter is a guide to the choice: it shows which mark answers which question, draws the common cases, and stops. Each mark then has a chapter of its own, with examples. The table at the end is for later, when you have data in front of you and want the row that fits.
If you have used ggplot2, a mark is close to a geom, and one difference is worth knowing early. Many geoms compute a statistic before they draw. geom_histogram() bins your data and then draws bars. geom_smooth() fits a curve and then draws a line. A mark never does that. A mark is only the shape, and the statistic is a separate word: a histogram is bar * bin, a trend line is line * smooth. A geom tends to name a chart. A mark names a shape.
gog reads the type of each column from your data:
- Continuous. A column that holds numbers (
gdp,temperature,score). - Categorical. A column that holds text (
continent,country,species), or a column with a declared order. - Date/time. Any date or date-time column; the axis reads as a calendar (see Scales).
7.1 One variable
With one variable there is one question: how are its values spread out? The answer depends on whether the column is continuous or categorical.
7.1.1 Continuous variable
Are the life expectancies of 142 countries close to one value, or spread across a wide range? A continuous column can be cut into bands, or a smooth curve can be estimated over it. bin cuts the column into bands and counts the rows in each. bar * bin draws one bar per band, which is the histogram:
# How is life expectancy distributed? → histogram
data(gapminder_2007) + bar * bin + x(life) +
x_label("Life expectancy (years)") + title("Distribution: bar * bin")(data(gapminder_2007) + bar * bin + x(col.life) +
x_label("Life expectancy (years)") + title("Distribution: bar * bin"))data(gapminder_2007) + bar * bin + x(:life) +
x_label("Life expectancy (years)") + title("Distribution: bar * bin")plot(data(gapminder_2007), layer(bar, bin), x(col.life),
x_label("Life expectancy (years)"), title("Distribution: bar * bin"))“Given gapminder 2007: bars derived by bin, x is life.”
When the jumps between bars distract from the shape, a curve shows the same spread without them. density estimates a smooth curve over the same column, and line * density draws that curve:
# Smooth shape of the distribution → density curve
data(gapminder_2007) + line * density + x(life) +
x_label("Life expectancy (years)") + title("Density: line * density")(data(gapminder_2007) + line * density + x(col.life) +
x_label("Life expectancy (years)") + title("Density: line * density"))data(gapminder_2007) + line * density + x(:life) +
x_label("Life expectancy (years)") + title("Density: line * density")plot(data(gapminder_2007), layer(line, density), x(col.life),
x_label("Life expectancy (years)"), title("Density: line * density"))“Given gapminder 2007: a line derived by density, x is life.”
7.1.2 Categorical variable
A categorical column cannot be cut into bands, so the same question reads as how often each category occurs. count tallies the rows in each category, and bar * count draws one bar per category:
# How many countries per continent? → frequency bar
data(gapminder_2007) + bar * count + x(continent) +
title("Frequency: bar * count")(data(gapminder_2007) + bar * count + x(col.continent) +
title("Frequency: bar * count"))data(gapminder_2007) + bar * count + x(:continent) +
title("Frequency: bar * count")plot(data(gapminder_2007), layer(bar, count), x(col.continent),
title("Frequency: bar * count"))“Given gapminder 2007: bars derived by count, x is continent.”
Africa’s 52 countries are easier to compare with the other continents as a share of all 142. proportion is the same tally as a share of the whole, so the bars sum to 1:
# What share of countries per continent? → proportion bar
data(gapminder_2007) + bar * proportion + x(continent) +
title("Relative frequency: bar * proportion")(data(gapminder_2007) + bar * proportion + x(col.continent) +
title("Relative frequency: bar * proportion"))data(gapminder_2007) + bar * proportion + x(:continent) +
title("Relative frequency: bar * proportion")plot(data(gapminder_2007), layer(bar, proportion), x(col.continent),
title("Relative frequency: bar * proportion"))“Given gapminder 2007: bars derived by proportion, x is continent.”
7.2 Two variables
With two variables, the pair of column types decides the mark.
7.2.1 Continuous × Continuous
Both variables are continuous, so the questions are about relationships, trends and patterns. Do richer countries live longer? point places one glyph per row, and the shape they make is the first answer:
# Is there a relationship? → scatter
data(gapminder_2007) + point + x(gdp) + y(life) + x_label("GDP per capita") +
y_label("Life expectancy") + title("Relationship: point")(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + x_label("GDP per capita") +
y_label("Life expectancy") + title("Relationship: point"))data(gapminder_2007) + point + x(:gdp) + y(:life) +
x_label("GDP per capita") + y_label("Life expectancy") +
title("Relationship: point")plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
x_label("GDP per capita"), y_label("Life expectancy"),
title("Relationship: point"))“Given gapminder 2007: points, x is gdp, y is life.”
The direction of the cloud can matter more than any one country in it. smooth fits a trend line through the points, and line * smooth draws it over the scatter. The points are colored by continent here, and the one line runs through all of them:
# Smooth the relationship → fitted trend line
data(gapminder_2007) + x(gdp) + y(life) +
point + color(continent) +
line * smooth +
x_label("GDP per capita") + y_label("Life expectancy") +
title("Trend: point + line * smooth")(data(gapminder_2007) + x(col.gdp) + y(col.life) +
point + color(col.continent) +
line * smooth +
x_label("GDP per capita") + y_label("Life expectancy") +
title("Trend: point + line * smooth"))data(gapminder_2007) + x(:gdp) + y(:life) + point + color(:continent) +
line * smooth + x_label("GDP per capita") + y_label("Life expectancy") +
title("Trend: point + line * smooth")plot(data(gapminder_2007), x(col.gdp), y(col.life), point,
color(col.continent), layer(line, smooth), x_label("GDP per capita"),
y_label("Life expectancy"), title("Trend: point + line * smooth"))“Given gapminder 2007: x is gdp, y is life, points, color by continent, and also a line derived by smooth.”
The actuals table records yearly sales, and the question about an amount is how much of it there was:
| year | sales |
|---|---|
| 2019 | 120 |
| 2020 | 135 |
| 2021 | 128 |
| 2022 | 152 |
| 2023 | 168 |
# How much, not just where → filled area
data(actuals) + area + x(year) + y(sales) + y_label("Sales") +
title("Quantity: area")(data(actuals) + area + x(col.year) + y(col.sales) + y_label("Sales") +
title("Quantity: area"))data(actuals) + area + x(:year) + y(:sales) + y_label("Sales") +
title("Quantity: area")plot(data(actuals), area, x(col.year), y(col.sales), y_label("Sales"),
title("Quantity: area"))“Given the actuals: an area, x is year, y is sales.”
line and area draw the same boundary and give it different meanings. A line says where the value was; an area says how much there was, so it fills to zero and the filled region is the quantity. Choose area when zero is a true baseline for your variable, and line when it is not.
ribbon fills between a low boundary and a high one. A range transform supplies that pair: the minimum and maximum at each x, unless you name two quantiles. Use ribbon when each x has a spread to show. Add a line * mean on top and you get a trend line inside its band.
When the order of the rows is what you want to show, path connects them in that order. line sorts its points by x before it joins them. path does not, so the stroke can move left as well as right, cross itself, or return to where it started. That is the connected scatterplot: the two axes carry measurements, and the joining line shows the order in time.
| 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 |
# The reading order is the table's → path
data(gapminder_asia) + path + x(gdp) + y(life) + color(country) +
style(arrow = "end") +
x_label("GDP per person") + y_label("Life expectancy") +
title("Route: path (the arrow points to 2007)")(data(gapminder_asia) + path + x(col.gdp) + y(col.life) + color(col.country) +
style(arrow = "end") +
x_label("GDP per person") + y_label("Life expectancy") +
title("Route: path (the arrow points to 2007)"))data(gapminder_asia) + path + x(:gdp) + y(:life) + color(:country) +
style(arrow = "end") + x_label("GDP per person") +
y_label("Life expectancy") +
title("Route: path (the arrow points to 2007)")plot(data(gapminder_asia), path, x(col.gdp), y(col.life),
color(col.country), style({ arrow: "end" }), x_label("GDP per person"),
y_label("Life expectancy"),
title("Route: path (the arrow points to 2007)"))“Given gapminder Asia: paths, x is gdp, y is life, color by country, with arrow end.”
Layered over any of these, rule marks a value on one axis and spans the other: a threshold line, or a rug. A rug reaches only a little way in from the edge, which is style(reach = "edge"), and shows where the observations are. Layered under them, zone shades a rectangle the same way: bounded where you give it a pair of columns, spanning the panel where you do not. Give zone a bin instead of the pair of columns, and the same mark tiles the panel with counted cells: the heatmap.
How many countries fall below 60, 70 or 80 years, and where along the income axis do they gather? life_bands holds three named bands of life expectancy:
| life | band |
|---|---|
| 60 | Low |
| 70 | Middle |
| 80 | High |
# One position, the panel supplies the other → rule
data(gapminder_2007) + point + x(gdp) + y(life) + style(opacity = 0.45) +
rule + x(gdp) + style(reach = "edge") +
data(life_bands) + rule + color(band) +
x_label("GDP per person") + y_label("Life expectancy") +
title("Threshold and rug: rule")(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + style(opacity = 0.45) +
rule + x(col.gdp) + style(reach = "edge") +
data(life_bands) + rule + color(col.band) +
x_label("GDP per person") + y_label("Life expectancy") +
title("Threshold and rug: rule"))data(gapminder_2007) + point + x(:gdp) + y(:life) +
style(opacity = 0.45) + rule + x(:gdp) + style(reach = "edge") +
data(life_bands) + rule + color(:band) + x_label("GDP per person") +
y_label("Life expectancy") + title("Threshold and rug: rule")plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
style({ opacity: 0.45 }), rule, x(col.gdp), style({ reach: "edge" }),
data(life_bands), rule, color(col.band), x_label("GDP per person"),
y_label("Life expectancy"), title("Threshold and rug: rule"))“Given gapminder 2007: points, x is gdp, y is life, and also rules, x is gdp, with reach edge, and also rules from the life bands, color by band.”
The rule names x(gdp) even though the points already named it. That second x(gdp) does not add an axis. Every layer shares the plot’s one x and one y. This rule reads the scatter’s table, which holds both gdp and life, so both axes answer. A rule sits on one axis and spans the other, so it has to say which. Leave that out and gog refuses rather than guessing; the Rule chapter shows the refusal. The three rules from life_bands need no position, because that table holds life and no gdp, so only one axis can answer.
7.2.2 Categorical × Continuous
One axis groups, the other measures. The natural question is how the measurement differs across groups. mean reduces each group to one value, its average. bar * mean draws one bar per group, and the bar’s height is that value:
# Mean value per group → bar
data(gapminder_2007) + bar * mean + x(continent) + y(life) +
y_label("Mean life expectancy") + title("Group summary: bar * mean")(data(gapminder_2007) + bar * mean + x(col.continent) + y(col.life) +
y_label("Mean life expectancy") + title("Group summary: bar * mean"))data(gapminder_2007) + bar * mean + x(:continent) + y(:life) +
y_label("Mean life expectancy") + title("Group summary: bar * mean")plot(data(gapminder_2007), layer(bar, mean), x(col.continent),
y(col.life), y_label("Mean life expectancy"),
title("Group summary: bar * mean"))“Given gapminder 2007: bars derived by mean, x is continent, y is life.”
A mean hides how far apart the countries inside one continent are. Sometimes that spread is what you want to see. Drop the transform, and point draws every country in its continent’s slot:
# All individual points per group → strip plot
data(gapminder_2007) + point + x(continent) + y(life) +
y_label("Life expectancy") +
title("Strip plot: point (one dot per country)")(data(gapminder_2007) + point + x(col.continent) + y(col.life) +
y_label("Life expectancy") +
title("Strip plot: point (one dot per country)"))data(gapminder_2007) + point + x(:continent) + y(:life) +
y_label("Life expectancy") +
title("Strip plot: point (one dot per country)")plot(data(gapminder_2007), point, x(col.continent), y(col.life),
y_label("Life expectancy"),
title("Strip plot: point (one dot per country)"))“Given gapminder 2007: points, x is continent, y is life.”
Nothing is summarized: each dot is one country, placed by its continent and its life expectancy. Dots for countries with the same life expectancy overlap, so a crowded column hides two things: how many dots it holds, and how they are spread. The next paragraph names three marks that show both.
For the shape of each group rather than every point, box draws the five-number summary as box + x(continent) + y(life): median, quartiles, and whiskers, with outliers drawn as separate dots. ribbon * density draws the whole estimated distribution that those five numbers summarize: the violin plot. When every point matters but the points overlap, point * jitter spreads them sideways: the jittered strip plot.
The question is not always which group is highest. It can be how the measure moves from one group to the next. line, area, step and ribbon take a category here too, joining one summary per group instead of drawing a bar on each:
# One value per group, joined → profile
data(gapminder_2007) + x(continent) + y(life) +
line * mean + point * mean +
y_label("Mean life expectancy") +
title("Profile: line * mean")(data(gapminder_2007) + x(col.continent) + y(col.life) +
line * mean + point * mean +
y_label("Mean life expectancy") +
title("Profile: line * mean"))data(gapminder_2007) + x(:continent) + y(:life) + line * mean +
point * mean + y_label("Mean life expectancy") +
title("Profile: line * mean")plot(data(gapminder_2007), x(col.continent), y(col.life),
layer(line, mean), layer(point, mean), y_label("Mean life expectancy"),
title("Profile: line * mean"))“Given gapminder 2007: x is continent, y is life, a line derived by mean and also points derived by mean.”
A bar compares one group with another; a profile traces a change across the groups. The profile is the weaker claim of the two, since its segments cross parts of the panel where nothing was measured. Use it when the categories have an order, such as a rating scale or size groups from smallest to largest. Continents have no order, so this plot does what that advice says not to do, and gog draws it anyway. The grammar refuses a sentence that is ill-formed, never one that is only unwise; whether a profile suits your categories is your judgment (Law 8). The same categories drawn around a circle give the radar chart, in Polar. area fills the same boundary, step holds it flat across each slot, and ribbon * range fills the spread as a band.
7.3 Quick reference
Everything above, in one table. Start from the types of your two columns, x and y, find the rows that match, and read the mark and transform that answer each question. The chapter drew the common rows; every mark has a chapter of its own for the rest. A — means the question needs no variable in that column.
| x | y | Mark + transform | Question |
|---|---|---|---|
| Continuous | — | bar * bin |
Distribution? |
| Continuous | — | line * density |
Smooth distribution? |
| Continuous | — | point * bin * stack |
Distribution, every observation shown? |
| Categorical | — | bar * count |
Frequency? |
| Categorical | — | bar * proportion |
Share? |
| Continuous | Continuous | point |
Relationship? |
| Continuous | Continuous | line |
Trend / time series? |
| Continuous | Continuous | line * smooth |
Smoothed trend? |
| Continuous | Continuous | area |
How much, over a range? |
| Continuous | Continuous | ribbon * range |
Spread / band over a range? |
| Categorical | Continuous | bar |
One value per group, already in the table? |
| Categorical | Continuous | bar * mean/sum/… |
Value per group? |
| Categorical | Continuous | point |
All values per group? |
| Categorical | Continuous | box |
Distribution per group, summarized? |
| Categorical | Continuous | ribbon * density |
Distribution per group, in full? |
| Categorical | Continuous | interval * range |
Spread per group? |
| Categorical | Continuous | line * mean |
Value per group, as a profile? |
| Categorical | Continuous | area * mean |
Value per group, filled? |
| Categorical | Continuous | line * mean + polar() |
Value per group, as a radar? |
One mark is not in the table, because its question has no x and no y: what is connected to what? edge draws one line for each connection in a table of connections, such as who trades with whom. Nothing in the data says where the two ends of that line should sit, so a layout transform inside the network() space places them. The Network chapter builds the whole diagram.
A third variable takes the third position, z, rather than a new mark. The last row below is different: it uses two categories, and space() gives the counts an axis of their own.
| x | y | z | Mark | Question |
|---|---|---|---|---|
| Continuous | Continuous | Continuous | point |
Relationship, in three? |
| Continuous | Continuous | Continuous | path |
A route through three? |
| Continuous | Continuous | Continuous | surface |
A height over a plane? (needs a grid: a z value at every x and y) |
| Categorical | Categorical | — | bar * count + space() |
How many, per pair? |
Every Categorical / Continuous row above can also be read with the two axes swapped. bar, box and interval sit in a slot on one axis and measure along the other. The column types decide which is which: the categorical column gets the slots. Swapping the two positions therefore draws the same plot horizontally. box + x(life) + y(continent) is the horizontal box plot, and bar + x(gold) + y(country) the horizontal bar. Nothing else is needed. If you know ggplot2’s coord_flip() or matplotlib’s barh(), this is the same result with no extra word. gog reads the orientation from which axis carries the categories, as ggplot2 itself now does.
That turn belongs to the marks with a slot, and not to every mark. line, step, area and ribbon read a domain along x and trace a measure up y, and they do not turn on their side when you swap their bindings: their two columns are often both numbers, so no column type could say which is the domain, and the mark fixes it. The line chapter says why, and what to write when the domain has to run up the page.
7.4 Mark families
The mark list looks like a set to memorize, one entry at a time. It is not. The fourteen consonants of Hangeul (한글) are not fourteen unrelated shapes either. The letters are featural: most of them are another letter with a single feature changed. ㅋ (k) is ㄱ (g) with one stroke added, so learning ㄱ teaches you most of ㅋ.
The marks are built the same way. Some pairs have the same rules for every channel: whatever one mark requires, accepts or refuses, for x, y, color, size and the rest, the other does too. They differ only in the geometry they draw:
| Family | Same rules for every channel | They differ only in |
|---|---|---|
area and ribbon |
✓ | a region closed on the baseline, against one closed on a second data boundary |
box and interval |
✓ | a body that computes its own five-number summary, against a span whose ends you supply |
line and step |
✓ | a stroke that slopes between values, against one that holds each value until it changes |
The second member of a pair takes almost nothing to learn. It asks exactly the questions its sibling asks, so you already know all of them. One sentence about what it draws is all that is left. The two marks of a family also sit near each other on the grid in Combinations, which is where the structure you can see on that page comes from.
When does gog add a mark? Only for a shape that no existing mark can draw. step was added because “hold the value until it changes” is such a shape: no channel, transform or setting can say it. A thick line was not, because style(size = ) already says it. A new chart is never the reason. A histogram, a radar and a pyramid are arrangements of marks that already exist. A new word is spelled with the letters you have, and a new chart is written with the marks you have. The families make the marks quick to learn. They are not a reason to add more marks.
7.5 Unpronounceable combinations
Just as some letter combinations cannot be pronounced, some mark + variable type combinations cannot be rendered meaningfully. Each is refused outright, and nothing is drawn: if a plot was drawn, the grammar accepted it.
bin, density and smooth all work along a continuous axis, so the column they measure must be numbers. A category on that axis is refused, and each refusal names the atom that asks the same question of categories:
| Combination | Problem | Use instead |
|---|---|---|
bar * bin + x(category) |
bin cuts a continuous axis into intervals |
bar * count + x(category) |
bar * density + x(category) |
density estimates a continuous distribution |
bar * proportion + x(category) |
point * smooth + x(category) |
smooth fits a curve along a continuous axis |
bar * mean + x(category) + y(value) |
All three are rendered as live refusals in What bin, density and smooth refuse; the table above is this page’s summary of them.
The next fourteen chapters give each mark its own page, starting with point.