9  Line

How did one value change over time? line sorts the rows by x, from smallest to largest, and draws one stroke through their (x, y) points in that order, the x order. The stroke is straight pieces laid end to end, each joining one row’s point to the next, with nothing smoothed between them.

9.1 Basic usage

Everything in this chapter is added to one sentence, the smallest that draws a line: a source, the mark, and its two positions.

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
data(gapminder_asia) + line + x(year) + y(life)
data(gapminder_asia) + line + x(col.year) + y(col.life)
data(gapminder_asia) + line + x(:year) + y(:life)
plot(data(gapminder_asia), line, x(col.year), y(col.life))
1960 1980 2000 40 50 60 70 80 Life Year

“Given gapminder Asia: a line, x is year, y is life.”

The stroke looks wrong, and the plot is correct. All five countries have a row for each year, so the one stroke passes through five values at every year. The vertical segments are where it joins the five countries inside one year, before it continues to the next year. That is exactly what a line does when nothing splits the rows: it draws one series, a single stroke in x order. gog warns about this sentence and says what to add, a color or a group. Multiple series below draws the same table as five lines, one per country.

9.2 Automatic sorting by x

Rows rarely arrive in the order a line needs: a file is sorted by name, or by nothing at all. line sorts its own vertices, the points it joins, by x, so the rows may arrive in any order. The table below is deliberately shuffled, and the line still runs from left to right:

scrambled: all 5 rows
year life
1992 68
1962 52
2002 71
1972 58
1982 63
data(scrambled) + line + x(year) + y(life)
data(scrambled) + line + x(col.year) + y(col.life)
data(scrambled) + line + x(:year) + y(:life)
plot(data(scrambled), line, x(col.year), y(col.life))
1960 1970 1980 1990 2000 55 60 65 70 Life Year

That sorting is part of what the mark means. It is not an extra step that the engine performs for you. A line says this value changed along this axis, and an axis has one direction, so there is only one order the vertices could be joined in.

path is the mark that does the opposite. It joins the rows in the order your table holds them, which is what draws a route or a loop. So choosing between the two is choosing whether the order of your rows carries meaning. With line the row order cannot carry meaning; with path the row order sets the whole shape.

9.3 Multiple series

This is the plot the warning above asked for. The table is the same one the chapter opened with, and one word splits the single stroke into a line per country: color, which also tells the reader which line is which.

data(gapminder_asia) + line + x(year) + y(life) +
  color(country)
(data(gapminder_asia) + line + x(col.year) + y(col.life) +
  color(col.country))
data(gapminder_asia) + line + x(:year) + y(:life) + color(:country)
plot(data(gapminder_asia), line, x(col.year), y(col.life),
  color(col.country))
1960 1980 2000 40 50 60 70 80 Life Year Country China India Indonesia Japan Korea, Rep.

“Given gapminder Asia: lines, x is year, y is life, color by country.”

Each unique value in country becomes a separate line with its own color, and a color legend is generated automatically. group makes the same split without a color or a legend, which the Groups recipe shows, for when the shape of the lines together is the answer and their names are not.

9.4 Layering line over other marks

A line shows the trend and hides where the measurements were. A dot at each row puts them back, which is why line and point are so often combined. Write color before the marks and it belongs to the plot, so both layers use it:

data(gapminder_asia) + x(year) + y(life) + color(country) +
  line + point
(data(gapminder_asia) + x(col.year) + y(col.life) + color(col.country) +
  line + point)
data(gapminder_asia) + x(:year) + y(:life) + color(:country) + line +
  point
plot(data(gapminder_asia), x(col.year), y(col.life), color(col.country),
  line, point)
1960 1980 2000 40 50 60 70 80 Life Year Country China India Indonesia Japan Korea, Rep.

“Given gapminder Asia: x is year, y is life, color by country, lines and also points.”

Both the lines and the points share the same color per country.

9.4.1 Giving each layer its own appearance

