How to draw a strip map of events along a route

On this page

Problem statement

A route is one-dimensional and a map is two-dimensional, so a map of a 69-mile highway spends most of its ink on where the road goes and almost none on what is along it. A strip map does the opposite: it straightens the route into a horizontal axis measured in postmiles, and stacks the event tables above it.

It is the right picture for anything whose position is a measure β€” volumes, structures, pavement condition, work zones β€” and it is a few lines of matplotlib once the events are on a common measure.

Quick answer

Measure on the x axis, one lane per event table:

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np, pandas as pd
from lrs import query, routes

shn = routes("County='SLO' AND Route=101 AND AlignCode='Right'").sort_values("bPM")

aadt = pd.DataFrame([f["attributes"] for f in
                     query("Traffic_AADT", "CNTY='SLO' AND RTE='101'")])
aadt["PM"] = pd.to_numeric(aadt.PM, errors="coerce")
aadt["AHEAD_AADT"] = pd.to_numeric(aadt.AHEAD_AADT, errors="coerce")
st = (aadt.drop_duplicates(subset=["PM_PFX", "PM", "DESCRIPTION"])
      .sort_values("PM").dropna(subset=["PM"]).reset_index(drop=True))

fig, ax = plt.subplots(figsize=(12, 3.2))
ax.step(st.PM, st.AHEAD_AADT, where="post", color="#0369a1", linewidth=1.8)
ax.fill_between(st.PM, st.AHEAD_AADT, step="post", alpha=0.15, color="#0ea5e9")
ax.set_xlabel("postmile, US 101 in San Luis Obispo county")
ax.set_ylabel("AADT")
ax.set_xlim(0, shn.ePM.max())
ax.grid(axis="y", alpha=0.3)
fig.savefig("strip_volume.png", dpi=150, bbox_inches="tight")

import os
print(f"{len(st)} stations plotted over {shn.ePM.max():.3f} postmiles")
print(f"volume range {st.AHEAD_AADT.min():,.0f}–{st.AHEAD_AADT.max():,.0f}")
print(f"wrote strip_volume.png, {os.path.getsize('strip_volume.png') / 1024:.0f} KB")
48 stations plotted over 69.322 postmiles
volume range 19,500–84,000
wrote strip_volume.png, 25 KB

A step plot, not a line: the volume is constant between count stations, and drawing it as a slope claims a gradient the data does not have.

A strip map: a measure axis with lanes for line events, point events and route segments. one axis for the route, one lane per tablevolumestructuressegments017355269postmile β€” the same axis for every lane, and that is the information spatialworkflow.io
One axis for the route, one lane per table, and the measure does the aligning.

One measure, steps and bands, lanes for points

1. Put every table on one measure

The x axis is a measure. Anything not on it β€” a table keyed on the odometer, a different route β€” has to be translated first.

2. Draw line events as steps or bands

A step plot for a value, a horizontal band for a category. Both make the constant-between-breakpoints nature visible.

3. Draw point events as marks on their own lane

A rug of ticks below the axis, with size or colour carrying an attribute.

4. Show the route's own discontinuities

Shade the equation gaps and prefix changes. They are the reason two events a tenth of a mile apart may not be a tenth of a mile apart.

5. Keep the axis honest

One inch per mile everywhere, no breaks. A strip map's whole claim is that distance along it is distance along the road.

6. Label in the reader's units

Postmiles with the prefix where there is one, and a secondary axis in kilometres or in the odometer if the audience uses it.

Strip map lane types with the geometry each uses. four lane typeslanedraw it asgood foran interval valuea step plotvolumes, speed limits, conditionscoresa categorya coloured bandpavement type, jurisdiction,designationpoint eventsa rug of ticksstructures, crashes, signsthe route itselfsegments with prefixes shadedshowing where the numbering jumpsthe common mistake is a line plot, which invents a value at every postmileEvery lane shares the measure axis; nothing shares a y axis spatialworkflow.io
Four lane types; the mistake is drawing a line event as a line plot.

