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 helps you with 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 tables in Quick reference are for later, when you have data in front of you and want the row that fits.

If you have used ggplot2, a mark is similar to a geom, but the two differ in one way. 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.

Each column of your data is one variable, and gog reads its type from the data. There are two types:

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? You can cut a continuous column into bands, or estimate a smooth curve 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 sharp changes in height from one bar to the next hide 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 becomes: how often does each category occur? 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.”

A count does not say what part of the whole each continent is. Africa holds 52 of the 142 countries, which is more than a third of them. 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("Shares: bar * proportion")
(data(gapminder_2007) + bar * proportion + x(col.continent) +
  title("Shares: bar * proportion"))
data(gapminder_2007) + bar * proportion + x(:continent) +
  title("Shares: bar * proportion")
plot(data(gapminder_2007), layer(bar, proportion), x(col.continent),
  title("Shares: bar * proportion"))
Asia Europe Africa Americas Oceania 0.0 0.1 0.2 0.3 Shares: 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 people in richer countries live longer? point places one glyph per row, and the shape those glyphs make is the first answer:

# Is there a relationship? → scatter
data(gapminder_2007) + point + x(gdp) + y(life) + x_label("GDP per person") +
  y_label("Life expectancy") + title("Relationship: point")
(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + x_label("GDP per person") +
  y_label("Life expectancy") + title("Relationship: point"))
data(gapminder_2007) + point + x(:gdp) + y(:life) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Relationship: point")
plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  x_label("GDP per person"), y_label("Life expectancy"),
  title("Relationship: point"))
0 10K 20K 30K 40K 50K 40 50 60 70 80 Relationship: point Life expectancy GDP per person

“Given gapminder 2007: points, x is gdp, y is life.”

Whether the points rise or fall together can matter more than any one country. smooth fits a trend line through the points, and line * smooth draws it over the scatter. color(continent) is written right after point, so it colors only the points, and one line is fitted to 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 person") + 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 person") + 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 person") + 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 person"),
  y_label("Life expectancy"), title("Trend: point + line * smooth"))
0 10K 20K 30K 40K 50K 40 50 60 70 80 Trend: point + line * smooth Life expectancy GDP per person Continent Asia Europe Africa Americas Oceania

“Given gapminder 2007: x is gdp, y is life, points colored by continent, and also a line derived by smooth.”

A question about two continuous columns is not always about a relationship. When one column is time and the other an amount, the question can be how much there was. The actuals table records yearly sales:

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.

Sometimes each x has a spread to show, not one value. ribbon fills between a low boundary and a high one, and a range transform supplies that pair: the minimum and maximum at each x. Name two quantiles and range uses those instead. Add a line * mean over the ribbon, and the line of means is drawn inside the 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, here for five Asian countries.

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)"))
0 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 the marks above, rule draws a straight stroke at one value on one axis, across the whole of the other: a threshold line, or a rug. A rug is a short tick at the edge of the panel for each observation, so it shows where the observations are. style(reach = "edge") shortens a rule into a rug.

How many countries have a life expectancy below 60, 70 or 80 years, and where are they on the income axis? life_bands holds three threshold values of life expectancy, each with a name:

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"))
0 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, colored 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. A rule marks a value on one axis and spans the other, so it has to say which. This rule reads the scatter’s table, which holds both gdp and life, so either column could place it. Without x(gdp), 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.

text is also layered over other marks: it writes a label from a column at each position where point would draw a dot. Layered under the other marks, zone shades a rectangle. On an axis where you give it a pair of columns, the rectangle starts at the value in one column and ends at the value in the other. On an axis where you give it no pair, the rectangle spans the whole panel. Write zone * bin instead, and the same mark covers the panel with a grid of cells and counts the rows in each: the heatmap.

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 slot hides two things: how many dots it holds, and how they are spread.

There are three ways to draw a crowded group more clearly, and each shows something different. For the shape of each group rather than every point, write box + x(continent) + y(life). box draws a box between the quartiles, a line at the median, whiskers, and outliers as separate dots. ribbon * density draws the whole estimated distribution that the box summarizes: 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 value changes from one group to the next. line, area, step and ribbon accept a category on x too. They join one summary per group instead of drawing a bar on each, and the result is a profile:

# 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. A profile needs more care than a bar, because 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 that advice says not to draw this plot. 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).

line is not the only mark that draws a profile. area fills under the same boundary, step holds each value flat until the next category, and ribbon * range fills the spread as a band. Drawn around a circle, the same line * mean profile is the radar chart, in Polar.

7.3 Quick reference

The table below holds the common cases, one question per row. Start from the types of the columns you put on x and y, and find the rows that match. Each row names the mark and transform that answer its question. This chapter drew plots for the most common rows, and each mark’s own chapter covers the rest. A — means that no column of yours goes on that position.

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?
Continuous Continuous path The order of the rows?
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?

The table has no row for edge, because its question has no x and no y: what is connected to what? edge draws one stroke 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 stroke should be placed, 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 has only two columns, both categorical, so count supplies the third number: how many rows have each pair. space() gives that count an axis of its own.

x y z Mark Question
Continuous Continuous Continuous point Relationship, in three dimensions?
Continuous Continuous Continuous path A route through three dimensions?
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?

7.3.1 Swapping x and y

Long category names, such as country names, are easier to read on the vertical axis than along the bottom. In the first table, each Categorical / Continuous row whose mark is drawn in a slot also works with the two axes swapped. bar, box, interval, point and the violin (ribbon * density) are all drawn in a slot on one axis and measure along the other. The column types decide which axis holds the slots: the one with the categorical column. 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), on the medals table, is 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 does.

Only the marks with a slot can be drawn horizontally this way. For line, step and area, x is always the domain, the axis the mark is read along, and y is the measure. The same is true of a ribbon drawn as a band rather than as a violin. Put the category on y for any of them, and gog refuses the sentence. Their two columns are often both numbers, so no column type could say which one is the domain, and the mark decides it instead. The line chapter shows what a swap of two continuous columns draws, and what to write when the domain must be the vertical axis.

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: x, y, color, size and the rest. If one mark requires, accepts or refuses a channel, the other mark does the same. They differ in the geometry they draw:

Family Same rules for every channel They differ in
area and ribbon ✓ area closes its region on the baseline; ribbon closes it on a second data boundary
box and interval ✓ box computes its own summary of each group; interval spans two ends a transform supplies
line and step ✓ line slopes from one value to the next; step holds each value until it changes

The second member of a pair is quick to learn. It follows the same rules for every channel as its sibling, so you already know those. The grid of marks and channels in Combinations shows this: the two marks of a family have identical rows there. What is left is what the second mark draws. ribbon and interval also need a transform, such as range, to supply their two ends.

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 added as a mark, because style(size = ) already says it. A new chart is never the reason. A histogram, a radar and a pyramid are built from 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, but gog never adds a mark only to complete a pair.

7.5 Unpronounceable combinations

Just as some letter combinations cannot be pronounced, some pairs of a transform and a column type have no meaning. gog refuses each one and draws nothing.

bin, density and smooth all work along a continuous axis, so the column on that axis must be continuous. A category on that axis is refused, and each refusal names the atom that does the same job for categories:

Combination Problem Use instead
bar * bin + x(category) bin cuts a continuous axis into bands 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 appear as live refusals in What bin, density and smooth refuse; the table above is this page’s summary of them.

7.6 What comes next

Choosing the mark is the first decision in a plot, not the last one. Point opens the fourteen chapters that follow, each describing one mark: its channels, its transforms, its settings and its refusals.