You do not have to map a column to color at all. If you want one color for the lines and another for the points, chosen by you rather than read from the data, that is a setting, style(color = ), and no legend appears. group(country) still keeps one line per country: it splits without coloring. style() attaches to the nearest preceding mark and stops there, so each layer can be styled independently:

data(gapminder_asia) + x(year) + y(life) +
  line + group(country) + style(color = "steelblue") +
  point + style(color = "firebrick", size = 3) +
  title("Blue line, red points: styled per layer")
(data(gapminder_asia) + x(col.year) + y(col.life) +
  line + group(col.country) + style(color = "steelblue") +
  point + style(color = "firebrick", size = 3) +
  title("Blue line, red points: styled per layer"))
data(gapminder_asia) + x(:year) + y(:life) + line + group(:country) +
  style(color = "steelblue") + point +
  style(color = "firebrick", size = 3) +
  title("Blue line, red points: styled per layer")
plot(data(gapminder_asia), x(col.year), y(col.life), line,
  group(col.country), style({ color: "steelblue" }), point,
  style({ color: "firebrick", size: 3 }),
  title("Blue line, red points: styled per layer"))
1960 1980 2000 40 50 60 70 80 Blue line, red points: styled per layer Life Year

“Given gapminder Asia: x is year, y is life, lines grouped by country, colored steelblue, and also points, colored firebrick, with size 3.”

Channels scope the same way. Written before any mark, a channel belongs to the plot and reaches every layer, as color(country) did above. Written after a mark, it binds to that mark alone, and not to any mark written after it, so each layer can carry a different encoding. The whole rule, for every atom, is one table in Encoding scope:

data(gapminder_asia) + x(year) + y(life) +
  line  + color(country) +
  point + size(population) +
  title("Color on the lines, size on the points")
(data(gapminder_asia) + x(col.year) + y(col.life) +
  line  + color(col.country) +
  point + size(col.population) +
  title("Color on the lines, size on the points"))
data(gapminder_asia) + x(:year) + y(:life) + line + color(:country) +
  point + size(:population) +
  title("Color on the lines, size on the points")
plot(data(gapminder_asia), x(col.year), y(col.life), line,
  color(col.country), point, size(col.population),
  title("Color on the lines, size on the points"))
1960 1980 2000 40 50 60 70 80 Color on the lines, size on the points Life Year Country China India Indonesia Japan Korea, Rep. Population 20.9M 669.8M 1.3B

“Given gapminder Asia: x is year, y is life, lines, color by country, and also points, size by population.”

That works because channels bind forward only. A channel that also bound backwards would put size on the line, which takes no per-row size, and the plot would be refused. See Encoding scope.

9.5 A category on the domain: the profile

A line’s two axes are not the same kind of thing. x is the domain, the axis the line is read along. y is the measure, the quantity it traces.

A bar turns on its side when you swap its bindings, because its categorical column says which axis holds the slots. A line has no such column. Its two columns are usually both numbers, such as year and life, so the rule fixes the domain: it is always x. The quarterly table holds one sales figure for each year. With the years on x, the line is the plot you expect:

quarterly: first 5 of 19 rows
year sales
2005 95
2006 104
2007 118
2008 112
2009 88
data(quarterly) + line + x(year) + y(sales) +
  title("Years on x: sales read along the years")
(data(quarterly) + line + x(col.year) + y(col.sales) +
  title("Years on x: sales read along the years"))
data(quarterly) + line + x(:year) + y(:sales) +
  title("Years on x: sales read along the years")
plot(data(quarterly), line, x(col.year), y(col.sales),
  title("Years on x: sales read along the years"))
2005 2010 2015 2020 100 150 Years on x: sales read along the years Sales Year

“Given quarterly: a line, x is year, y is sales.”

Sales climbed for most of the years. They fell in 2008 and 2009, and again in 2020, and climbed back after each fall. Swap the two bindings and the line still sorts along x, which is now sales. The stroke starts at 2009, the year with the lowest sales, and puts 2020 between 2012 and 2013, so the years are scrambled:

