| life | share |
|---|---|
| 71.777 | 0.0333333 |
| 72.476 | 0.0666667 |
| 73.005 | 0.1000000 |
| 73.338 | 0.1333333 |
| 74.002 | 0.1666667 |
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:
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"))“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:
| 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"))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:
| 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"))“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:
| 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"))“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:
| 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"))“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"))“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:
| 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"))“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:
| 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"))“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.