12  Step

How do you draw a value that holds until it jumps? step draws it as a staircase. A line slopes straight from one point to the next. A step holds each value until it changes, then jumps. The flat part runs from one observation to the next, and the jump is where the value changes. That is the right shape for anything that does not change between observations: a cumulative count, a survival probability, an interest rate. step takes the same channels as line, and only the path drawn between two points differs.

Several professions draw this shape, each under its own name, and step draws all of these plots. Statisticians draw the empirical cumulative distribution function (the empirical CDF) with it. Medical researchers draw the survival curve, the Kaplan–Meier estimate (Kaplan & Meier, 1958), which drops at each observed death. Reliability engineers draw the same staircase for parts rather than patients, and call it a failure plot. Analysts in finance draw the step chart of a rate that holds until someone changes it. A cumulative count rises and a survival probability falls, and step draws both as this one staircase.

12.1 A step function

What share of European countries have a life expectancy at or below 75 years, or 80, or any value you choose? The empirical CDF answers all of those questions at once. It is a step function: flat between observations, with a jump at each one. The gm_europe_cdf table holds one row per European country in 2007: its life expectancy, and the share of countries at or below it. Sort a table by the value, and each row’s share is its position divided by the number of rows. The table already holds the shares, so step needs no transform: give it an x and a y, and it draws the curve. With n observations, each observation adds 1/n to the share:

gm_europe_cdf: first 5 of 30 rows
life share
71.777 0.0333333
72.476 0.0666667
73.005 0.1000000
73.338 0.1333333
74.002 0.1666667
data(gm_europe_cdf) + step + x(life) + y(share) +
  x_label("Life expectancy (years)") + y_label("Cumulative share of countries") +
  title("Empirical CDF of life expectancy in Europe, 2007")
(data(gm_europe_cdf) + step + x(col.life) + y(col.share) +
  x_label("Life expectancy (years)") + y_label("Cumulative share of countries") +
  title("Empirical CDF of life expectancy in Europe, 2007"))
data(gm_europe_cdf) + step + x(:life) + y(:share) +
  x_label("Life expectancy (years)") +
  y_label("Cumulative share of countries") +
  title("Empirical CDF of life expectancy in Europe, 2007")
plot(data(gm_europe_cdf), step, x(col.life), y(col.share),
  x_label("Life expectancy (years)"),
  y_label("Cumulative share of countries"),
  title("Empirical CDF of life expectancy in Europe, 2007"))
72 74 76 78 80 82 0.0 0.2 0.4 0.6 0.8 1.0 Empirical CDF of life expectancy in Europe, 2007 Cumulative share of countries Life expectancy (years)

“Given the gm europe cdf table: a step outline, x is life, y is share.”

A line on this same table would slope between points, so it would show cumulative shares at life expectancies no country has. The step is exact: between two observations no country is added, so the share does not change.

The cumulative transform computes the same shares from the countries themselves. On a continuous axis, count tallies the rows at each distinct value, and cumulative(share = TRUE) adds the tallies up and ends at 1. The gm_europe table holds the same 30 countries, with no share column:

gm_europe: first 5 of 30 rows
country continent year life population gdp
Albania Europe 2007 76.423 3600523 5937.030
Austria Europe 2007 79.829 8199783 36126.493
Belgium Europe 2007 79.441 10392226 33692.605
Bosnia and Herzegovina Europe 2007 74.852 4552198 7446.299
Bulgaria Europe 2007 73.005 7322858 10680.793
data(gm_europe) + step * count * cumulative(share = TRUE) + x(life) +
  x_label("Life expectancy (years)") + y_label("Cumulative share of countries") +
  title("The same curve, computed by cumulative")
(data(gm_europe) + step * count * cumulative(share = True) + x(col.life) +
  x_label("Life expectancy (years)") + y_label("Cumulative share of countries") +
  title("The same curve, computed by cumulative"))
data(gm_europe) + step * count * cumulative(share = true) + x(:life) +
  x_label("Life expectancy (years)") +
  y_label("Cumulative share of countries") +
  title("The same curve, computed by cumulative")
plot(data(gm_europe), layer(step, count, cumulative({ share: true })),
  x(col.life), x_label("Life expectancy (years)"),
  y_label("Cumulative share of countries"),
  title("The same curve, computed by cumulative"))
72 74 76 78 80 82 0.0 0.2 0.4 0.6 0.8 1.0 The same curve, computed by cumulative Cumulative share of countries Life expectancy (years)

“Given gapminder Europe: a step outline derived by count and cumulative as shares, x is life.”

The curve is the same as the one above, with a jump at each of the 30 countries. With bin in place of count, it jumps once for each bin instead.

Whether you can see the flat parts depends on how many observations there are. Thirty countries make each jump a thirtieth of the axis, and most of the flat parts between them are easy to see. Draw the same plot over all 142 countries in the gapminder_2007 table, and each jump is about a fifth as tall as here. The staircase then reads as a slightly rough line.