A three-lane strip with its discontinuities

The data used on this page

Caltrans's state highway network and its event layers β€” traffic counts (Traffic_AADT), bridges (State_Highway_Bridges) and functional classification β€” from the Caltrans ArcGIS feature services. lrs.py packages the download code from the reading guide as a module: query() pages through any layer, routes() returns centrelines with their postmiles, projected by the server to California Albers (EPSG:3310), and point_at_measure() interpolates a position along one line.

%%writefile lrs.py
"""Caltrans linear referencing: the reading guide's fetch and to_frame, as a module."""
import hashlib
import urllib.parse

import geopandas as gpd
import numpy as np
import requests
from shapely.geometry import LineString

SERVICE = "https://caltrans-gis.dot.ca.gov/arcgis/rest/services/CHhighway/{layer}/FeatureServer/0/query"
# Every page this guide requests, as Caltrans served it on 9 October 2026 (the layers are revised as
# counts are updated): FROZEN holds one file per request, named by a SHA-1 of the live URL.
FROZEN = "https://data.spatialworkflow.io/caltrans-2026-10-09/{key}.json"


def query(layer, where, out_sr=4326, want_m=False):
    """Every feature of a Caltrans layer that matches where, following the service's pages."""
    features, offset = [], 0
    while True:
        params = {"where": where, "outFields": "*", "f": "json", "outSR": out_sr,
                  "resultOffset": offset, "resultRecordCount": 2000}
        if want_m:
            params["returnM"] = "true"                   # without this there is no measure
        url = SERVICE.format(layer=layer) + "?" + urllib.parse.urlencode(params)
        key = hashlib.sha1(url.encode()).hexdigest()[:16]   # the live request: requests.get(url)
        page = requests.get(FROZEN.format(key=key), timeout=180).json()
        features += page["features"]
        if not page.get("exceededTransferLimit"):         # the service says when there is more
            return features
        offset += len(page["features"])


def to_frame(features, crs):
    """One row per path, the measures kept as an array per row."""
    rows, geoms, measures = [], [], []
    for f in features:
        for path in f["geometry"]["paths"]:
            a = np.asarray(path, dtype="float64")
            rows.append(f["attributes"])
            geoms.append(LineString(a[:, :2]))
            measures.append(a[:, 2])
    frame = gpd.GeoDataFrame(rows, geometry=geoms, crs=crs)
    frame["m"] = measures
    return frame


def routes(where, out_sr=3310):
    """State highway centrelines with postmiles, projected by the server (California Albers)."""
    frame = to_frame(query("SHN_Lines", where, out_sr=out_sr, want_m=True), out_sr)
    frame["pm_route"] = [f"{r.County}-{r.Route}{r.PMPrefix or ''}{r.PMSuffix or ''}-{r.AlignCode}"
                         for r in frame.itertuples()]         # the full route key
    return frame


def point_at_measure(line, m, target):
    """The position at a measure along one line, or None if the line does not carry it."""
    xy, m = np.asarray(line.coords), np.asarray(m)
    if not m.min() - 1e-9 <= target <= m.max() + 1e-9:
        return None
    distance = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))])
    order = np.argsort(m)                                # np.interp needs increasing x
    return line.interpolate(float(np.interp(target, m[order], distance[order])))
Writing lrs.py

Example 1 β€” a strip with three lanes

bridges = pd.DataFrame([f["attributes"] for f in
                        query("State_Highway_Bridges", "CO='SLO' AND RTE='00101'")])
bridges["PM"] = pd.to_numeric(bridges.PM, errors="coerce")
bridges["LENG"] = pd.to_numeric(bridges.LENG, errors="coerce")
bridges = bridges.dropna(subset=["PM"])

