18  Rule

Where is the line you want to compare every value against? rule draws a line at one position and lets the other axis decide how far it reaches. Every other mark reads two positions; a rule reads one and takes the rest from the panel. That gives two familiar plots: a reference line at a threshold, and a rug of ticks showing where the observations sit.

Those are often three separate marks: a vertical reference line, a horizontal one, and a rug. gog has one. A vertical and a horizontal reference line differ only in which axis carries the position. The grammar reads that off the bindings, exactly as it does for a bar.

18.1 A line at a threshold

Which countries have a life expectancy above 70? A scatter leaves the reader to find 70 on the axis and hold a line there by eye. A line drawn at the threshold does that once, for every reader. Here is the 2007 gapminder scatter, income against life expectancy, with lines drawn at three life-expectancy thresholds:

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
life_bands: all 3 rows
life band
60 Low
70 Middle
80 High
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 property: 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.

Every atom that takes a column works this way (color(species) names a column, not a color), and that 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, so it scales, facets and bends with everything else.

18.2 Which axis it lands on

Nothing in that sentence said “horizontal”. The mark decided it, and this is 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.

A threshold on income wants a line standing up rather than lying across, and there is no second mark for it. Change which column the table holds and the same word draws the line vertically:

gdp_threshold: its one row
gdp
10000
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 and the sentence did not change. Where ggplot2 has geom_vline() and geom_hline(), and matplotlib axvline() and axhline(), gog has one rule: the column the table holds is what chose the axis.

18.3 A rug is the same mark

A scatter says where each country sits. How the countries are spread along the income axis alone is a second question. 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) +
  rule + x(gdp) + 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) +
  rule + x(col.gdp) + 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) + rule + x(:gdp) + 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 }), rule, x(col.gdp), 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

“Given gapminder 2007: points, x is gdp, y is life, and also rules, x is gdp, with reach edge.”

The rule names x(gdp) even though the points already named it. This layer has no table of its own, so it reads the scatter’s, which holds both gdp and life. Both axes answer, so the rule has to say which one it sits on. life_bands above held only one, which is why those rules named nothing.

Look along the bottom. Each tick is one country, drawn at its own income. Together they answer a question the points above cannot. How many countries sit at each income, counting the ones hidden under other points? Points overlap, so the 66 countries below $5,000 look like far fewer dots than that. The ticks share only one coordinate instead of two, so they overlap less, and the crowding at the left stays visible.

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 already showing something else. You can read the spread of gdp without losing the scatter, and without a second plot whose axis you would have to match.

Everything else about the mark is unchanged. The only new setting is style(reach = "edge"), and there is no distance to set. A tick’s length comes from the panel, the way a label’s nudge distance 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

Income is not the only axis worth a rug. Life expectancy has a distribution of its own, and the scatter hides it the same way. A rug is not tied to the horizontal. Which axis it lands on is decided by the position you name on the layer. Write y(life) instead of x(gdp) and the ticks run up the left edge. Write both, as two layers, and the scatter gets a rug along the bottom and another up the left at once:

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

Two layers rather than one, because each rug names one axis, and both read the scatter’s table.

Read the left edge and the distribution looks different. The ticks crowd together above 70, thin out between 60 and 70, then run down to 40 in a looser second band. That thin band in the middle is what to notice: of the 142 countries, 83 sit above 70 and 43 below 60, with only 16 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

Three lines drawn alike leave the reader to work out which threshold is which from the axis. A name on each, carried by its color and decoded by the legend, removes that step. Each row of a rule’s table is its own segment, so color maps per row. The band column in life_bands, which names each threshold, is for this:

data(gapminder_2007) + point + x(gdp) + y(life) + style(color = "lightgray") +
  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 = "lightgray") +
  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 = "lightgray") + 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: "lightgray" }), 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

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

A thin line gives a reader no area to compare shades in, so color here takes categories, not a continuous ramp. line and path answer the same way.

18.5 Thickening a rule

A thin line marks a threshold without making it prominent. When one value is the point of the plot, it should be seen before the data around it. Width is the rule’s other visible setting. style(size = ) sets it, and a wide, faint rule 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

“Given gapminder 2007: points, x is gdp, y is life, and also rules from the life bands, colored seagreen, with size 26 and opacity 0.20.”

That is a good way to mark a value, as long as you remember what the width is: pixels, not data. The band above covers about two and a half years of life expectancy. It covers that much only because this panel holds the range from 40 to 80. 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, given a low column and a high column you computed. The table has two rows, one at each end of the income axis, so the band reaches from side to side:

healthy_band: all 2 rows
gdp lo hi
277.5519 65 75
49357.1902 65 75
data(healthy_band) + 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")
(data(healthy_band) + ribbon * bounds(col.lo, col.hi) + style(color = "seagreen", opacity = 0.18) +
  data(gapminder_2007) + point + x(col.gdp) + y(col.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"))
data(healthy_band) + 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")
plot(data(healthy_band), layer(ribbon, bounds(col.lo, col.hi)),
  style({ color: "seagreen", opacity: 0.18 }), data(gapminder_2007),
  point, x(col.gdp), y(col.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

“Given the healthy band table: a ribbon from lo to hi; then given gapminder 2007: points, x is gdp, y is life, and also rules from the life bands.”

Now 65 and 75 are positions. They rescale with the axis and keep the same values in every facet. On a log axis they land where the data puts them, not at a fixed number of pixels. 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 the band becomes a box bounded on both axes. That is how you shade a corner of a scatter, or one span of a time series.

Neither the band nor the box stretches to the edge of 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. For a rectangle that takes one side from the data and the other from the panel, use zone. It is rule with one more dimension.

18.6 In polar: the ring and the spoke

A wind plot is drawn on a circle, and a target speed on it is a threshold like any other. The question is what a line at one value becomes when the axis it spans is bent. The mark does one thing: it spans the whole of the axis it does not name. In polar that is unchanged, and it draws two shapes rather than one, because a circle has two axes to span. Polar builds the circle itself; what matters here is that a rule spans whichever axis it does not name.

winds is the table The book’s data introduced, and speed_target holds the one target speed:

winds: first 5 of 264 rows
direction bearing speed season
N 19.612260 10.1 Winter
N 357.049499 11.0 Winter
N 349.709737 13.8 Winter
N 2.602499 7.4 Winter
N 338.210715 3.7 Summer
speed_target: its one row
speed
15

The angular axis runs all the way around the circle, and that whole sweep is the turn. Span the turn whole and you get a ring. Here it marks a target wind speed on a circular wind plot, 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

“Given the winds: a line derived by mean, x is direction, y is speed, in polar, and also rules from the speed target.”

Span the radius whole and you get a spoke, a line from the center to the rim. Give the rule a table holding direction instead of speed, and the line stands at that compass point. Neither shape is a special case in the mark. 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 around.

That is what a mark must show before polar counts as the same mark: stated once, read off the bindings, meaning the same thing in every space. A rule that needed a special case in polar would not be one mark. It would be three: a flat line, a ring, and a spoke.

18.7 What you can set

A threshold line has to look different from the data it crosses, and no column says how. A setting decides that without reading one, and each mark takes its own. These are a rule’s, generated from the engine’s own legality table, so this page cannot differ from what style() 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

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 = "lightgray") +
  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 = "lightgray") +
  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 = "lightgray") + 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: "lightgray" }), 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

“Given gapminder 2007: points, x is gdp, y is life, colored lightgray; then given one row with life 50: a rule colored firebrick, with size 4; then given one row with life 60: a rule colored seagreen, with pattern dashed; then given one row with life 70: a rule colored steelblue, with pattern dotted and size 2.5; then given one row with life 80: a rule colored black, with size 6 and opacity 0.25.”

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 follows from the same geometry. There is no shape, because a stroke has no glyph to choose, and there is no border. What it refuses shows the border refused.

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.

18.8 What it refuses

The shortest sentence to try is a bare rule over the plot’s own table, expecting a default axis. 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 gog refuses rather than picking one:

data(gapminder_2007) + point + x(gdp) + y(life) + rule
data(gapminder_2007) + point + x(col.gdp) + y(col.life) + rule
data(gapminder_2007) + point + x(:gdp) + y(:life) + rule
plot(data(gapminder_2007), point, x(col.gdp), y(col.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 why the rugs above name their axis, rule + x(gdp) and rule + y(life). A threshold with a table of its own needs no position. That table holds only one of the two columns, so the axis is read off it. A bar with categories on both axes is refused for a related reason. Neither axis measures anything, so there is nothing to read the orientation from, and gog will not guess.

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
(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
  data(life_bands) + rule * mean)
data(gapminder_2007) + point + x(:gdp) + y(:life) + data(life_bands) +
  rule * mean
plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  data(life_bands), layer(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 message points back at what a rule is. A line at the mean is a mean you computed yourself, passed in as a column. A rule’s position always comes from a column.

A rule has no border either. A border belongs to a filled shape, and a rule is the outline already:

data(gapminder_2007) + point + x(gdp) + y(life) +
  data(life_bands) + rule + style(border_color = "black")
(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
  data(life_bands) + rule + style(border_color = "black"))
data(gapminder_2007) + point + x(:gdp) + y(:life) + data(life_bands) +
  rule + style(border_color = "black")
plot(data(gapminder_2007), point, x(col.gdp), y(col.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.

So style(color = ) is a rule’s color and style(size = ) its width. That is the same answer line, step and path give, because it is the same geometry.