| 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 |
17 Path
How did income and life expectancy change together in each country, year by year? path draws one stroke through the rows’ points, in the order the table gives them. That is the whole definition of the mark, and it is the one thing line does not do. A line sorts its vertices by x first, because a line draws a function: one y for each x, read left to right along a domain. A path draws a route. It visits row 1, then row 2, and it may turn back, cross itself, or return to where it started.
The two marks draw the same kind of stroke, and they take the same color and group splits and the same style(size = ) and style(pattern = ) settings. One question separates them: does the order of the rows mean something? A path keeps that order, and three things follow from it. It can carry an arrowhead, because its last vertex is the table’s last row. In polar it can go around the circle more than once, because two rows with the same angle stay apart, each on its own turn. And it can run through the cube, because the row order does not depend on any axis. None of the three is a special case.
17.1 The connected scatterplot
Two measures that change over time can be drawn as two separate time series, one above the other. The reader then has to find the same year in both plots before comparing them. One plot can show both measures and time together, if the rows are already in time order. Here are five Asian countries, each measured twelve times from 1952 to 2007. Put income on one axis and life expectancy on the other, and connect each country’s measurements in the order they were taken:
data(gapminder_asia) + path + x(gdp) + y(life) + color(country) +
x_label("GDP per person") + y_label("Life expectancy") +
title("Fifty-five years, five countries, one plane")(data(gapminder_asia) + path + x(col.gdp) + y(col.life) + color(col.country) +
x_label("GDP per person") + y_label("Life expectancy") +
title("Fifty-five years, five countries, one plane"))data(gapminder_asia) + path + x(:gdp) + y(:life) + color(:country) +
x_label("GDP per person") + y_label("Life expectancy") +
title("Fifty-five years, five countries, one plane")plot(data(gapminder_asia), path, x(col.gdp), y(col.life),
color(col.country), x_label("GDP per person"),
y_label("Life expectancy"),
title("Fifty-five years, five countries, one plane"))“Given gapminder Asia: paths, x is gdp, y is life, color by country.”
Each route is one country’s history. The plot has no time axis, but it still shows time: time is the order in which the points are joined. China’s route goes almost straight up at very low income. That shape belongs to a country whose life expectancy rose long before its income did, and India’s and Indonesia’s routes rise the same way. Korea’s route starts upward and then turns a sharp corner to the right. Past that corner its income climbs fast and its life expectancy rises more slowly. Japan’s route is the flattest of the five. It begins where Korea’s route turns: Japan had that income and life expectancy in 1952, twenty years before Korea. This is a connected scatterplot, and path is the only mark that draws it.
17.2 What line draws instead
line is the mark you would probably write first, because it is the familiar way to join rows with a stroke. So compare what line draws from the same table. One word changes in the specification, and three of the five routes change shape:
data(gapminder_asia) + line + x(gdp) + y(life) + color(country) +
x_label("GDP per person") + y_label("Life expectancy") +
title("the same data, sorted by x")(data(gapminder_asia) + line + x(col.gdp) + y(col.life) + color(col.country) +
x_label("GDP per person") + y_label("Life expectancy") +
title("the same data, sorted by x"))data(gapminder_asia) + line + x(:gdp) + y(:life) + color(:country) +
x_label("GDP per person") + y_label("Life expectancy") +
title("the same data, sorted by x")plot(data(gapminder_asia), line, x(col.gdp), y(col.life),
color(col.country), x_label("GDP per person"),
y_label("Life expectancy"), title("the same data, sorted by x"))“Given gapminder Asia: lines, x is gdp, y is life, color by country.”
Look at China’s route. Its income did not rise steadily: it fell once and then rose again, so the year order and the income order differ, and line joins the points in income order. Every point is still in the right place, but the stroke between them no longer follows the years. Indonesia’s and Japan’s routes change the same way, Japan’s only slightly. India’s and Korea’s income rose at every measurement, so for them both marks draw the same picture. This is not a defect in line. A line draws a function, and a function has one value for each x. Sorting the rows by x cannot change which value belongs to which x. For a time series read along a date axis, the sort simply puts the rows in date order.
The two marks answer different questions. If a specification names the wrong one, the engine still draws a plot, and the plot still looks reasonable. Nothing reports the mistake. The rule is this. If the reader should follow the points in any order other than left to right, that order has to come from the table, and only path takes its order from the table.
The bump chart, which follows each category’s rank from year to year, needs a path for another reason. Its y is a rank stored as text, and line refuses a categorical y. Comparing categories draws it.
17.3 Which end is the last row?
A route has a direction, and a plain stroke does not show it. Japan’s route could be read from either end. So path takes a setting no other mark can:
data(gapminder_asia) + path + x(gdp) + y(life) + color(country) +
style(arrow = "end") +
x_label("GDP per person") + y_label("Life expectancy") +
title("style(arrow = \"end\"): the head is at 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("style(arrow = \"end\"): the head is at 2007"))data(gapminder_asia) + path + x(:gdp) + y(:life) + color(:country) +
style(arrow = "end") + x_label("GDP per person") +
y_label("Life expectancy") +
title("style(arrow = \"end\"): the head is at 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("style(arrow = \"end\"): the head is at 2007"))“Given gapminder Asia: paths, x is gdp, y is life, color by country, with arrow end.”
Now each route points at its last row. arrow takes "end", "start", or "both": an arrowhead (a head, for short) at the last row, at the first row, or at both ends.
Two marks take it: path, and edge, whose direction comes from the two columns of its layout. Every other mark refuses it. A line’s last vertex is the row with the largest x, so a head there would point at the end of the sort, not at the last row. A path’s end comes from the data. What it refuses shows a line refusing an arrowhead.
17.4 An arrow is a layer
arrow has a second use, and it needs no new atom. A note placed in an empty part of the plot has to say which point it is about. An arrow from the words to the point says it. The Text chapter drew a note by itself; here path adds the arrow beside one. A note is a one-row table; the arrow that points from the note to the point it is about is a two-row table, one row for each end:
| 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 |
| gdp | life |
|---|---|
| 20000 | 45.5 |
| 13100 | 50.2 |
| gdp | life | what |
|---|---|---|
| 20500 | 44.8 | Botswana |
data(gapminder_2007) + point + x(gdp) + y(life) +
data(botswana_arrow) + path + style(arrow = "end", color = "#c0392b") +
data(botswana_label) + text + label(what) +
title("an annotation, in the grammar you already have")(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
data(botswana_arrow) + path + style(arrow = "end", color = "#c0392b") +
data(botswana_label) + text + label(col.what) +
title("an annotation, in the grammar you already have"))data(gapminder_2007) + point + x(:gdp) + y(:life) + data(botswana_arrow) +
path + style(arrow = "end", color = "#c0392b") + data(botswana_label) +
text + label(:what) +
title("an annotation, in the grammar you already have")plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
data(botswana_arrow), path, style({ arrow: "end", color: "#c0392b" }),
data(botswana_label), text, label(col.what),
title("an annotation, in the grammar you already have"))“Given gapminder 2007: points, x is gdp, y is life, and also a path from the Botswana arrow, and text from the Botswana label, label by what.”
This grammar has no annotate() function, and it needs none. An annotation, a note with its arrow, is made of layers, each over a small table. Its marks are the marks everything else uses: text for the words, path for the arrow, and point for a dot at a place you choose. The arrow’s table holds data, so gog scales it like any other table. That is why the arrow’s ends are written in the data’s own units rather than in pixels.
17.5 Two transforms, and the rest refused
Every path so far has been drawn directly from the rows. The next question is what happens when a transform changes the rows before the path draws them. line, step, area and ribbon compose with many transforms. path composes with two, density and cluster, and refuses every other transform. The summaries show the reason most clearly. What it refuses shows path * mean being refused.
A summary replaces the rows. mean reduces each country to one number, and what is left to connect is one point per country, in whatever order the summary returned them. One point per country, joined left to right, is what line already draws. A path’s meaning is the row order, and a summary discards that order. That is why this mark refuses almost every transform, and why its row on the Combinations grid is nearly empty.
density is the transform this chapter shows, and Cluster shows the other. Like a summary, density replaces the rows. Unlike a summary, its new rows have an order that means something: they are the points around each ring, in the order the ring is traced.
17.6 The contour: path * density
Where are the flowers most crowded, and how tightly? A scatterplot of these 150 flowers would show two groups. Where several flowers share a position their dots are drawn on top of each other, and you cannot tell how many are there. A contour plot leaves the dots out and draws closed rings around the places where they are most crowded. Bind both positions and compose density. gog estimates the density of points at every place on the plane, then traces the rings on which that density is the same:
| sepal_length | sepal_width | petal_length | species |
|---|---|---|---|
| 5.1 | 3.5 | 1.4 | setosa |
| 4.9 | 3.0 | 1.4 | setosa |
| 4.7 | 3.2 | 1.3 | setosa |
| 4.6 | 3.1 | 1.5 | setosa |
| 5.0 | 3.6 | 1.4 | setosa |
data(iris_flowers) + path * density +
x(sepal_length) + y(petal_length) +
title("where the flowers cluster")(data(iris_flowers) + path * density +
x(col.sepal_length) + y(col.petal_length) +
title("where the flowers cluster"))data(iris_flowers) + path * density + x(:sepal_length) +
y(:petal_length) + title("where the flowers cluster")plot(data(iris_flowers), layer(path, density), x(col.sepal_length),
y(col.petal_length), title("where the flowers cluster"))“Given the iris flowers: paths derived by density, x is sepal length, y is petal length.”
Over two positions a density is a field: one estimated value at every place on the plane. Three marks read that field, and each one draws it differently:
Mark derived by density, over two positions |
Shape |
|---|---|
zone * density |
the field as filled cells |
surface * density |
the field as a sheet in the cube |
path * density |
the field as contour rings, the shape this section draws |
Surface draws all three from one table, one after another.
That contour needed nothing new in the grammar. One rule produced it, and the same rule makes zone * bin draw a heatmap. The rule is that the mark decides how many axes a transform cuts. You never state the number yourself. In bar * density you bind only one position, so one axis is still free. The density is estimated along the bound axis and drawn on the free one, as one thin bar at each point where it was estimated. A path is a route through two columns and treats them equally, so it has no axis left over. density therefore cuts both. There is no contour atom for the same reason there is no pie() (Polar makes that argument): the atoms you already have spell it.
Read the outermost ring, then the ones inside it. The outermost is a single ring around both groups, and the next level inward splits into two separate rings. One level can therefore be several separate rings. So a level is not one stroke, the way one color value is one line. Each ring is drawn as its own stroke, and the level decides only its color. The two inner rings are never joined across the gap between them.
17.6.1 The parameters
The rings are colored by their level, and you did not bind color to get that. density writes a new column, level, into the rows it returns. gog bound color to that column, the same way bar * bin binds y for you. Bind it yourself if you prefer, and change how many rings are traced:
data(iris_flowers) + path * density(levels = 10) +
x(sepal_length) + y(petal_length) + color(level) +
title("ten levels, named out loud")(data(iris_flowers) + path * density(levels = 10) +
x(col.sepal_length) + y(col.petal_length) + color(col.level) +
title("ten levels, named out loud"))data(iris_flowers) + path * density(levels = 10) + x(:sepal_length) +
y(:petal_length) + color(:level) + title("ten levels, named out loud")plot(data(iris_flowers), layer(path, density({ levels: 10 })),
x(col.sepal_length), y(col.petal_length), color(col.level),
title("ten levels, named out loud"))“Given the iris flowers: paths derived by density at 10 levels, x is sepal length, y is petal length, color by level.”
The rings now come in ten levels instead of the default six, so they are closer together.
density is one transform, and the mark decides what it computes. With line it computes a curve over one column. With zone or path it computes a field over two columns: a zone fills the field, and a path draws its rings. The parameters follow from this. A width suits the curve, and a number of levels suits the field.
levels cuts the field into that many steps. Of the three marks that read the field, zone and path accept it, and surface refuses it, for a reason Surface gives. A one-dimensional density is a single curve, and a curve has no field to cut, so a line refuses it.
bandwidth is how far each row is spread when the density is estimated, in the column’s own units, so it belongs to the curve over one column. A contour spreads over two columns, and two columns are usually in different units, such as dollars and years. One number cannot be a width in both. Sepal length and petal length are both in centimeters, but gog refuses bandwidth here too, because the rule is about two columns, whatever their units. What it refuses shows both refusals.
adjust multiplies the bandwidth gog chooses by itself. It is a plain number rather than a length, so it means the same thing on either axis. It is the one parameter line, zone, surface and path all accept:
data(iris_flowers) + path * density(adjust = 1.6) +
x(sepal_length) + y(petal_length) +
title("the same flowers, smoothed more")(data(iris_flowers) + path * density(adjust = 1.6) +
x(col.sepal_length) + y(col.petal_length) +
title("the same flowers, smoothed more"))data(iris_flowers) + path * density(adjust = 1.6) + x(:sepal_length) +
y(:petal_length) + title("the same flowers, smoothed more")plot(data(iris_flowers), layer(path, density({ adjust: 1.6 })),
x(col.sepal_length), y(col.petal_length),
title("the same flowers, smoothed more"))“Given the iris flowers: paths derived by density adjusted by 1.6, x is sepal length, y is petal length.”
Each flower now spreads further when the density is estimated, so the rings are smoother.
To estimate one field per category, split the rows with group. The level is already on color, which is why the split has to be group. Versicolor and virginica overlap on this plot, while setosa is separate. The petals table keeps only the two that overlap:
| sepal_length | sepal_width | petal_length | species |
|---|---|---|---|
| 7.0 | 3.2 | 4.7 | versicolor |
| 6.4 | 3.2 | 4.5 | versicolor |
| 6.9 | 3.1 | 4.9 | versicolor |
| 5.5 | 2.3 | 4.0 | versicolor |
| 6.5 | 2.8 | 4.6 | versicolor |
data(petals) + path * density(levels = 4) +
x(sepal_length) + y(petal_length) + group(species) +
title("two species, estimated separately")(data(petals) + path * density(levels = 4) +
x(col.sepal_length) + y(col.petal_length) + group(col.species) +
title("two species, estimated separately"))data(petals) + path * density(levels = 4) + x(:sepal_length) +
y(:petal_length) + group(:species) +
title("two species, estimated separately")plot(data(petals), layer(path, density({ levels: 4 })),
x(col.sepal_length), y(col.petal_length), group(col.species),
title("two species, estimated separately"))“Given the petals: paths derived by density at 4 levels, x is sepal length, y is petal length, grouped by species.”
Each species has its own set of rings. Both sets take their colors from the levels, so the lower set, with the shorter petals, is versicolor. The two fields are smoothed alike. On each axis, the two species share the same bandwidth: the average of the bandwidths each species would get alone. A difference between the two sets of rings therefore comes from the flowers and not from the smoothing.
17.6.2 One field, two marks
A path has no axis left over, and a zone has none either, so a zone also reads density over both positions. The difference is in the geometry. A path draws the rings as strokes. A zone given levels fills the bands between them, and without levels it fills small cells. Give both marks the same levels and you get the same curves twice: once as strokes, once as the edges of filled bands. The filled version is the filled contour, and zone * density draws it; the zone chapter covers the color ramp it uses. One field, two marks. It is the same division of work as in bar * bin and line * bin: the mark chooses the geometry, and the transform stays the same.
17.7 A path in polar
The arrowhead was the first of the three things the row order gives a path. The second appears in polar, when the same angle appears in more than one row. Some routes turn: a bearing that moves through all 360 degrees, or a value that cycles through the hours while another grows. The spiral table holds two days of readings, one every 15 minutes. a is the hour of the day, and r grows the whole time:
| a | r |
|---|---|
| 0.00 | 0.1000000 |
| 0.25 | 0.1204188 |
| 0.50 | 0.1408377 |
| 0.75 | 0.1612565 |
| 1.00 | 0.1816754 |
On flat axes the route has to jump back at midnight:
data(spiral) + path + x(a) + y(r) +
title("a path on flat axes")(data(spiral) + path + x(col.a) + y(col.r) +
title("a path on flat axes"))data(spiral) + path + x(:a) + y(:r) + title("a path on flat axes")plot(data(spiral), path, x(col.a), y(col.r), title("a path on flat axes"))“Given the spiral: a path, x is a, y is r.”
The long stroke across the middle joins 23:45 on the first day to midnight on the second, and no reading lies along it. On a straight axis the end of one day is drawn far from the start of the next. On a circle the two are at the same angle, so the route continues instead of jumping. path works in polar like every other mark:
data(spiral) + path + x(a) + y(r) + polar() +
title("a path in polar")(data(spiral) + path + x(col.a) + y(col.r) + polar() +
title("a path in polar"))data(spiral) + path + x(:a) + y(:r) + polar() + title("a path in polar")plot(data(spiral), path, x(col.a), y(col.r), polar(),
title("a path in polar"))“Given the spiral: a path, x is a, y is r, in polar.”
The route goes around twice, one day after the other, and turns outward in a spiral. A line cannot draw this. It sorts the rows by a, so each moment of the first day is joined to the same moment of the second, and the stroke zigzags between them inside one turn:
data(spiral) + line + x(a) + y(r) + polar() +
title("a line in polar")(data(spiral) + line + x(col.a) + y(col.r) + polar() +
title("a line in polar"))data(spiral) + line + x(:a) + y(:r) + polar() + title("a line in polar")plot(data(spiral), line, x(col.a), y(col.r), polar(),
title("a line in polar"))“Given the spiral: a line, x is a, y is r, in polar.”
There is one thing path does not copy from line: the segment that closes a radar chart, like the one in Line. A categorical line in polar joins its last vertex back to its first, because the categories fill the whole turn, and a gap there would leave one sector blank. A path puts each category on the same spoke as a line does, but it does not join its last vertex back to its first. Its last vertex is where the data stopped, so closing the route would add a segment that is not in the table. If a route does return to its start, say so by repeating the first row.
17.8 A path in the cube
In polar the path still joined the same rows in the same order. A third axis does not change that either, because the row order does not depend on any axis. A glider’s route has three positions: east, north and altitude. A flat page holds only two of them. Drawn flat, two circling gliders are two rings, one inside the other, and nothing on the page shows that they are rising. The thermals table holds one row for each recorded position of two gliders. Both circle the same thermal, a rising column of warm air, as they climb:
| east | north | altitude | glider | second |
|---|---|---|---|---|
| 330.0000 | 0.00000 | 900.0000 | Alpha | 0 |
| 327.5939 | 39.77710 | 907.0082 | Alpha | 2 |
| 320.4108 | 78.97417 | 914.0163 | Alpha | 4 |
| 308.5554 | 117.01961 | 921.0245 | Alpha | 6 |
| 292.2005 | 153.35865 | 928.0327 | Alpha | 8 |
Bind z and the route runs through the cube instead of across the page. Of the marks that join their rows into one stroke, path is the only one that takes the third position. The reason is the row order this whole chapter is about:
data(thermals) + path + x(east) + y(north) + z(altitude) + color(glider) +
title("Two gliders, one thermal")(data(thermals) + path + x(col.east) + y(col.north) + z(col.altitude) + color(col.glider) +
title("Two gliders, one thermal"))data(thermals) + path + x(:east) + y(:north) + z(:altitude) +
color(:glider) + title("Two gliders, one thermal")plot(data(thermals), path, x(col.east), y(col.north), z(col.altitude),
color(col.glider), title("Two gliders, one thermal"))“Given the thermals: paths, x is east, y is north, z is altitude, color by glider.”
An order is not a property of any axis, so adding a third axis does not change it. The route joins the same rows in the same sequence, whatever angle the scene is turned to. A line sorts by x, and the cube has no left to right. In the cube x is one of three equal positions. At some viewing angles x points straight into the page, so a line would be sorted by an axis the reader cannot see. So a line’s sort by x has no meaning in the cube, and line, step, area and ribbon refuse z rather than draw a stroke the reader cannot read, as What it refuses shows.
Look again at the two routes in the cube. They cross several times. At each crossing the route nearer to the eye is drawn over the one behind it, and which route is nearer changes as the two gliders climb. To draw the cube on a flat page, gog draws the pieces farthest from the eye first and the nearest last, so that near pieces cover far ones. This drawing order is the depth sort. A glyph is at one distance from the eye, so it takes one place in that order. A stroke through the cube has a far end and a near end, so each segment takes its own place. The segments of both routes share one depth sort. If each stroke took one place in the depth sort, one glider’s route would be drawn entirely in front of the other’s, and the two coils could not pass through each other as they do here. An arrowhead takes its place the same way, at the depth of its own tip. A head far from the eye is covered by whatever is drawn in front of it.
As on a flat line, color can vary along the route, because a color belongs to one place on the stroke rather than to the whole stroke. Bind altitude to color as well as to z. The climb is then said twice: once by where the route is, once by what color it is:
data(thermals) + path + x(east) + y(north) + z(altitude) +
color(altitude) + group(glider) + palette("viridis") +
space(turn = -25, tilt = 20) +
title("Altitude, said twice")(data(thermals) + path + x(col.east) + y(col.north) + z(col.altitude) +
color(col.altitude) + group(col.glider) + palette("viridis") +
space(turn = -25, tilt = 20) +
title("Altitude, said twice"))data(thermals) + path + x(:east) + y(:north) + z(:altitude) +
color(:altitude) + group(:glider) + palette("viridis") +
space(turn = -25, tilt = 20) + title("Altitude, said twice")plot(data(thermals), path, x(col.east), y(col.north), z(col.altitude),
color(col.altitude), group(col.glider), palette("viridis"),
space({ turn: -25, tilt: 20 }), title("Altitude, said twice"))“Given the thermals: paths, x is east, y is north, z is altitude, color by altitude, grouped by glider, with the viridis palette.”
palette("viridis") chooses the color ramp that shows the altitudes. space(turn = -25, tilt = 20) sets the viewing angle, which Space explains; a z binding alone would draw the cube from its default angle. group(glider) tells gog that the rows belong to two routes rather than one. Remove group(glider) and the plot is wrong. A category on color splits the mark into one stroke per group, but a measure has no categories to split on. Without group the two gliders would be joined into a single route, with one straight segment from the last row of one glider to the first row of the other. color shows a value along the route; group says which route each row belongs to. They are two different questions, so they need two atoms.
The depth sort draws the route as separate segments. Left alone, the dash pattern would start again at every segment, so gog continues it from one segment to the next:
data(thermals) + path + x(east) + y(north) + z(altitude) + color(glider) +
style(pattern = "dashed") +
space(turn = -25, tilt = 20)(data(thermals) + path + x(col.east) + y(col.north) + z(col.altitude) + color(col.glider) +
style(pattern = "dashed") +
space(turn = -25, tilt = 20))data(thermals) + path + x(:east) + y(:north) + z(:altitude) +
color(:glider) + style(pattern = "dashed") +
space(turn = -25, tilt = 20)plot(data(thermals), path, x(col.east), y(col.north), z(col.altitude),
color(col.glider), style({ pattern: "dashed" }),
space({ turn: -25, tilt: 20 }))“Given the thermals: paths, x is east, y is north, z is altitude, color by glider, with pattern dashed.”
The dashes run along the route rather than resetting at every row. The dash belongs to the route, not to the segments the depth sort needs.
Space owns the third dimension and the viewing angle.
17.9 What a path is for
In the three spaces this chapter used (the flat plane, polar and the cube), the path did one thing: it joined the rows in the table’s order. The connected scatterplot is the best known use, but the mark is more general than that. A path is the right mark whenever the reader should follow the rows in the order the table gives them. A route through space is one, and so is a cycle that returns to where it began. So is a hysteresis loop, which is a cycle whose outward and return routes differ, and so is an arrow. The test is always the same question. Does the order of the rows mean something? If it does, the mark is path; if the reader should read left to right, it is line.
17.10 What you can set
A plain stroke does not show which way the route runs, and it looks like every other stroke on the panel. The table has no column for the direction or for the look of the stroke, so both are set rather than mapped, and each mark takes its own settings. These are a path’s, 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, dotdash, longdash |
style(arrow = ) |
end, start, both |
arrow belongs to path and edge only, for the reason Which end is the last row? gave.
A route can carry the heads, a width and a dash together. The plot uses the gapminder_asia table, shown in The connected scatterplot:
data(gapminder_asia) + path + x(gdp) + y(life) + color(country) +
style(arrow = "both", size = 2.5, pattern = "dotted") +
x_label("GDP per person") + y_label("Life expectancy") +
title("Both ends marked, on a dotted route")(data(gapminder_asia) + path + x(col.gdp) + y(col.life) + color(col.country) +
style(arrow = "both", size = 2.5, pattern = "dotted") +
x_label("GDP per person") + y_label("Life expectancy") +
title("Both ends marked, on a dotted route"))data(gapminder_asia) + path + x(:gdp) + y(:life) + color(:country) +
style(arrow = "both", size = 2.5, pattern = "dotted") +
x_label("GDP per person") + y_label("Life expectancy") +
title("Both ends marked, on a dotted route")plot(data(gapminder_asia), path, x(col.gdp), y(col.life),
color(col.country),
style({ arrow: "both", size: 2.5, pattern: "dotted" }),
x_label("GDP per person"), y_label("Life expectancy"),
title("Both ends marked, on a dotted route"))“Given gapminder Asia: paths, x is gdp, y is life, color by country, with arrow both, size 2.5 and pattern dotted.”
arrow takes a value rather than a true-or-false flag. "both" is an ordinary thing to ask for, and a flag would have needed a second setting to say it. The heads are drawn solid on a dotted path. The dash is the route’s texture, and a head cut into dashes no longer reads as an arrow.
This chapter covers the parameters of one transform, density: levels, bandwidth and adjust, in The parameters. Cluster covers those of cluster. Every other property of a path is either mapped to a column or set with style().
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.
17.11 What it refuses
You might want the route to thicken as income rises, so that the stroke itself carries a third measure. size reads one value from every row. A path joins all of its rows into a single stroke, and a stroke is drawn at one width from end to end. There is nowhere to put a second width:
data(gapminder_asia) + path + x(gdp) + y(life) + size(gdp)data(gapminder_asia) + path + x(col.gdp) + y(col.life) + size(col.gdp)data(gapminder_asia) + path + x(:gdp) + y(:life) + size(:gdp)plot(data(gapminder_asia), path, x(col.gdp), y(col.life), size(col.gdp))Error:
! gog: `size` cannot be bound to `path` — a path takes one size for the whole layer, not one per row. Drop the `gdp` mapping and set it with `style(size = )`, or use a mark that maps `size`: `point` or `text`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
Set it instead and every segment takes the same width, which is the Setting vs mapping distinction again.
A summary is the second refusal. A path draws the rows in their order, and a summary replaces those rows, as Two transforms, and the rest refused explained:
data(gapminder_asia) + path * mean + x(country) + y(life)data(gapminder_asia) + path * mean + x(col.country) + y(col.life)data(gapminder_asia) + path * mean + x(:country) + y(:life)plot(data(gapminder_asia), layer(path, mean), x(col.country), y(col.life))Error:
! gog: `path` strokes the rows in the order the table gives them, and `mean` replaces those rows with one summary per key — after which the order is the keys' and the path is a `line`. Use `line * mean`, which sorts by `x` and is the mark a statistic is drawn on.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
density(bandwidth = ) is the third. It is a width in one column’s own units, and the two columns of a contour are usually in different units, so one number cannot be a width in both:
data(iris_flowers) + path * density(bandwidth = 0.5) +
x(sepal_length) + y(petal_length)(data(iris_flowers) + path * density(bandwidth = 0.5) +
x(col.sepal_length) + y(col.petal_length))data(iris_flowers) + path * density(bandwidth = 0.5) + x(:sepal_length) +
y(:petal_length)plot(data(iris_flowers), layer(path, density({ bandwidth: 0.5 })),
x(col.sepal_length), y(col.petal_length))Error:
! gog: `density(bandwidth = )` is a width in one column's own units, and `path * density` spreads over *two* columns measuring different quantities — one number cannot be a width in both. Use `density(adjust = )`, which scales the automatic bandwidth on each axis by the same dimensionless factor.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
Three more refusals follow. In each one, line refuses something that path accepts, and two of them come from the sort. An arrowhead is the first of them. A line sorts its rows by x, so its last vertex is simply the largest x in the table. A head there would point at the result of the sort, not at the last measurement taken:
data(gapminder_asia) + line + x(year) + y(life) + style(arrow = "end")data(gapminder_asia) + line + x(col.year) + y(col.life) + style(arrow = "end")data(gapminder_asia) + line + x(:year) + y(:life) + style(arrow = "end")plot(data(gapminder_asia), line, x(col.year), y(col.life),
style({ arrow: "end" }))Error:
! gog: `style(arrow = )` is a `path` setting, and a `line` cannot carry it — a line sorts its vertices by `x`, so its last point is wherever the domain ends rather than where the data stopped — a head there would point at the sort. Use `path`, which strokes the rows in the data's own order and so has an end the data chose.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
Here x is the year, so the sort and the table order agree, and the head would land on 2007. gog refuses it anyway, because on a line the end of the stroke always comes from the sort.
density(levels = ) is the second. It cuts the estimated field into steps, and a one-dimensional density is a single curve with no field to cut:
data(iris_flowers) + line * density(levels = 8) + x(sepal_length)data(iris_flowers) + line * density(levels = 8) + x(col.sepal_length)data(iris_flowers) + line * density(levels = 8) + x(:sepal_length)plot(data(iris_flowers), layer(line, density({ levels: 8 })),
x(col.sepal_length))Error:
! gog: `density(levels = )` cuts a field into levels, and a `line` estimates *one curve* — a curve has no levels to cut. Drop it for a density curve, or read the density over both axes: `path * density(levels = )` traces the contours, `zone * density(levels = )` fills the bands between them.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
z is the third. A line sorts by x, and the cube has no left to right, so the sort has no meaning in the cube, as A path in the cube explained:
data(thermals) + line + x(east) + y(north) + z(altitude)data(thermals) + line + x(col.east) + y(col.north) + z(col.altitude)data(thermals) + line + x(:east) + y(:north) + z(:altitude)plot(data(thermals), line, x(col.east), y(col.north), z(col.altitude))Error:
! gog: `line` reads a *domain* left to right — it sorts by `x` and draws one value for each — and a cube has no left to right: `x` is one of three equal positions, and at some viewing angles it runs into the page and becomes depth. A `line` in space would be sorted by an axis the reader cannot see, so this is refused rather than drawn. For a route through three dimensions use `path`, which is `line` with that sort removed: `path + x(<a>) + y(<b>) + z(altitude)`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
Two of the three have one cause: a line’s shape comes from the sort, and a path’s comes from the rows. The third has another. A curve over one column has no field to cut into levels.
A path also needs at least two rows in each series, as a line does. Group gapminder_2007 by country, and each series holds one row, so nothing would be drawn and gog refuses the plot.
17.12 In short
A path joins the rows in the order the table holds them, so sorting the table changes the picture. That order is the mark’s meaning. The arrowhead belongs to path and edge only, and the two transforms a path takes are density and cluster.
17.13 What comes next
Every part of a path comes from the data. Rule draws a different kind of stroke, one that crosses the whole panel at a value you give it.