data(quarterly) + line + x(sales) + y(year) +
  title("The bindings swapped: the line still sorts along x")
(data(quarterly) + line + x(col.sales) + y(col.year) +
  title("The bindings swapped: the line still sorts along x"))
data(quarterly) + line + x(:sales) + y(:year) +
  title("The bindings swapped: the line still sorts along x")
plot(data(quarterly), line, x(col.sales), y(col.year),
  title("The bindings swapped: the line still sorts along x"))
100 150 2005 2010 2015 2020 The bindings swapped: the line still sorts along x Year Sales

“Given quarterly: a line, x is sales, y is year.”

Read as a history of sales, this seems to say that sales fluctuated while climbing. It does not. Each segment joins the two years with the nearest sales, not two consecutive years, so the rises and falls come from the sorting, not from the sales. Only the overall slope survives, and only because sales were mostly higher in later years.

Put a category on y instead and gog refuses it, as What it refuses shows. So there are two cases. When both columns are numbers, the swap is accepted, but you do not get the same plot on its side: the line sorts along x and draws a different picture. When one column is a category, it must be on x.

Sometimes the domain has to run up the page, as depth does in an ocean profile, or as the years do here. Then write path instead. A path joins the rows in the order the table holds them, so a table sorted along the domain draws the vertical series the line could not:

data(quarterly) + path + x(sales) + y(year) +
  title("The same rows, joined in table order")
(data(quarterly) + path + x(col.sales) + y(col.year) +
  title("The same rows, joined in table order"))
data(quarterly) + path + x(:sales) + y(:year) +
  title("The same rows, joined in table order")
plot(data(quarterly), path, x(col.sales), y(col.year),
  title("The same rows, joined in table order"))
100 150 2005 2010 2015 2020 The same rows, joined in table order Year Sales

“Given quarterly: a path, x is sales, y is year.”

The domain takes a category as readily as a number, and connecting one summary per category is the profile plot:

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
data(gapminder_2007) + line * mean + point * mean + x(continent) + y(life) +
  y_label("Mean life expectancy") +
  title("line * mean + x(continent): one value per category, joined")
(data(gapminder_2007) + line * mean + point * mean + x(col.continent) + y(col.life) +
  y_label("Mean life expectancy") +
  title("line * mean + x(continent): one value per category, joined"))
data(gapminder_2007) + line * mean + point * mean + x(:continent) +
  y(:life) + y_label("Mean life expectancy") +
  title("line * mean + x(continent): one value per category, joined")
plot(data(gapminder_2007), layer(line, mean), layer(point, mean),
  x(col.continent), y(col.life), y_label("Mean life expectancy"),
  title("line * mean + x(continent): one value per category, joined"))
Asia Europe Africa Americas Oceania 60 70 80 line * mean + x(continent): one value per category, joined Mean life expectancy Continent

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

The transform is what makes that plot readable. Without one, a line connects all 142 rows across five category slots:

data(gapminder_2007) + line + x(continent) + y(life) +
  title("Without the transform: 142 rows across five slots")
(data(gapminder_2007) + line + x(col.continent) + y(col.life) +
  title("Without the transform: 142 rows across five slots"))
data(gapminder_2007) + line + x(:continent) + y(:life) +
  title("Without the transform: 142 rows across five slots")
plot(data(gapminder_2007), line, x(col.continent), y(col.life),
  title("Without the transform: 142 rows across five slots"))
Asia Europe Africa Americas Oceania 40 50 60 70 80 Without the transform: 142 rows across five slots Life Continent

“Given gapminder 2007: a line, x is continent, y is life.”

No reader can follow that. Inside each slot the stroke runs up and down through every country, and between slots it joins whichever two countries the sort put last and first. The engine prints a warning on your console when a line has many ungrouped rows. mean leaves exactly one point per category, which is what makes the connected line mean anything.

