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:


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:

gapminder_2007: first 5 of 142 rows
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
# 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"))
50 60 70 80 0 10 20 30 Distribution: bar * bin Count Life expectancy (years)

“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"))
40 60 80 0.00 0.01 0.02 0.03 0.04 Density: line * density Density Life expectancy (years)

“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"))
Asia Europe Africa Americas Oceania 0 20 40 Frequency: bar * count Count Continent

“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"))
Asia Europe Africa Americas Oceania 0.0 0.1 0.2 0.3 Relative frequency: bar * proportion Proportion Continent

“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"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Relationship: point Life expectancy GDP per capita

“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"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Trend: point + line * smooth Life expectancy GDP per capita Continent Asia Europe Africa Americas Oceania

“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:

actuals: all 5 rows
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"))
2019 2020 2021 2022 2023 0 50 100 150 Quantity: area Sales Year

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

gapminder_asia: first 5 of 60 rows
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)"))
0K 10K 20K 30K 40 50 60 70 80 Route: path (the arrow points to 2007) Life expectancy GDP per person Country China India Indonesia Japan Korea, Rep.

“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_bands: all 3 rows
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"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Threshold and rug: rule Life expectancy GDP per person Band Low Middle High

“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"))
Asia Europe Africa Americas Oceania 0 20 40 60 80 Group summary: bar * mean Mean life expectancy Continent

“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)"))
Asia Europe Africa Americas Oceania 40 50 60 70 80 Strip plot: point (one dot per country) Life expectancy Continent

“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"))
Asia Europe Africa Americas Oceania 60 70 80 Profile: line * mean Mean life expectancy Continent

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