18  Rule

Where is the threshold these values should be compared against? Every other mark reads two positions and draws something between them. rule reads one, and asks the panel for the rest. That is the whole of it, and two familiar things fall out of it: a reference line at a threshold, and a rug of ticks showing where the observations actually sit.

Those are often four separate marks: a vertical reference line, a horizontal one, a line from a slope and an intercept, and a rug. gog has one, because a vertical and a horizontal reference line differ only in which axis carries the position, and the grammar already reads that off the bindings, the same way it decides whether a bar stands up or lies down.

18.1 A line at a threshold

Here is the 2007 gapminder cloud, income against life expectancy, with a line drawn where life expectancy reaches 70:

data(gapminder_2007) + point + x(gdp) + y(life) +
  data(life_bands) + rule +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Three thresholds, one table")
(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
  data(life_bands) + rule +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Three thresholds, one table"))
data(gapminder_2007) + point + x(:gdp) + y(:life) + data(life_bands) +
  rule + x_label("GDP per person") + y_label("Life expectancy") +
  title("Three thresholds, one table")
plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  data(life_bands), rule, x_label("GDP per person"),
  y_label("Life expectancy"), title("Three thresholds, one table"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Three thresholds, one table Life expectancy GDP per person

“Given gapminder 2007: points, x is gdp, y is life, and also rules from the life bands.”

Three lines, and only one rule in the sentence. That is the mark’s most useful habit: its position is a column, not a number. life_bands holds three values, so it draws three lines. Add a fourth row and a fourth line appears with no change to the plot’s sentence at all.

This is the same rule every column-taking atom keeps (color(species) names a column, not a color), and it is why there is no rule(70). A number written into the plot would be a value the grammar could not scale, facet, or bend into another coordinate space; a column is data, and data goes everywhere data goes.

18.2 Which axis it lands on

Nothing in that sentence said “horizontal”. The rule worked it out, and here is the whole of how: life_bands has a life column and no gdp column, and the plot’s y is life. One axis answers, the other does not, so the rule sits on the one that does and spans the one that does not.

Change which column the table holds and the same word stands the line up:

data(gapminder_2007) + point + x(gdp) + y(life) +
  data(gdp_threshold) + rule + style(color = "firebrick", pattern = "dashed") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("The same mark, the other axis")
(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
  data(gdp_threshold) + rule + style(color = "firebrick", pattern = "dashed") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("The same mark, the other axis"))
data(gapminder_2007) + point + x(:gdp) + y(:life) + data(gdp_threshold) +
  rule + style(color = "firebrick", pattern = "dashed") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("The same mark, the other axis")
plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  data(gdp_threshold), rule,
  style({ color: "firebrick", pattern: "dashed" }),
  x_label("GDP per person"), y_label("Life expectancy"),
  title("The same mark, the other axis"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 The same mark, the other axis Life expectancy GDP per person

gdp_threshold holds gdp, the plot’s x, so the line stands up. The mark did not change, the sentence did not change, and there is no flip anywhere: the data said which axis it was about.

18.3 A rug is the same mark

A rug tick has no second position either: it sits at one value and reaches a little way in from the edge. That is not a different geometry, only a different distance, so it is a setting rather than a second mark:

data(gapminder_2007) + point + x(gdp) + y(life) + style(opacity = 0.45) +
  data(gdp_rug) + rule + style(reach = "edge") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Where the countries actually sit on the income axis")
(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + style(opacity = 0.45) +
  data(gdp_rug) + rule + style(reach = "edge") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Where the countries actually sit on the income axis"))
data(gapminder_2007) + point + x(:gdp) + y(:life) +
  style(opacity = 0.45) + data(gdp_rug) + rule + style(reach = "edge") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Where the countries actually sit on the income axis")
plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  style({ opacity: 0.45 }), data(gdp_rug), rule, style({ reach: "edge" }),
  x_label("GDP per person"), y_label("Life expectancy"),
  title("Where the countries actually sit on the income axis"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Where the countries actually sit on the income axis Life expectancy GDP per person

Look along the bottom. Each tick is one country, drawn at its own income, and the row of them answers a question the cloud above cannot: how many countries are there at each income, including the ones hidden underneath each other? Points overlap, and a hundred countries crowded below $5,000 look much like forty. The ticks do not overlap in the same way, because they only have to agree on one coordinate instead of two, so the crowding at the left is visible as crowding.

That is what a rug is for in general. It puts the one-dimensional distribution of a variable in the margin of a plot that is busy showing something else. You can read the spread of gdp without giving up the scatter, and without a second plot that would have its own axis to reconcile.

Everything else about the mark is unchanged. style(reach = "edge") is the only difference between this and the threshold lines above, and there is no distance to set: a tick’s length comes from the panel, the way a label’s nudge comes from the font size. A number there would be a pixel count that meant something different in every facet and at every plot size.

18.3.1 The other axis

A rug is not tied to the horizontal. Which axis it lands on is decided the way it was for the threshold lines: by the column its own table holds. Hand it life instead of gdp and the ticks run up the left edge. Hand it both, as two layers, and you get the margins of the scatter on both sides at once:

data(gapminder_2007) + point + x(gdp) + y(life) + style(opacity = 0.45) +
  data(gdp_rug)  + rule + style(reach = "edge") +
  data(life_rug) + rule + style(reach = "edge") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Both margins, from two tables")
(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + style(opacity = 0.45) +
  data(gdp_rug)  + rule + style(reach = "edge") +
  data(life_rug) + rule + style(reach = "edge") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Both margins, from two tables"))
data(gapminder_2007) + point + x(:gdp) + y(:life) +
  style(opacity = 0.45) + data(gdp_rug) + rule + style(reach = "edge") +
  data(life_rug) + rule + style(reach = "edge") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Both margins, from two tables")
plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  style({ opacity: 0.45 }), data(gdp_rug), rule, style({ reach: "edge" }),
  data(life_rug), rule, style({ reach: "edge" }),
  x_label("GDP per person"), y_label("Life expectancy"),
  title("Both margins, from two tables"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Both margins, from two tables Life expectancy GDP per person

Two layers rather than one, and that is the constraint the chapter started with rather than a limitation of rugs. A rule reads its axis off its table, so a single table holding gdp and life would be the ambiguous case the grammar refuses. One table per axis is how you say which you meant.

Read the left edge and the picture changes character. The ticks crowd above 70, go noticeably sparse through the sixties, then spread out again from 60 all the way down to 40. That thin band in the middle is the point: of the 142 countries, 83 sit above 70 and 43 below 60, with only 16 in the decade between. It is a distribution with two groups and a gap, not one spread, and neither the scatter nor the bottom rug shows it.

18.4 Coloring the lines

Each row of a rule’s table is its own segment, so color maps per row. The second column in life_bands was waiting for this:

data(gapminder_2007) + point + x(gdp) + y(life) + style(color = "lightgrey") +
  data(life_bands) + rule + color(band) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("One line per band, keyed by the legend")
(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + style(color = "lightgrey") +
  data(life_bands) + rule + color(col.band) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("One line per band, keyed by the legend"))
data(gapminder_2007) + point + x(:gdp) + y(:life) +
  style(color = "lightgrey") + data(life_bands) + rule + color(:band) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("One line per band, keyed by the legend")
plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  style({ color: "lightgrey" }), data(life_bands), rule, color(col.band),
  x_label("GDP per person"), y_label("Life expectancy"),
  title("One line per band, keyed by the legend"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 One line per band, keyed by the legend Life expectancy GDP per person Band Low Middle High

A rule is paint on a hairline, so color takes categories rather than a continuous ramp, the same answer line and path give.

18.5 Thickening a rule

style(size = ) will make a rule as fat as you like, and a fat translucent one reads as a highlighted band:

data(gapminder_2007) + point + x(gdp) + y(life) + style(opacity = 0.55) +
  data(life_bands) + rule + style(color = "seagreen", size = 26, opacity = 0.20) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("A thick rule: emphasis, measured in pixels")
(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + style(opacity = 0.55) +
  data(life_bands) + rule + style(color = "seagreen", size = 26, opacity = 0.20) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("A thick rule: emphasis, measured in pixels"))
data(gapminder_2007) + point + x(:gdp) + y(:life) +
  style(opacity = 0.55) + data(life_bands) + rule +
  style(color = "seagreen", size = 26, opacity = 0.2) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("A thick rule: emphasis, measured in pixels")
plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  style({ opacity: 0.55 }), data(life_bands), rule,
  style({ color: "seagreen", size: 26, opacity: 0.2 }),
  x_label("GDP per person"), y_label("Life expectancy"),
  title("A thick rule: emphasis, measured in pixels"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 A thick rule: emphasis, measured in pixels Life expectancy GDP per person

That is a perfectly good way to draw attention to a value, and it is worth being clear about what it is: a stroke width is pixels, not data. The band above covers about four years of life expectancy, but only because of where this plot’s vertical axis happens to sit. Facet it, resize it, or change the data’s range and the same size = 26 will cover a different number of years while looking identical. Nothing is wrong with that as long as the width is decoration; it is wrong the moment the width is supposed to mean something.

When it should mean something (“the healthy range is 65 to 75”, not “look here”), the thickness belongs to the data, and then you want a region with real edges. That is ribbon fed a pre-computed pair, with a two-row table putting the left and right edges where you want them:

healthy <- data.frame(gdp = range(gapminder_2007$gdp), lo = 65.0, hi = 75.0)

data(healthy) + ribbon * bounds(lo, hi) + style(color = "seagreen", opacity = 0.18) +
  data(gapminder_2007) + point + x(gdp) + y(life) +
  data(life_bands) + rule + style(color = "seagreen", pattern = "dashed") +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("A band whose edges are 65 and 75, and rules on top")
0K 10K 20K 30K 40K 50K 40 50 60 70 80 A band whose edges are 65 and 75, and rules on top Life expectancy GDP per person

Now 65 and 75 are positions. They rescale with the axis, they survive a facet, and on a log axis they land where the data says rather than where the pixels fell. The highlight is written first so the points draw over it.

Give that same table two different gdp values instead of the data’s range and it is a box rather than a band, bounded on both axes, which is how you shade a corner of a scatter, or one span of a time series.

The one thing neither spelling does is reach the panel. A ribbon is bounded by its data, so it stops exactly at the numbers you gave it, and you cannot pad them outward without widening the scale itself. A rectangle that takes one extent from the data and the other from the panel is rule’s trick one dimension up, and that is exactly what zone is.

18.6 Bent: the ring and the spoke

The mark’s one sentence is a rule spans the axis it does not name, whole. In polar that sentence is unchanged, and it draws two shapes rather than one, because a circle has two ways of going all the way round.

Span the turn whole and you get a ring. Here it marks a target wind speed on the rose, and speed is the radial axis:

data(winds) + line * mean + x(direction) + y(speed) + polar() +
  data(speed_target) + rule + style(color = "firebrick", pattern = "dashed") +
  title("A target speed, ringed")
(data(winds) + line * mean + x(col.direction) + y(col.speed) + polar() +
  data(speed_target) + rule + style(color = "firebrick", pattern = "dashed") +
  title("A target speed, ringed"))
data(winds) + line * mean + x(:direction) + y(:speed) + polar() +
  data(speed_target) + rule +
  style(color = "firebrick", pattern = "dashed") +
  title("A target speed, ringed")
plot(data(winds), layer(line, mean), x(col.direction), y(col.speed),
  polar(), data(speed_target), rule,
  style({ color: "firebrick", pattern: "dashed" }),
  title("A target speed, ringed"))
N NE E SE S SW W NW 10 15 20 A target speed, ringed Speed Direction

Span the radius whole and you get a spoke. Nothing was added to the mark to get either one: spanning the axis a rule does not name is what it always did, and in a circle the radial axis runs from the center outward while the angular axis runs all the way round.

That is the test a mark has to pass here before it is given a shortcut. The pie earned one the same way: stated once, read off the bindings, and meaning the same thing in every coordinate space. A rule that needed a special case in polar would not have been one rule; it would have been three marks wearing one name.

18.7 What you can set

Everything a rule can be told to look like, generated from the engine’s own rule table so this page cannot drift from what style() actually accepts:

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

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

Four of those are a stroke’s ordinary controls, and one is the mark’s own. style(reach = ) decides how far the rule crosses the axis it does not name, and it is what makes the rug the same mark rather than a second one. The other four behave here as on line or path:

data(gapminder_2007) + point + x(gdp) + y(life) + style(color = "lightgrey") +
  data(data.frame(life = 50.0)) + rule + style(color = "firebrick", size = 4) +
  data(data.frame(life = 60.0)) + rule + style(color = "seagreen", pattern = "dashed") +
  data(data.frame(life = 70.0)) + rule +
    style(color = "steelblue", pattern = "dotted", size = 2.5) +
  data(data.frame(life = 80.0)) + rule +
    style(color = "black", size = 6, opacity = 0.25) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Bottom to top: size, dashed, dotted, a faded thick rule")
(data(gapminder_2007) + point + x(col.gdp) + y(col.life) + style(color = "lightgrey") +
  data({"life": [50.0]}, name='data.frame(life = 50.0)') + rule + style(color = "firebrick", size = 4) +
  data({"life": [60.0]}, name='data.frame(life = 60.0)') + rule + style(color = "seagreen", pattern = "dashed") +
  data({"life": [70.0]}, name='data.frame(life = 70.0)') + rule +
    style(color = "steelblue", pattern = "dotted", size = 2.5) +
  data({"life": [80.0]}, name='data.frame(life = 80.0)') + rule +
    style(color = "black", size = 6, opacity = 0.25) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Bottom to top: size, dashed, dotted, a faded thick rule"))
data(gapminder_2007) + point + x(:gdp) + y(:life) +
  style(color = "lightgrey") + data((life = [50],)) + rule +
  style(color = "firebrick", size = 4) + data((life = [60],)) + rule +
  style(color = "seagreen", pattern = "dashed") + data((life = [70],)) +
  rule + style(color = "steelblue", pattern = "dotted", size = 2.5) +
  data((life = [80],)) + rule +
  style(color = "black", size = 6, opacity = 0.25) +
  x_label("GDP per person") + y_label("Life expectancy") +
  title("Bottom to top: size, dashed, dotted, a faded thick rule")
plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  style({ color: "lightgrey" }), data({ life: [50] }), rule,
  style({ color: "firebrick", size: 4 }), data({ life: [60] }), rule,
  style({ color: "seagreen", pattern: "dashed" }), data({ life: [70] }),
  rule, style({ color: "steelblue", pattern: "dotted", size: 2.5 }),
  data({ life: [80] }), rule,
  style({ color: "black", size: 6, opacity: 0.25 }),
  x_label("GDP per person"), y_label("Life expectancy"),
  title("Bottom to top: size, dashed, dotted, a faded thick rule"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Bottom to top: size, dashed, dotted, a faded thick rule Life expectancy GDP per person

The pattern values are the three dashes, because a rule is a stroke. style(pattern = "hatch") is refused, pointing at the dashes: a hatch is a fill’s texture, and it is what zone takes instead. One setting name, one realization per geometry.

What a rule refuses says the same thing from the other side. There is no shape, because a stroke has no glyph to choose. And there is no border:

data(gapminder_2007) + point + x(gdp) + y(life) +
  data(life_bands) + rule + style(border_color = "black")
Error:
! gog: a `rule` is drawn with a stroke, not a filled shape — it has no separate border. `style(color = )` sets its color and `style(size = )` its width.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

A border belongs to a filled shape, and a rule is the outline already, so style(color = ) is its color and style(size = ) its width. That is the same answer line, step and path give, because it is the same geometry.

18.8 What it refuses

Give a rule the plot’s own table and both position columns answer at once. There is then nothing to say which axis is meant, so the grammar says so rather than picking one:

data(gapminder_2007) + point + x(gdp) + y(life) + rule
Error:
! gog: `rule` marks a value on one axis and spans the other, but this table has a column for both — `x(gdp)` and `y(life)` — so there is nothing to say which axis is meant. Say it on the layer: `rule + x(gdp)` places every line by `gdp`, `rule + y(life)` by `life`. Giving the rule its own table holding just the one column works too.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

That refusal is the reason the tables above each hold a single column. It is the same shape as a bar with a category on both axes: the grammar can only read an orientation when the bindings actually distinguish the two axes, and guessing would teach a rule that is not real.

A rule takes no transform either. It has handed one whole axis to the panel, so there is no measure for a statistic to compute and nowhere to put the answer:

data(gapminder_2007) + point + x(gdp) + y(life) +
  data(life_bands) + rule * mean
Error:
! gog: `rule` is placed by one column and spans the other axis, so it has no measure for `mean` to compute and nowhere to put the answer. Compute the value where your data lives and give the rule a table of the results — one row per line — which is what a rule's position always is: a column.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The direction it gives is the one the mark is built on. A line at the mean is a mean you computed, handed over as a column, which is what a rule’s position always is.