fig, axes = plt.subplots(3, 1, figsize=(12, 5.4), sharex=True,
                         gridspec_kw={"height_ratios": [3, 1, 1]})

axes[0].step(st.PM, st.AHEAD_AADT, where="post", color="#0369a1", linewidth=1.8)
axes[0].fill_between(st.PM, st.AHEAD_AADT, step="post", alpha=0.15, color="#0ea5e9")
axes[0].set_ylabel("AADT")

axes[1].vlines(bridges.PM, 0, bridges.LENG.fillna(10), color="#0f9d8f", linewidth=1.4)
axes[1].set_ylabel("deck ft")

shn["prefix"] = shn.PMPrefix.fillna("")          # unprefixed is "", not null
for seg in shn.itertuples():
    axes[2].barh(0, seg.ePM - seg.bPM, left=seg.bPM, height=0.6,
                 color="#fde68a" if seg.prefix else "#e8f4fd",
                 edgecolor="#64748b", linewidth=0.6)
axes[2].set_yticks([])
axes[2].set_ylabel("segments")
axes[2].set_xlabel("postmile")
axes[2].set_xlim(0, shn.ePM.max())
fig.savefig("strip_three_lanes.png", dpi=150, bbox_inches="tight")

print(f"lane 1: {len(st)} volume steps")
print(f"lane 2: {len(bridges)} structures, "
      f"{bridges.LENG.notna().sum()} with a deck length")
print(f"lane 3: {len(shn)} route segments, "
      f"{(shn.prefix != '').sum()} with a postmile prefix")
print(f"wrote strip_three_lanes.png, "
      f"{os.path.getsize('strip_three_lanes.png') / 1024:.0f} KB")
lane 1: 48 volume steps
lane 2: 93 structures, 93 with a deck length
lane 3: 9 route segments, 3 with a postmile prefix
wrote strip_three_lanes.png, 31 KB

Example 2 β€” marking the discontinuities

gaps = []
ordered = shn.sort_values("bPM")
reach = None
for seg in ordered.itertuples():
    if reach is not None and seg.bPM > reach + 1e-9:
        gaps.append((reach, seg.bPM))
    reach = max(reach or 0.0, seg.ePM)

fig, ax = plt.subplots(figsize=(12, 3.2))
ax.step(st.PM, st.AHEAD_AADT, where="post", color="#0369a1", linewidth=1.8)
for a, b in gaps:
    ax.axvspan(a, b, color="#ef4444", alpha=0.35, linewidth=0)
for seg in shn[shn.prefix != ""].itertuples():
    ax.axvspan(seg.bPM, seg.ePM, color="#fde68a", alpha=0.45, linewidth=0)
ax.set_xlim(0, shn.ePM.max())
ax.set_xlabel("postmile (amber: prefixed alignment, red: numbering gap)")
ax.set_ylabel("AADT")
fig.savefig("strip_with_gaps.png", dpi=150, bbox_inches="tight")

print(f"{len(gaps)} numbering gaps drawn, "
      f"{sum(b - a for a, b in gaps):.3f} miles in total")
pre = shn[shn.prefix != ""]
print(f"{len(pre)} prefixed stretches, {(pre.ePM - pre.bPM).sum():.3f} miles")
print(f"wrote strip_with_gaps.png, {os.path.getsize('strip_with_gaps.png') / 1024:.0f} KB")
6 numbering gaps drawn, 0.167 miles in total
3 prefixed stretches, 9.726 miles
wrote strip_with_gaps.png, 29 KB

The gaps are a tenth of a mile wide on a 69-mile axis, so they are almost invisible β€” which is the correct impression. Drawn at their true width they say "this is a bookkeeping detail"; drawn as a break in the axis they would say "something big happens here", and nothing does.

Example 3 β€” the same route as a map, for comparison

import geopandas as gpd

fig, (ax_map, ax_strip) = plt.subplots(2, 1, figsize=(12, 7),
                                       gridspec_kw={"height_ratios": [4, 1]})
