About the Bike Map of Madison
An interactive map of cycling infrastructure in Dane County, Wisconsin, derived from OpenStreetMap and refreshed monthly. This page documents how the network is extracted and classified, for people who already know the OSM tagging schema.
Overview
The page is a static site (Mapbox GL JS, hosted on GitHub Pages). The bike network is a Mapbox vector tileset built from an OSM extract; a scheduled GitHub Action re-pulls the data, re-classifies it, and re-publishes the tileset. Nothing is hand-drawn: if a segment looks wrong, the fix belongs in OSM. The pinned segment card has "View on OSM" and "Edit" links for the underlying way, and corrections show up after the next refresh (plus tile cache expiry).
- Scope: everything Overpass places inside the Dane County boundary relation (relation 1799444).
- Unit of data: one feature per OSM way, with its geometry unmodified. Ways are not merged, split, or clipped.
- Classification is derived from tags by the rules below, but the raw tags we use are carried through to every feature so you can verify any call from the hover card.
Extraction query
Overpass QL, run against the area derived from the county relation
(3600000000 + 1799444). It selects candidate ways with any tag that could indicate bicycle
infrastructure, and the classifier then decides what each one is.
[out:json][timeout:300];
area(3601799444)->.a;
(
way["highway"="cycleway"](area.a);
way["highway"~"^(path|footway|pedestrian|bridleway|track)$"]["bicycle"="designated"](area.a);
way["highway"]["cycleway"~"^(lane|track|shared_lane|share_busway|shoulder|opposite|opposite_lane|opposite_track|opposite_share_busway)$"](area.a);
way["highway"]["cycleway:both"~"…same regex…"](area.a);
way["highway"]["cycleway:left"~"…same regex…"](area.a);
way["highway"]["cycleway:right"~"…same regex…"](area.a);
way["highway"]["bicycle_road"="yes"](area.a);
way["highway"]["cyclestreet"="yes"](area.a);
);
out tags geom;
/* curated route relations, from pipeline/signed_routes.txt */
rel(id:3896886,11242307,…)->.sr;
.sr out body;
way(r.sr)(area.a);
out tags geom;
The first block selects ways by tag. The second adds the member ways of a short, hand-picked list of
route=bicycle relations (see Signed routes). Nodes and all other relations are not used.
Results come from the public Overpass API with fallback endpoints; the job retries and fails rather than
publishing a partial pull (see Refresh pipeline).
Classification
Each candidate way receives at most one class. A way never appears twice, even when
cycleway:left and cycleway:right differ: the best-protected value wins and the raw
per-side tags are kept as properties.
Exclusions
Dropped before classification, regardless of other tags:
| Condition | Reason |
|---|---|
highway ∈ proposed, construction, abandoned, razed, planned, disused | Not usable infrastructure |
bicycle ∈ no, dismount | Bikes not permitted or must dismount |
access ∈ private, no | No public access |
Not excluded: access=customers/permissive/destination and similar, bicycle=permissive,
and surface type. Unpaved paths are included; surface is shown on the card.
Decision procedure
First match wins. Steps are evaluated in this order:
-
highway=cycleway→permitwhenfee=yesoraccess=permit; otherwisepathif the way'snameis on the curated trail list; otherwisecycleway.
facility(display label):foot ∈ {yes, designated, permissive}andsegregated=yes→ "Segregated shared-use path";footin that set otherwise → "Shared-use path"; else "Bike path". Anycycleway*tags on a cycleway are ignored. -
highway ∈ {path, footway, pedestrian, bridleway, track}withbicycle=designated→pathif thenameis on the curated list, otherwisepathway(facility "Designated path"). No fee/permit distinction is made here. -
Road-side facilities. Read
cycleway,cycleway:both,cycleway:left,cycleway:right, map each value through the table below, and take the lowest-rank(best protected) class found. -
Signed ways with no other facility. If the way is signed (see below) and steps 1–3
found nothing, it becomes
route(facility "Signed route"). A signed way that does have a facility keeps that class: acyclestreetwithcycleway=laneis alanethat is also signed. - Otherwise the way is not part of the network.
Signed routes
"Signed or Well-Known Route" is an overlay on top of the classes, not a class you have to be limited to. A way is signed when either of these is true:
- it carries
bicycle_road=yesorcyclestreet=yes; or - it is a member way of a relation listed in
pipeline/signed_routes.txt(the Lake Loop and the city's bicycle boulevards at the moment). Relations taggedstate=proposedare skipped.
Signed ways get signed=1 and, for relation members, routes with the relation names. A signed
way keeps its normal class (shared lane, trail, and so on) and the map adds a glow behind it. Only a signed way
with no other bike facility gets class route, drawn as a dotted line. Local trail and greenway relations
(Fitchburg, Middleton, the state trails) are intentionally not in the list: those paths are already Bike Trail or
Off-Street Bike Lane.
Curated trail list
"Bike Trail or Path" is deliberately narrow: it is for well-known, named trails (the Southwest Path,
Capital City State Trail, Cannonball Path, and so on), not for every highway=cycleway. Membership is
decided by a hand-maintained allowlist,
pipeline/trail_names.txt.
- A way is a
pathwhen it qualifies in step 1 or 2 and itsnametag equals a list entry (exact match, case-insensitive, whitespace-normalised). Onlynameis consulted;alt_name,official_name, and route relations are not. - Every other off-street way falls back to
cycleway(ahighway=cycleway) orpathway(a designatedpath/footway/…), which the map draws as "Off-Street Bike Lane". - Permit-required cycleways are
permitregardless of the list. - The file ships with the included names plus a commented-out candidate list of every other named off-street way found when it was written, with lengths. Add a name, or uncomment one, and run the refresh workflow to apply it. The list is static: new names that appear in OSM won't be picked up on their own.
cycleway=* value mapping
Applies to step 3, for each of the four keys above.
| Tag value | Class | Notes |
|---|---|---|
track, opposite_track | track | Physically separated track, tagged on the road way |
lane, opposite_lane, opposite | lane | opposite is treated as an opposite-direction lane |
shoulder | shoulder | Paved shoulder usable by bikes |
shared_lane, share_busway, opposite_share_busway | shared | Sharrows and shared bus lanes |
separate | ignored | The path is its own way; it is captured through step 1 or 2 |
no, none, anything else | ignored | Including legacy shared, sidewalk, crossing |
Only the main cycleway* values are read. Subtags such as cycleway:left:lane=exclusive,
:separation, :buffer, :segregated, and :width are not used for
classification.
Classes and rank
rank orders classes by protection (lower is better). It decides which feature the hover card
shows when lines overlap, and the label priority for route names.
| class | rank | Source tags (summary) |
|---|---|---|
path | 1 | Off-street way (step 1 or 2) whose name is on the curated trail list |
permit | 1 | highway=cycleway + (fee=yes or access=permit) |
cycleway | 2 | Any other highway=cycleway |
track | 2 | cycleway[:side]=track | opposite_track |
pathway | 2 | highway=path|footway|pedestrian|bridleway|track + bicycle=designated, not on the curated list |
route | 7 | Signed way with none of the facilities above |
lane | 4 | cycleway[:side]=lane | opposite_lane | opposite |
shoulder | 5 | cycleway[:side]=shoulder |
shared | 6 | cycleway[:side]=shared_lane | share_busway | opposite_share_busway |
Output data model
The tileset has one source layer, bike (LineString features, zoom 8–14), with one feature per OSM way. Route-name labels come from a separate file, described below. Feature properties on bike
are the derived fields plus a pass-through of raw OSM tag values. Keys containing a colon are written with an
underscore (cycleway:left → cycleway_left). Tag values are not normalised.
| Property | Type | Description |
|---|---|---|
osm_id | integer | OSM way id. Used as the feature id (promoteId) for hover state. |
class | string | One of the nine classes above. |
rank | integer | Protection rank of class. |
facility | string, optional | Human label for off-street ways (steps 1–2 only). |
| Pass-through OSM tags, present only when the way has them | ||
name, ref, operator | string | Used for card titles and route-name labels. |
highway | string | The way's own highway value. |
cycleway, cycleway_both, cycleway_left, cycleway_right | string | Raw side tags, kept so asymmetric streets can be read in full. |
signed | integer, optional | 1 when the way is signed (see above). |
routes | string, optional | Names of the listed route relations the way belongs to, separated by "; ". |
bicycle_road, cyclestreet | string | Raw values. |
bicycle, foot, segregated, access, fee | string | Access and use tags. |
surface, smoothness, width, incline | string | Physical attributes. |
lit, oneway, maxspeed, bridge, tunnel | string | Raw values (for example maxspeed is whatever the mapper entered). |
Label lines: data/bike-names.geojson
Used only for the route-name text along the lines. It is a small GeoJSON file the page loads directly, not part of the tileset (line labels placed unreliably on the vector tiles at z12–13, and the GeoJSON path doesn't have that problem). OSM often splits one trail into dozens of ways, and a line label needs a long enough continuous line, so the pipeline builds a second set of features:
- Features of the labeled classes (
path, permit, cycleway, track, pathway) that have anameare grouped bynamealone, so a trail that switches between, say,pathandpermitstays one line. - Within a name, ways are joined end to end wherever exactly two ways share an endpoint. At junctions (three or more) the ways stay separate.
- Remaining pieces are then joined across small gaps (road crossings, short unnamed connectors): two line ends are connected when each is the other's nearest end and they are within 60 m. The label line can therefore jump a gap that the real line doesn't have; it only affects where text can sit.
- Each merged line takes the
classthat covers most of its length. Properties:name,class,rank. There is noosm_id; this layer is not used for hover or click.
A data/manifest.json file is committed on each run with the OSM data timestamp
(osm3s.timestamp_osm_base), per-class way counts and lengths, and a content hash.
Map categories and display rules
The map legend follows a print map's symbology, which groups the classes above:
| Legend category | Classes | Symbol | Drawn from | Route names |
|---|---|---|---|---|
| Bike Trail or Path | path (curated names) | Blue edges, light fill | z9 | Yes |
| Bike Trail (permit required) | permit | Blue edges, gold fill | z9 | Yes |
| Off-Street Bike Lane | cycleway, pathway, track | Solid blue | z9 | Yes |
| Dedicated Bike Lane | lane | Dashed blue | z11 | No |
| Shared Bike Lane | shared, shoulder | Dashed light blue | z11 | No |
| Signed or Well-Known Route | route, plus the glow on any signed way | Pale-blue glow and white line under any signed segment; dots on top when there is no other facility | z11 | No |
- Below z10.5 only ways with a
nametag are drawn; unnamed ways fade in from z10.5, and on-street categories from z11 (each fade spans 0.7 zoom levels). - Route names are a separate symbol layer (from
data/bike-names.geojson) along the line from z12, repeated every 250 px with bends up to 45°. On-street lane and shared categories are not labeled because theirnameis the street name, which the basemap already labels. - County lines (dashed gray-blue, from z8) come from the Mapbox Streets
adminlayer (admin_level2). They carry no county names, so every county border in view is drawn, not just Dane's. Panning is limited to the county plus a margin of about 5 km, so the minimum zoom depends on the window size (about z10 on a tall window). - The legend checkboxes filter by
class, so toggling a category also hides its labels. - The mapping from class to legend category is a few lines of configuration in
js/app.js(CATEGORIES); the tileset itself keeps all eight classes, so re-grouping needs no data refresh.
Refresh pipeline
- A GitHub Action runs monthly (the 1st, 09:17 UTC) and can be started manually.
fetch_osm.pyruns the query above, trying up to three Overpass endpoints with retries, then classifies every way. If fewer than 500 features come out, the run aborts rather than publishing a suspect pull.- Features are written as line-delimited GeoJSON, sorted by
osm_id, with coordinates rounded to 7 decimals. A SHA-256 of that file is compared with the previous run. The merged label lines are written separately. - If the content changed (or the run is forced), the file replaces the tileset source and the Mapbox Tiling
Service recipe is updated and published (source layer
bike, zoom 8–14). Unchanged data is not re-published. - The manifest is committed back to the repository every run.
Known limitations
- "Well-known" is a hand-picked list. Trail membership depends on exact OSM
namevalues, so a renamed or misspelled way, or a trail mapped without a name, falls back to Off-Street Bike Lane. About 150 mi ofhighway=cyclewayin the 2026-10-21 extract had nonametag at all. - Only listed route relations count. Of the roughly 110
route=bicyclerelations in the county, only the few inpipeline/signed_routes.txtare read, and their member ways are used as mapped. A route relation that is incomplete or out of date in OSM shows up that way here. - Separate sidepaths look like trails. A cycle track mapped as its own
highway=cyclewaybeside a road is classifiedpath; thetrackclass only captures tracks tagged on the road way itself. No attempt is made to detectcycleway=sidepath,footway=sidewalk, or adjacency to a road. - Subtags are ignored. Lane buffers, physical separation, and segregation subtags do not change classification.
highway=pathneedsbicycle=designated. Paths withbicycle=yesor no bicycle tag are not included, and roads withbicycle=designatedbut no cycleway tags are not included.- One class per way. Streets with different treatment on each side show the best side; the raw left/right tags are in the card.
- Geometry is not clipped to the county line, and ways are not merged, so long facilities appear as many features.
- Overpass lag. Data is as current as the public Overpass instance, typically minutes behind OSM, then up to a month behind the page.
Attribution
Bike network data © OpenStreetMap contributors, available under the Open Database License. Basemap © Mapbox.
Map and data pipeline by Stephen Kennedy of LATLONG.SHOP.