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 is the tread, and the jump is the riser. 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 subjects have their own name for this shape, and one mark draws them all. Statisticians draw the empirical cumulative distribution function (the empirical CDF) with it. Doctors draw the survival curve, the Kaplan–Meier estimate (Kaplan & Meier, 1958), which steps down 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 at once, and it is the plainest step function there is. 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. Give step an x and a y with no transform, and it draws one. With n observations the curve rises by 1/n at each observation, and stays flat between them:

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: the share stayed at that value until the next observation.

The size of n decides whether you can see that. Thirty countries make each riser a thirtieth of the axis, and the treads between them are plain. The same plot over all 142 countries in the world gives each riser a fifth of that height, and the staircase 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, 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:

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 holds, falls, then rises")
(data(policy_rates) + step + x(col.year) + y(col.rate) +
  x_label("Year") + y_label("Policy rate (%)") +
  title("A rate holds, falls, then rises"))
data(policy_rates) + step + x(:year) + y(:rate) + x_label("Year") +
  y_label("Policy rate (%)") + title("A rate holds, falls, then rises")
plot(data(policy_rates), step, x(col.year), y(col.rate), x_label("Year"),
  y_label("Policy rate (%)"), title("A rate holds, falls, then rises"))
2018 2019 2020 2021 2022 2023 1 2 3 4 A rate holds, falls, then rises 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 run, showing rates that were never set.

12.3 Multiple series

One rate held and jumped on its own. 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, passes North in week 5, and ends below it again. Neither series slopes between its changes.

12.4 A category on the domain

How do the continents compare on one summary, without a slope that claims a change between them? step takes the same channels as line, so a category on the domain works here as it does in Line. The staircase holds each category’s value across its own slot:

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): one value per category, held")
(data(gapminder_2007) + step * mean + x(col.continent) + y(col.life) +
  y_label("Mean life expectancy") +
  title("step * mean + x(continent): one value per category, held"))
data(gapminder_2007) + step * mean + x(:continent) + y(:life) +
  y_label("Mean life expectancy") +
  title("step * mean + x(continent): one value per category, held")
plot(data(gapminder_2007), layer(step, mean), x(col.continent),
  y(col.life), y_label("Mean life expectancy"),
  title("step * mean + x(continent): one value per category, held"))
Asia Europe Africa Americas Oceania 60 70 80 step * mean + x(continent): one value per category, held Mean life expectancy Continent

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

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, which suits separate groups better. The section A value that holds said the same thing about time. It is true for categories as well.

12.5 Histogram outline

How do three distributions compare in one panel? Three filled histograms overlap in the middle of the panel, and gog fades those fills so the shapes underneath still show. A step has no fill at all, so nothing needs fading. step * bin draws a histogram as an outline rather than as bars: one stepped line tracing the top of each bin. 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. A step is a single stroke with no fill, so where two outlines cross neither hides the other.

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

Mark derived by bin Shape
bar * bin filled bars
line * bin frequency polygon: straight lines 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 thin strokes are hard to follow where they cross. A thicker stroke is easier to see, and a little transparency keeps each one visible where it passes over another. 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 follow the same setting vs mapping rule as line; see Setting vs mapping.

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

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. Of the four, color is the one that can also vary along the staircase, and the plot at the end of this chapter shows that.

Set three of them at once and the staircase changes color, weight and dash together. The six weeks table holds 42 consecutive days of orders, with day a real date:

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 20 30 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 and pattern is a setting.

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.

One stroke has one width. Point anywhere on the staircase and there is no width for that place alone. So a width cannot vary along it:

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 has no size feature. Remove the `gdp` mapping from `size`, or use a mark that has one.
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 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", or "dotted".
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The third is the one line, area and ribbon also give. A step reads x as its domain and y as its measure, so a category on y has nothing to measure:

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.

color is different from the first two, and it is the case the settings section pointed to. A width belongs to the whole stroke, but a color belongs to a place on it. Point anywhere on the staircase and that place has a color, so a measure can vary along it. Each tread takes its row’s color, and each riser 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 tread and the riser are the whole mark: a value held, then the jump to the next one.