12.2 A value that holds until it changes

The same exactness matters for anything that jumps rather than changing gradually: a policy rate (the interest rate a central bank sets), a price, an inventory level. Each holds its value, then jumps on a single day. A straight line would draw a gradual change that never happened. The policy_rates table holds one interest rate for each year from 2018 to 2023, and a step draws it:

policy_rates: first 5 of 6 rows
year rate
2018 1.50
2019 2.25
2020 0.25
2021 0.25
2022 1.75
data(policy_rates) + step + x(year) + y(rate) +
  x_label("Year") + y_label("Policy rate (%)") +
  title("A rate rises, falls, holds two years, then rises twice")
(data(policy_rates) + step + x(col.year) + y(col.rate) +
  x_label("Year") + y_label("Policy rate (%)") +
  title("A rate rises, falls, holds two years, then rises twice"))
data(policy_rates) + step + x(:year) + y(:rate) + x_label("Year") +
  y_label("Policy rate (%)") +
  title("A rate rises, falls, holds two years, then rises twice")
plot(data(policy_rates), step, x(col.year), y(col.rate), x_label("Year"),
  y_label("Policy rate (%)"),
  title("A rate rises, falls, holds two years, then rises twice"))
2018 2019 2020 2021 2022 2023 1 2 3 4 A rate rises, falls, holds two years, then rises twice Policy rate (%) Year

The rate fell to 0.25 in 2020 and held there for two full years, then rose twice. A line would have sloped into and out of that flat part, showing rates that were never set.

12.3 Multiple series

The policy rate was a single series, and often you need to compare two. Each of two sites keeps a stock of units, and only a delivery or a sale changes it. To compare them week by week, you want both on one plot. color and group split a step exactly as they split a line: one staircase per category. Here two series hold and jump independently:

inventory: first 5 of 16 rows
week units site
1 40 North
2 40 North
3 25 North
4 25 North
5 25 North
data(inventory) + step + x(week) + y(units) + color(site) +
  x_label("Week") + y_label("Units in stock") +
  title("Inventory by site: each site holds until it changes")
(data(inventory) + step + x(col.week) + y(col.units) + color(col.site) +
  x_label("Week") + y_label("Units in stock") +
  title("Inventory by site: each site holds until it changes"))
data(inventory) + step + x(:week) + y(:units) + color(:site) +
  x_label("Week") + y_label("Units in stock") +
  title("Inventory by site: each site holds until it changes")
plot(data(inventory), step, x(col.week), y(col.units), color(col.site),
  x_label("Week"), y_label("Units in stock"),
  title("Inventory by site: each site holds until it changes"))
2 4 6 8 10 20 30 40 50 Inventory by site: each site holds until it changes Units in stock Week Site North South

“Given the inventory: step outlines, x is week, y is units, color by site.”

North holds at 40 for two weeks, then falls twice before jumping to 55. South starts lower, rises above North in week 5, and falls below it again in week 7. Neither series slopes between its changes.

12.4 A category on the domain

Every x so far has been a number. How do the continents compare on one summary value? A step will draw that plot, but a bar is the better mark for it, as the step drawn below shows. A step reads x as its domain, the axis it runs along, and y as its measure, the quantity it traces. A category on the domain gets one slot per value, as in Line. The staircase holds each continent’s mean from the center of its slot to the center of the next one:

gapminder_2007: first 5 of 142 rows
country continent year life population gdp
Afghanistan Asia 2007 43.828 31889923 974.5803
Albania Europe 2007 76.423 3600523 5937.0295
Algeria Africa 2007 72.301 33333216 6223.3675
Angola Africa 2007 42.731 12420476 4797.2313
Argentina Americas 2007 75.320 40301927 12779.3796
data(gapminder_2007) + step * mean + x(continent) + y(life) +
  y_label("Mean life expectancy") +
  title("step * mean + x(continent): the five means as a staircase")
(data(gapminder_2007) + step * mean + x(col.continent) + y(col.life) +
  y_label("Mean life expectancy") +
  title("step * mean + x(continent): the five means as a staircase"))
data(gapminder_2007) + step * mean + x(:continent) + y(:life) +
  y_label("Mean life expectancy") +
  title("step * mean + x(continent): the five means as a staircase")
plot(data(gapminder_2007), layer(step, mean), x(col.continent),
  y(col.life), y_label("Mean life expectancy"),
  title("step * mean + x(continent): the five means as a staircase"))
Asia Europe Africa Americas Oceania 60 70 80 step * mean + x(continent): the five means as a staircase Mean life expectancy Continent

“Given gapminder 2007: a step outline derived by mean, x is continent, y is life.”