Does the connection mean anything? That depends on your data. Nothing was measured between Africa and the Americas, and the segment is still drawn there. If you read only the vertices, the segments help you compare the five heights. If you read the segments themselves, they claim a change that never happened. The grammar draws what you asked for. You decide whether your data supports the connection. Two cases make the judgment easy. An ordered category, such as a grade, a size band or a survey scale, genuinely has a direction for the line to follow. A circular category has no ends at all, and that is the next plot.

Drop the point layer, add polar(), and the same profile bends into a circle. That is the radar chart, and it needs no new atom:

data(gapminder_2007) + line * mean + x(continent) + y(life) + polar() +
  title("line * mean + polar(): the radar")
(data(gapminder_2007) + line * mean + x(col.continent) + y(col.life) + polar() +
  title("line * mean + polar(): the radar"))
data(gapminder_2007) + line * mean + x(:continent) + y(:life) + polar() +
  title("line * mean + polar(): the radar")
plot(data(gapminder_2007), layer(line, mean), x(col.continent),
  y(col.life), polar(), title("line * mean + polar(): the radar"))
Asia Europe Africa Americas Oceania 60 70 80 line * mean + polar(): the radar Life Continent

“Given gapminder 2007: a line derived by mean, x is continent, y is life, in polar.”

Look at the segment from Oceania back to Asia. On the flat profile the line has two ends, because a straight axis has two ends. In the circle the five categories are spread around the whole ring, so the last one sits beside the first and the line closes. Nothing was added to say so: a categorical angular axis wraps, and the mark follows the space it is drawn in. The Polar chapter covers this in full.

9.6 Smoothed trend line

Richer countries tend to live longer. The relationship between gdp and life is positive: as one rises, the other tends to rise as well. Sometimes the direction of a relationship is all a reader needs to know. Combine line with the smooth transform to draw a trend line fitted through all the rows instead of the rows themselves:

data(gapminder_2007) + x(gdp) + y(life) +
  point + color(continent) +
  line * smooth
(data(gapminder_2007) + x(col.gdp) + y(col.life) +
  point + color(col.continent) +
  line * smooth)
data(gapminder_2007) + x(:gdp) + y(:life) + point + color(:continent) +
  line * smooth
plot(data(gapminder_2007), x(col.gdp), y(col.life), point,
  color(col.continent), layer(line, smooth))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp 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 point layer keeps every country visible, and smooth reduces the same rows to one fitted curve. Only the line layer carries the transform, so the points are untouched.

9.7 Two boundaries from a pair

Most transforms give a line one value per x. A pair transform gives it two, a low and a high, and a line then draws both boundaries as two separate lines rather than connecting them into one zigzag. That is the unfilled counterpart to a ribbon filling the same pair. There are four pair transforms: range, confidence, deviation and bounds. Here are the lowest and highest life expectancies among five countries, drawn as two lines:

data(gapminder_asia) + line * range + x(year) + y(life) +
  y_label("Life expectancy") +
  title("The lowest and highest life expectancy, as two lines")
(data(gapminder_asia) + line * range + x(col.year) + y(col.life) +
  y_label("Life expectancy") +
  title("The lowest and highest life expectancy, as two lines"))
data(gapminder_asia) + line * range + x(:year) + y(:life) +
  y_label("Life expectancy") +
  title("The lowest and highest life expectancy, as two lines")
plot(data(gapminder_asia), layer(line, range), x(col.year), y(col.life),
  y_label("Life expectancy"),
  title("The lowest and highest life expectancy, as two lines"))
1960 1980 2000 40 50 60 70 80 The lowest and highest life expectancy, as two lines Life expectancy Year

“Given gapminder Asia: a line derived by range, x is year, y is life.”

Add style(pattern = "dashed") for the dashed band often used in published figures. Ribbon shows the filled and unfilled versions next to each other.

9.8 Stroke width and opacity

A line is drawn with a single stroke from its first point to its last, so size and opacity are not channels here: there is nothing per row for them to vary along. That one stroke still has a width and an opacity, and style() sets them:

