9  Line

How did one value change over time? line connects the rows in x order, drawing a polyline through each (x, y) point.

9.1 Basic usage

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

9.2 You do not have to sort first

line sorts its own vertices by x, so the rows may arrive in any order. The frame below is deliberately shuffled, and the line still runs left to right:

scrambled <- data.frame(year = c(1992, 1962, 2002, 1972, 1982),
                        life  = c(  68,   52,   71,   58,   63))

data(scrambled) + line + x(year) + y(life)
scrambled = {"year": [1992, 1962, 2002, 1972, 1982], "life": [  68,   52,   71,   58,   63]}
data(scrambled) + line + x(col.year) + y(col.life)
scrambled = (year = [1992, 1962, 2002, 1972, 1982], life = [68, 52, 71, 58, 63],)
data(scrambled) + line + x(:year) + y(:life)
const scrambled = { year: [1992, 1962, 2002, 1972, 1982], life: [68, 52, 71, 58, 63] };
plot(data(scrambled), line, x(col.year), y(col.life))
1960 1970 1980 1990 2000 55 60 65 70 Life Year

That is a property of the mark rather than a convenience. 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 it cannot, and with path it is the whole point.

9.3 Multiple series

Use color to split into one line per group:

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.

Each unique value in country becomes a separate line with its own color. A color legend is generated automatically.

9.4 Layering line over other marks

line and point are often combined: the point layer adds visible markers at each data position. Write color before the marks and it belongs to the plot, so both layers take 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.

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

9.4.1 Giving each layer its own appearance

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

Channels scope the same way. Written after a mark, one binds to that mark alone, so each layer can carry a different encoding:

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

That works because channels bind forward only. A channel that also reached backwards would put size on the line, which has no size, and the plot would be refused with no way to say what you meant. 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 path is read along; y is the measure, the quantity it traces. The domain takes a category as readily as a number, and connecting one summary per category is the profile plot:

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

The transform is doing real work there. Without one, a line would connect every row in the frame, and 142 countries sharing five slots is a scribble rather than a profile; the engine says so on stderr when it sees a line with many ungrouped rows. mean leaves exactly one point per category, which is what makes the connected path mean anything.

Whether the connection is honest is a question about your data, and it is worth asking. The segment from Africa to the Americas crosses ground where nothing was measured. Read the vertices, and the segments guide the eye down a ranking; read the segments, and they assert a change over a distance that does not exist. The grammar draws what you said and leaves the judgment with you. 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 path to run along. A circular one has no ends at all, which is the plot the next section is really about.

Because it is the same sentence, the same profile bends into a circle. That is the radar, 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

Look at the segment from Oceania back to Asia. On the flat profile the path has two ends, because a straight axis has two ends; in the circle the categories fill the turn between them and the last is next to the first, so the path closes. Nothing was added to say so: a categorical angular axis wraps, and the mark reads that off the space it is drawn in. Full treatment in Polar.

A categorical measure is a different matter and is refused, because a line traces a quantity and category names are not one:

render_svg(data(gapminder_2007) + line * mean + x(life) + y(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.

9.6 Smoothed trend line

Combine line with the smooth transform to draw a fitted trend instead of raw data:

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

9.7 Two boundaries from a pair

Most transforms give a line one value per x. A pair transform (range, confidence, or bounds) gives it two (a low and a high), and a line then draws both boundaries as separate curves rather than connecting them in a zigzag. It is the unfilled counterpart to a ribbon filling the same pair. Here the min–max envelope of five countries, as two lines:

data(gapminder_asia) + line * range + x(year) + y(life) +
  y_label("Life expectancy") + title("A min–max envelope, as two boundary lines")
(data(gapminder_asia) + line * range + x(col.year) + y(col.life) +
  y_label("Life expectancy") + title("A min–max envelope, as two boundary lines"))
data(gapminder_asia) + line * range + x(:year) + y(:life) +
  y_label("Life expectancy") +
  title("A min–max envelope, as two boundary lines")
plot(data(gapminder_asia), layer(line, range), x(col.year), y(col.life),
  y_label("Life expectancy"),
  title("A min–max envelope, as two boundary lines"))
1960 1980 2000 40 50 60 70 80 A min–max envelope, as two boundary lines Life expectancy Year

Add style(pattern = "dashed") for the dashed ± band publications use; see Ribbon for the filled and unfilled versions side by side.

9.8 Stroke width and opacity

A polyline is drawn with a single stroke, so size and opacity are not channels here: there is no per-row anything 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

This is the clearest case of the split described in Setting vs mapping: “can a column be mapped here?” and “does this mark have this property?” are different questions, and line answers them differently.

A set color is the usual way to push a layer into the background:

data(gapminder_asia) + x(year) + y(life) +
  line + group(country) + style(color = "lightgrey") +
  line * smooth + style(color = "firebrick", size = 3) +
  title("Context in gray, message in red")
(data(gapminder_asia) + x(col.year) + y(col.life) +
  line + group(col.country) + style(color = "lightgrey") +
  line * smooth + style(color = "firebrick", size = 3) +
  title("Context in gray, message in red"))
data(gapminder_asia) + x(:year) + y(:life) + line + group(:country) +
  style(color = "lightgrey") + line * smooth +
  style(color = "firebrick", size = 3) +
  title("Context in gray, message in red")
plot(data(gapminder_asia), x(col.year), y(col.life), line,
  group(col.country), style({ color: "lightgrey" }), layer(line, smooth),
  style({ color: "firebrick", size: 3 }),
  title("Context in gray, message in red"))
1960 1980 2000 40 50 60 70 80 Context in gray, message 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 rather than a shorthand, because a plot has no appearance of its own to style.

9.9 What you can set

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

And these vary per row if you map them to a column instead: color() (either), pattern() (categories), group() (categories), play() (either).

A line is a stroke, so size is its width and pattern its dash. Neither can vary along the line, because a width and a dash are properties of the whole stroke: they are settings here even though size maps per row on point. (color is the one that can vary along it, and the reason is exactly that a color is a property of a place on the stroke rather than of the stroke: see What it refuses.)

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.

color is mapped there, so it is not also set; a layer that both maps and sets the same feature is refused, because one of the two would have to be silently dropped.

The transforms a line takes carry their own parameters, which is where the rest of the control lives: line * density(adjust = 1.5) widens the kernel, line * bounds(lo, hi) traces a pre-computed band’s two edges.

9.10 What it refuses

A line is a stroke, and its texture vocabulary says so: a line’s pattern is a dash, while a hatch fills an area, and a line has no area to fill.

data(gapminder_asia) + line + x(year) + y(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 dashes a stroke does take. Its other two refusals are above rather than here, each in the section that earns it. One is a statistic across a category, which line cannot lay down. The other is a mapped width or opacity, which one stroke cannot vary row by row even though it can be set. That second pair is the worked example in Setting vs mapping.

color is deliberately not in that pair, and the line between them is worth drawing precisely, because it is the reason none of this is arbitrary. A width and an opacity are properties of the stroke as a whole (there is no answer to “how wide is the line here”). A color has an answer everywhere: point at a place on the line and it has one. So a measure can ride color and vary along the stroke, and it earns a gradient key rather than one swatch 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

Read one thread from left to right and you are watching two variables at once: how long people live (the height) and how rich the country was when they did (the color). group still splits the mark into one line per country; color answers the other question. A categorical color splits and colors in one go, exactly as it always has, which is the sentence at the top of this chapter.