7  Choosing a mark

Which visible form should your data take? A mark is the visible form your data takes: a dot, a bar, a line. Choosing the right mark is the first question in every plot. The answer depends on the types of your variables and the question you are asking.

If you come from ggplot2, a mark is close to a geom, and one difference is worth knowing early. Many geoms carry a statistic inside them. 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, and there are thirteen of them.

gog infers variable type automatically from your data:


7.1 One variable

7.1.1 Continuous variable

# 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.”

# 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)

7.1.2 Categorical variable

# 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
# 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

7.2 Two variables

7.2.1 Continuous × Continuous

Both variables are numeric. The natural questions are about relationships, trends, and patterns.

# 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
# Smooth the relationship → LOESS 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
# 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

line and area draw the same boundary and mean different things by it. A line says where the value went; an area says how much there was, so it fills to zero and the quantity is the ink. Choose area when zero is a real number for your variable, and line when it is not.

When there is a spread to show at each x, such as a min–max range or a confidence band, ribbon fills between a low and a high boundary that a range transform supplies. Layer a line * mean through it and you have the familiar trend with a band around it.

And when the order of the rows is itself the story, path connects them in the table’s order rather than sorting by x, so the stroke may double back or return where it began. That is the connected scatterplot: two measurements on the axes and time in the joining.

# 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 head is 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 head is 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 head is 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 head is 2007)"))
0K 10K 20K 30K 40 50 60 70 80 Route: path (the head is 2007) Life expectancy GDP per person Country China India Indonesia Japan Korea, Rep.

Layered over any of these, rule marks a value on one axis and spans the other: a threshold line, or, reaching only a little way in from the edge, a rug showing where the observations actually sit. 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. Let bin cut the pairs instead of naming them and the same mark tiles the panel with counted cells, which is a heatmap.

# One position, the panel supplies the other → rule
data(gapminder_2007) + point + x(gdp) + y(life) + style(opacity = 0.45) +
  data(gdp_rug) + rule + 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) +
  data(gdp_rug) + rule + 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) + data(gdp_rug) + rule + 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 }), data(gdp_rug), rule, 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

7.2.2 Categorical × Continuous

One axis groups, the other measures. The natural question is how the measurement differs across groups.

# 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
# 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

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 to the extremes. ribbon * density draws the whole estimated distribution that summary is a summary of, which is the violin. When the points are the picture but overlap hides them, point * jitter spreads the pile sideways, the jittered strip plot.

The path and region marks take a category here too, joining one summary per group instead of standing 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

Bars compare, profiles trace. A profile is the weaker claim of the two, since the segments run through space where nothing was measured. It is worth drawing when the categories have a direction, such as an ordered scale or a size band, or no direction at all. A closed set of categories bent into a circle is the radar, in Polar. area fills the same boundary, step holds it flat across each slot, and ribbon * range bands the spread.


7.3 Quick reference

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 * 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 The same, filled?
Categorical Continuous line * mean + polar() The same, as a radar?

Three variables put a third position in the sentence rather than a new mark family:

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)
Categorical Categorical bar * bin + space() How many, per pair?

Every Categorical / Continuous row above can be read the other way round. bar, box and interval sit in a slot on one axis and measure along the other, and they read which is which off the bindings. Swapping the two positions therefore lays the chart on its side. box + x(life) + y(continent) is the horizontal box plot, and bar + x(gold) + y(country) the horizontal bar. That is the whole of it, because there is no flip atom to learn.


7.4 Marks come in families

The mark list looks like a set to memorize, one entry at a time. It is not, for the same reason the consonants of Hangeul (한글) are not fourteen shapes: the letters are featural, built so that most of them are another letter with a single feature changed. ㅋ (k) is ㄱ (g) with a stroke added, and once you know ㄱ you have most of ㅋ already.

The marks are built the same way. Some pairs share their entire channel row, meaning everything they require, accept and refuse of your columns is identical, and they part only on the geometry they draw:

Family Identical channel row They part only on
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 is nearly free to learn. You already know every question it will ask of your data, because it asks exactly the ones its sibling asks, so what is left is a single sentence about what it draws. The families sit close to one another too, often a single cell apart, which is where the structure visible in Combinations comes from.

This is a fact about learning the set, never a license to grow it. A mark that is another mark with one feature changed belongs only when that feature is a geometry nothing else can express. step qualifies, because “hold the value until it changes” is not something a channel, a transform or a setting can say. A thick line does not, because style(size = ) already says it.

Marks are Lego bricks, and this rule is what that comparison is good for. You do not get a new brick because you want to build a new model. Almost every model is a new arrangement of bricks you already have. A new brick is cut only when the shape you need is one that no existing brick has. That is the test a mark has to pass here: a new geometry, never a new chart.


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: a plot you can see is a plot the grammar agreed to.

The three distributional transforms need a number line to spread along, so a category is refused on the axis they read, 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)
line * density + x(category) density estimates a continuous distribution bar * proportion + x(category)
point * smooth + x(category) smooth fits a curve along a number line bar * mean + x(category) + y(value)

All three are rendered as live refusals in What bin, density and smooth refuse, so the messages on this page cannot drift from the ones the engine actually gives.