data(gapminder_asia) + line + x(year) + y(life) + group(country) +
  style(size = 4, opacity = 0.45) +
  title("One width, one opacity, for the whole layer")
(data(gapminder_asia) + line + x(col.year) + y(col.life) + group(col.country) +
  style(size = 4, opacity = 0.45) +
  title("One width, one opacity, for the whole layer"))
data(gapminder_asia) + line + x(:year) + y(:life) + group(:country) +
  style(size = 4, opacity = 0.45) +
  title("One width, one opacity, for the whole layer")
plot(data(gapminder_asia), line, x(col.year), y(col.life),
  group(col.country), style({ size: 4, opacity: 0.45 }),
  title("One width, one opacity, for the whole layer"))
1960 1980 2000 40 50 60 70 80 One width, one opacity, for the whole layer Life Year

“Given gapminder Asia: lines, x is year, y is life, grouped by country, with size 4 and opacity 0.45.”

Two questions look alike but are not the same. “Can a column be mapped here?” and “Does this mark have this property?” get different answers on a line, which is why Setting vs mapping uses it as its clearest case.

Setting one pale color is the usual way to keep a layer behind the one you want the reader to read:

data(gapminder_asia) + x(year) + y(life) +
  line + group(country) + style(color = "lightgray") +
  line * smooth + style(color = "firebrick", size = 3) +
  title("Every country in gray, the trend in red")
(data(gapminder_asia) + x(col.year) + y(col.life) +
  line + group(col.country) + style(color = "lightgray") +
  line * smooth + style(color = "firebrick", size = 3) +
  title("Every country in gray, the trend in red"))
data(gapminder_asia) + x(:year) + y(:life) + line + group(:country) +
  style(color = "lightgray") + line * smooth +
  style(color = "firebrick", size = 3) +
  title("Every country in gray, the trend in red")
plot(data(gapminder_asia), x(col.year), y(col.life), line,
  group(col.country), style({ color: "lightgray" }), layer(line, smooth),
  style({ color: "firebrick", size: 3 }),
  title("Every country in gray, the trend in red"))
1960 1980 2000 40 50 60 70 80 Every country in gray, the trend in red Life Year

style() scopes like a channel: it attaches to the mark it is written after. Unlike a channel it has no plot-scoped form. Writing it before any mark is an error, because a plot has no appearance of its own to style.

9.9 What you can set

A setting changes how the stroke looks without reading a column, and each mark takes its own. These are a line’s settings, with the values each accepts:

Setting Value
style(color = ) any CSS color name or hex
style(opacity = ) 0 to 1
style(size = ) pixels
style(pattern = ) solid, dashed, dotted

A line is a stroke, so size is its width and pattern its dash. A setting gives the whole layer one value. A channel reads a column instead, so what is drawn follows the data.

Two of the four above are also channels. color(country) draws one color per country. A mapped pattern dashes the series instead, though a stroke has only three dashes, so they repeat past the third series: see pattern. opacity and size can only be set, because a width and an opacity belong to the whole stroke, and there is nothing per row for a column to give. color goes one step further. It is the only one that can vary along a single stroke, because a color belongs to a place on the stroke rather than to the whole stroke. The gradient at the end of this chapter shows that case.

Set both at once, and every series takes the same width and the same dash:

data(gapminder_asia) + line + x(year) + y(life) + color(country) +
  style(size = 3, pattern = "dashed") +
  y_label("Life expectancy") + title("One width and one dash, for every series")
(data(gapminder_asia) + line + x(col.year) + y(col.life) + color(col.country) +
  style(size = 3, pattern = "dashed") +
  y_label("Life expectancy") + title("One width and one dash, for every series"))
data(gapminder_asia) + line + x(:year) + y(:life) + color(:country) +
  style(size = 3, pattern = "dashed") + y_label("Life expectancy") +
  title("One width and one dash, for every series")