gpd.GeoSeries(shn.geometry.values, crs=shn.crs).plot(ax=ax_map, color="#1a3a6b",
                                                     linewidth=1.4)
ax_map.set_aspect("equal")
ax_map.set_axis_off()
ax_strip.step(st.PM, st.AHEAD_AADT, where="post", color="#0369a1", linewidth=1.6)
ax_strip.set_xlim(0, shn.ePM.max())
ax_strip.set_xlabel("postmile")
fig.savefig("strip_vs_map.png", dpi=150, bbox_inches="tight")

bounds = shn.total_bounds
print(f"map extent {((bounds[2] - bounds[0]) / 1000):.0f} x "
      f"{((bounds[3] - bounds[1]) / 1000):.0f} km for "
      f"{shn.geometry.length.sum() / 1609.344:.0f} miles of road")
print(f"strip axis: {shn.ePM.max():.1f} postmiles across the same figure width")
print(f"wrote strip_vs_map.png, {os.path.getsize('strip_vs_map.png') / 1024:.0f} KB")
map extent 27 x 90 km for 69 miles of road
strip axis: 69.3 postmiles across the same figure width
wrote strip_vs_map.png, 31 KB

The map spends a 27-by-90-kilometre box on a road that is 111 kilometres long; the strip spends the whole width on the road and none on the county around it. Which is right depends on whether the reader needs to know where the road is or what is on it.

A strip map is not a distance chart

Why a strip map is not a chart of distance

A chart of a variable against distance is a strip map with one lane. What makes it a map is the combination: several tables sharing an axis, so a reader can see that the busiest mile is also the one with ten structures and a prefixed alignment. The alignment between lanes is the information.

Why steps and bands, not lines

A line plot interpolates. Between two count stations the volume is a constant that the publisher measured at the upstream station; drawing a slope invents a gradual change and puts wrong values at every point in between. Steps are the honest rendering of an interval-valued quantity, which is what almost every linear event is.

Why the discontinuities belong on the picture

A strip map's x axis claims to be the road. Where the numbering skips, it is not, and a reader measuring distances off the axis will be wrong by the size of the gap. Shading them keeps the claim honest at the cost of two lines of code.

The same interval-valued event data drawn as a line and as steps. steps, not a lineline plotβœ•slopes between count stationsβœ•invents a value at every postmileβœ•implies a gradual change nobody measuredβœ•reads as a trend, which it is notstep plotβœ“constant between breakpointsβœ“jumps exactly where the data changesβœ“the flat runs are the eventsβœ“what an interval-valued quantity lookslikeAlmost every linear event is interval-valued, so almost every strip lane is a step spatialworkflow.io
The same volumes drawn two ways; one of them invents a value at every postmile.

Long routes, both directions and print

  • Very long routes need panelling β€” twenty miles per row, stacked β€” rather than one unreadable strip.
  • Both directions go above and below the axis, which is a strip map's natural advantage over a map.
  • Categorical lanes work best as coloured bands with a legend, not as a numeric axis.
  • Interactive versions let a reader brush a measure range; the same data structure drives both.
  • Print at one inch per mile or per kilometre, and say so, so distances can be measured off it.
  • Rail and pipeline schematics are the same idea with a century's head start on conventions.

FAQ

What is a strip map?

A diagram that straightens a route into an axis measured in its own measures, with event tables stacked along it.

When is it better than a map?

Whenever the question is "what is along this route" rather than "where does this route go".

Should I use a line or a step plot?

Steps, for anything that is constant between breakpoints β€” which is most linear event data.

How do I show both directions?

Put one above the axis and one below; the strip has room a map does not.

Do I need to show the numbering gaps?

Yes, if readers will measure distances off the axis. Shade them at their true width.

How long a route can one strip take?

About twenty miles at a readable scale; beyond that, panel it into rows.