Africa is lowest at 54.8 years and Oceania highest at 80.7. Oceania, the last slot, has no next slot, so its mean is only the top of the last jump. The five slots run in the order the continents first appear in the table. That order means nothing, so the rise and fall across the slots is not a trend.

A step draws only the values that were measured. A line slopes from one category to the next, and nothing was measured between them. A step holds flat and then jumps, so every height it draws is one of the five means.

That does not make it the right mark for this plot. A flat part claims that the value held across the part of the axis under it. A policy rate does hold between two dates, as A value that holds until it changes showed. Between two continents there is no value to hold, so the flat part there says nothing about the data. One summary value per category is what a bar draws: keep the mean and change the mark. Each bar is in its own slot, joined to nothing.

Other marks take the same mean, and only the shape changes:

Mark derived by mean Shape
bar * mean one bar per category, each in its own slot
point * mean one dot at each mean
line * mean the means joined into a profile
area * mean the same profile, filled to the baseline
step * mean each mean held flat until the next category, the plot above

Legal is not the same as useful. gog draws this step and prints no warning. Law 8 says the grammar guarantees a legal plot, never a useful one, so the choice of mark is yours.

12.5 Histogram outline

mean is not the only transform a step takes. With bin, the staircase answers a question that filled bars make harder to read: how do three distributions compare in one panel? Filled histograms overlap wherever two groups share a bin. gog fades those fills so the shapes underneath still show, but the faded colors mix where they overlap. A step has no fill at all, so nothing needs fading. step * bin draws a histogram as an outline rather than as bars: one staircase along the tops of the bins. Add color and the outline splits, one staircase per group, cut on the same bins:

iris_flowers: first 5 of 150 rows
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) + step * bin + x(petal_length) + color(species) +
  x_label("Petal length (cm)") + title("Petal length by species: step outline")
(data(iris_flowers) + step * bin + x(col.petal_length) + color(col.species) +
  x_label("Petal length (cm)") + title("Petal length by species: step outline"))
data(iris_flowers) + step * bin + x(:petal_length) + color(:species) +
  x_label("Petal length (cm)") +
  title("Petal length by species: step outline")
plot(data(iris_flowers), layer(step, bin), x(col.petal_length),
  color(col.species), x_label("Petal length (cm)"),
  title("Petal length by species: step outline"))
2 4 6 0 10 20 30 40 Petal length by species: step outline Count Petal length (cm) Species setosa versicolor virginica

“Given the iris flowers: step outlines derived by bin, x is petal length, color by species.”

bin cuts all three species into the same bins, so their outlines share the same edges. An outline goes to zero in any bin where its species has no rows.

The same bin works with other marks, and only the mark changes:

Mark derived by bin Shape
bar * bin filled bars
line * bin frequency polygon: one stroke of straight pieces through the bin centers
area * bin the same polygon, filled to the baseline
step * bin the staircase outline above

12.6 Stroke width and opacity

The three outlines above are thin, and where two of them run along the same bin edge, the one drawn last covers the other. A thicker stroke is easier to see, and a little transparency lets the covered stroke show through. Like line, a step is a single stroke per series, so size and opacity are not channels; they are settings on the whole stroke:

data(iris_flowers) + step * bin + x(petal_length) + color(species) +
  style(size = 2.5, opacity = 0.7) +
  x_label("Petal length (cm)") + title("A thicker, more transparent outline")
(data(iris_flowers) + step * bin + x(col.petal_length) + color(col.species) +
  style(size = 2.5, opacity = 0.7) +
  x_label("Petal length (cm)") + title("A thicker, more transparent outline"))
data(iris_flowers) + step * bin + x(:petal_length) + color(:species) +
  style(size = 2.5, opacity = 0.7) + x_label("Petal length (cm)") +
  title("A thicker, more transparent outline")
plot(data(iris_flowers), layer(step, bin), x(col.petal_length),
  color(col.species), style({ size: 2.5, opacity: 0.7 }),
  x_label("Petal length (cm)"),
  title("A thicker, more transparent outline"))
2 4 6 0 10 20 30 40 A thicker, more transparent outline Count Petal length (cm) Species setosa versicolor virginica

“Given the iris flowers: step outlines derived by bin, x is petal length, color by species, with size 2.5 and opacity 0.7.”

size sets the stroke width. opacity makes the whole staircase more transparent. Both are set, never mapped, exactly as on a line; Setting vs mapping explains the difference.

12.7 What you can set

A setting changes how the staircase looks without reading a column, and each mark takes its own. These are a step’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

A step is a stroke, so it takes exactly what line takes: one color, one width, one opacity and one dash for the whole staircase. Mapped to a column instead of set, color alone can change along the staircase, as the last plot in What it refuses shows.

Set three of them at once and the staircase changes color, width and dash together. The six weeks table holds 42 consecutive days of orders, and day is a date column, so the axis shows calendar days:

six_weeks: first 5 of 42 rows
day orders weekday week
2024-03-01 20 Fri Week 1
2024-03-02 23 Sat Week 1
2024-03-03 25 Sun Week 1
2024-03-04 28 Mon Week 1
2024-03-05 30 Tue Week 1
data(six_weeks) + step + x(day) + y(orders) +
  style(color = "purple", size = 2.5, pattern = "dotted") +
  y_label("Orders") + title("A dotted staircase")
(data(six_weeks) + step + x(col.day) + y(col.orders) +
  style(color = "purple", size = 2.5, pattern = "dotted") +
  y_label("Orders") + title("A dotted staircase"))
data(six_weeks) + step + x(:day) + y(:orders) +
  style(color = "purple", size = 2.5, pattern = "dotted") +
  y_label("Orders") + title("A dotted staircase")
plot(data(six_weeks), step, x(col.day), y(col.orders),
  style({ color: "purple", size: 2.5, pattern: "dotted" }),
  y_label("Orders"), title("A dotted staircase"))
Mar 4 Mar 11 Mar 18 Mar 25 Apr 1 Apr 8 15 20 25 30 35 A dotted staircase Orders Day

“Given the six weeks table: a step outline, x is day, y is orders, colored purple, with size 2.5 and pattern dotted.”

The staircase and the dash change different things. The staircase is geometry: it sets where the path goes, and puts a corner where the value changes. The dash is paint: it changes how the path looks, never where it goes. That is why step is a mark, while a dash only changes how a mark is drawn.

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.

12.8 What it refuses

A step is one connected stroke with no filled interior, and the first two refusals follow from that. The third comes from the axes, and the fourth from the series.

One stroke has one width, the same at every place along it. So no column can make the staircase wider in one place than in another:

gapminder_asia: first 5 of 60 rows
country continent year life population gdp
China Asia 1952 44.00000 556263527 400.4486
China Asia 1957 50.54896 637408000 575.9870
China Asia 1962 44.50136 665770000 487.6740
China Asia 1967 58.38112 754550000 612.7057
China Asia 1972 63.11888 862030000 676.9001
data(gapminder_asia) + step + x(year) + y(life) + size(gdp)
data(gapminder_asia) + step + x(col.year) + y(col.life) + size(col.gdp)
data(gapminder_asia) + step + x(:year) + y(:life) + size(:gdp)
plot(data(gapminder_asia), step, x(col.year), y(col.life), size(col.gdp))
Error:
! gog: `size` cannot be bound to `step` — a step 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.

The stroke still has a width, and style(size = ) sets it. size(column) asks for a width per row, which one stroke cannot give. That distinction is the subject of Setting vs mapping.

The second refusal is about which patterns a stroke accepts. A hatch is a texture that fills an area, and a staircase is a stroke with no area to fill:

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

The third refusal is one that line, area and ribbon give too. A step reads y as its measure, and a category is not a quantity, so gog refuses it there:

data(gapminder_2007) + step * mean + x(life) + y(continent)
data(gapminder_2007) + step * mean + x(col.life) + y(col.continent)
data(gapminder_2007) + step * mean + x(:life) + y(:continent)
plot(data(gapminder_2007), layer(step, mean), x(col.life),
  y(col.continent))
Error:
! gog: `y(continent)` maps a categorical (text) column, but `y` on `step` needs a continuous (numeric) column. On these marks `x` is the domain and `y` the measure, and a category is not a quantity to measure: a mean of category names is not a number, and a region has no categorical baseline to close on. Put the category on `x` instead — `line * mean + x(<category>) + y(<number>)` is the profile plot, and `area * mean` fills it. Unlike `bar`/`box`/`interval`, these marks do not read their orientation off the bindings, because their two axes do not have the same role.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The fourth is shared by every mark that joins its positions. A staircase needs at least two in each series. A plot in which every series holds one would draw nothing, so gog refuses it, as Line shows.

color is not refused: it is the one property that can change along a single staircase. This is the plot promised in What you can set. A width and a dash belong to the whole stroke. A dash can still change from one series to the next, as it does for a line, but never along one series. A color belongs to each place on the stroke instead, so a column can change it from one row to the next. Each flat part takes its row’s color, and each jump blends the colors of the two rows it joins:

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

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

Read one country’s staircase from left to right and you see two variables. The height is how long people live, and the color is how rich the country was then. The color stays the same along each flat part and changes only at the jumps, where the value changes too.

12.9 In short

A step holds each value until it changes, so it invents no value between two observations. It takes the same channels and settings as a line: width and opacity belong to the whole stroke, and only color varies along it. With color or group it draws one staircase per category, and with bin it draws a histogram as an outline instead of as bars.

12.10 What comes next

Each staircase follows one value at each x. Interval spans two values at every position instead, from a low one to a high one.