plot(data(gapminder_asia), line, x(col.year), y(col.life),
  color(col.country), style({ size: 3, pattern: "dashed" }),
  y_label("Life expectancy"),
  title("One width and one dash, for every series"))
1960 1980 2000 40 50 60 70 80 One width and one dash, for every series Life expectancy Year Country China India Indonesia Japan Korea, Rep.

“Given gapminder Asia: lines, x is year, y is life, color by country, with size 3 and pattern dashed.”

Each property takes its value from one place: a column, or you. Here color reads a column, and size and pattern are set, so nothing collides. Ask for both on one property and gog refuses, rather than dropping one in silence. Setting vs mapping shows the refusal.

A third kind of number belongs to neither the column nor you. A transform carries its own, and they change what is computed rather than how it is drawn. line * density(adjust = 1.5) widens the bandwidth, so the curve comes out smoother than the automatic one. Transforms lists what each one takes.

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.

9.10 What it refuses

A dash is the only texture a stroke can carry. A hatch fills an area, and a line has no area to fill.

data(gapminder_asia) + line + x(year) + y(life) + style(pattern = "hatch")
data(gapminder_asia) + line + x(col.year) + y(col.life) + style(pattern = "hatch")
data(gapminder_asia) + line + x(:year) + y(:life) +
  style(pattern = "hatch")
plot(data(gapminder_asia), line, x(col.year), y(col.life),
  style({ pattern: "hatch" }))
Error:
! gog: `style(pattern = "hatch")` is not a stroke pattern. Use "solid" (the default), "dashed", or "dotted".
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The refusal lists the three values a stroke’s pattern does take. Two more refusals belong to a line. The first is a categorical column on y, refused because y carries the quantity a line traces, and category names are not a quantity. A category on the domain showed where that rule comes from:

data(gapminder_2007) + line * mean + x(life) + y(continent)
data(gapminder_2007) + line * mean + x(col.life) + y(col.continent)
data(gapminder_2007) + line * mean + x(:life) + y(:continent)
plot(data(gapminder_2007), layer(line, mean), x(col.life),
  y(col.continent))
Error:
! gog: `y(continent)` maps a categorical (text) column, but `y` on `line` 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.

The second is only described, under Stroke width and opacity: a mapped width or opacity, refused because one stroke has a single width and a single opacity, so no row is left for a column to answer.

color is the exception, for the reason the section above gave: a color belongs to a place on the stroke rather than to the whole stroke. A continuous column mapped to color therefore varies along one line, and it earns a gradient legend rather than one color per series:

data(gapminder_asia) + line + x(year) + y(life) + group(country) +
  color(gdp, scale = "log") + palette("viridis") +
  y_label("Life expectancy") + title("Each country's line, colored by its income")
(data(gapminder_asia) + line + x(col.year) + y(col.life) + group(col.country) +
  color(col.gdp, scale = "log") + palette("viridis") +
  y_label("Life expectancy") + title("Each country's line, colored by its income"))
data(gapminder_asia) + line + x(:year) + y(:life) + group(:country) +
  color(:gdp, scale = "log") + palette("viridis") +
  y_label("Life expectancy") +
  title("Each country's line, colored by its income")
plot(data(gapminder_asia), line, x(col.year), y(col.life),
  group(col.country), color(col.gdp, { scale: "log" }),
  palette("viridis"), y_label("Life expectancy"),
  title("Each country's line, colored by its income"))
1960 1980 2000 40 50 60 70 80 Each country's line, colored by its income Life expectancy Year Gdp 31.7K 3560.4 400.4

“Given gapminder Asia: lines, x is year, y is life, grouped by country, color by gdp on a log scale, with the viridis palette.”

palette() names the colors a mapping uses. "viridis" is a ramp, and the named palettes and what each is for are in Palettes.

Read one country’s line from left to right and you see two variables. The height is how long people live, and the color is how rich the country was then. group still splits the mark into one line per country; color answers the other question. A categorical color splits and colors at the same time, which is what Multiple series showed above.