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.
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.
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.
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.
Internal links
- How to overlay linear event tables in Python β getting the lanes onto one measure
- Dynamic segmentation explained β what the bands are
- How to aggregate events to route segments β the lane data
- Postmile and milepost systems explained β what the shaded gaps mean
- How to build a print-ready map layout in Matplotlib β scale, labels and output
- Choropleth classification explained β classing the categorical lanes
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.