Eight GPX tracks go in; eight day pages come out, each with an elevation profile, a map, live conditions and a list of places to stop. Everything below is either free, keyless, or running on a public token. This page exists so that anyone wondering where a number came from can go and check it.
The shape of it
It is a static site with a hand-rolled generator: two Python scripts, some Jinja
templates, and one dependency. build_data.py reads the GPX tracks
and writes JSON; generate_pages.py renders that JSON through the
templates into plain HTML. There is no framework, no bundler, and no
package.json. The published site is HTML, CSS, about 640 lines of
vanilla JavaScript, and a single PHP file.
That last file is the only server-side code, and it exists for one specific reason, explained below.
Routes
Most days come from GPX tracks drawn by hand in Ride with GPS or exported from Garmin Connect. Days 3–5 were re-routed in August 2026 when a fire closed the Big Sur coast; those replacements were generated with BRouter, an open routing engine that runs on OpenStreetMap data and lets you pick a cycling profile rather than accepting whatever a car router thinks a bike wants. The original coast tracks are kept rather than deleted, in case the road reopens.
The profile choice matters more than it sounds. Routing Day 5 with a general touring profile produced a line with an unpaved section of Santa Rita Road; a road-cycling profile avoided it and found Highway 41 instead, for a third of a mile more and 280 ft less climbing.
Distance, elevation and climbs
All computed from the GPX in pure Python, no library:
- The map line is simplified with Ramer–Douglas–Peucker at a 4 m tolerance — Day 1 goes from 8,324 track points to 672 without visibly changing shape at map scale.
- Elevation is resampled every 100 m and smoothed over a five-sample window before gain and loss are totalled. Raw GPX elevation is noisy enough that summing it directly inflates the climbing figures.
- Climbs are found by thresholding gradient, merging stretches separated by a short flat, then discarding anything under 500 m or 20 m of gain.
Every elevation chart is drawn on the same vertical scale, so a flat day looks flat. Charts that auto-fit their own range make every day look equally mountainous, which is worse than useless for comparing them.
Maps
Leaflet with raster tiles from Mapbox. Three basemaps, because the two things this route is judged on are climbing and shade: Elevation (contours and hillshade, the default), Vegetation (satellite, where tree cover is directly visible), and Plain for reading road names.
Clicking any point hands that coordinate to Google Maps, Apple Maps or Street View. Neither Google nor Apple lets a third-party site embed an interactive map without a paid, billed key, so handing the coordinate off is the honest substitute.
Stops, water and towns
Everything in the Stops along the way lists comes from OpenStreetMap, queried through the Overpass API and written into the GPX files as waypoints so they also appear on a head unit.
Two things had to be handled carefully. A Garmin course has a capped course-point budget shared between turn cues and waypoints, and going over truncates it silently — losing turn prompts, which matter more than a café. So POIs are thinned per category, with priorities: a supermarket survives over the fast-food place next door, and water is never thinned.
And amenity=toilets says a toilet exists, not that a passing cyclist
may use it. Auditing the route turned up private units, permit-only beach
facilities, and six separate toilet nodes inside a college campus. Restricted
access and school, church or club operators are now filtered out;
customers-only stops are kept and labelled, because buying a coffee is a fair
trade and a café is in practice the most reliable restroom on any of these
roads.
Where OpenStreetMap simply has nothing — Los Olivos has well-known places to eat and none of them are tagged as anything the query matches — a short hand-maintained list fills the gap. Those carry a star on the day page and say so in the waypoint comment on the device, because an OSM-derived point may be stale or mistagged while a hand-picked one was chosen deliberately. The star is about where the stop came from, not about the food: nothing here has been checked on the ground.
Hand-picked stops also skip the offset screen every scraped stop passes, so one can sit further off the line than anything around it. Those print the detour under the name rather than sitting in the list looking like somewhere you ride past.
That screen is a quarter mile on most days and 1.5 mi on Days 3 and 4. A quarter mile is right where there is something every few miles — a stop further off than that is noise nobody would ride to. It is wrong where there is nothing, because it then reports an empty route as an empty valley: Day 3 runs the west side of the Salinas Valley with every town off to the east, and at a quarter mile the page showed four stops in sixty-five miles while a corridor search found shops every few miles. Widening it took the day from 10 stops to 28. It stops at 1.5 mi — a three-mile round trip — on purpose: 3 mi would close the gap on paper, but only by assuming somebody rides five and a half miles for a filling station.
Anything admitted past a quarter mile also has to be reachable without crossing US‑101, tested against the freeway's own geometry rather than guessed at from which side of the map it falls on. That is what keeps Soledad's shops off Day 3 while keeping the Mission, which sits west of the freeway even though the town does not. The test is scoped to the far stops deliberately: Day 7 rides the 101 shoulder through Gaviota, and applying it there would discard legitimate roadside stops as unreachable.
Tasting rooms and bars are listed only on Days 5 and 6, the two days built around them. Elsewhere they crowded out the water and toilets somebody is actually looking for, and on a hot inland day they are the wrong thing to steer a rider toward.
One caution about all of this, learned the hard way. The Overpass query used to
keep its own list of shop and amenity tags, separate from the table that says what
each tag means. The two drifted: shop=general was in the table but
never fetched, so Lockwood Store and Diner — 0.03 mi off the road at
mi 25 of Day 4, the only resupply in the middle of the emptiest day on
the trip — simply never appeared, and the day's notes concluded there was
nothing there. The query is now generated from the table, so the two cannot
disagree again.
Lodging
The hotel for each night is written down once per town, since it ends one day and starts the next. Its coordinate is measured against the last point of that day's track: most days were drawn to the door, and the few that stop short say how far short, because the last half mile of a sixty-mile day is the one worth knowing about in advance.
Shade
The most involved calculation, and the one that settled an actual decision. Two mechanisms, measured separately:
- Canopy — mapped woodland within 30 m of the road. Proximity, not containment: mappers draw woodland up to the road edge and stop, so a point on the centreline is essentially never inside a wood. Testing containment scored every day zero while polygons sat twenty feet away.
- Terrain — the honest form of "aspect". A slope facing west only helps if it is steep enough and close enough to actually block the sun, which a bare aspect number never tells you. So the ground is sampled along the sun's compass bearing at 100 m to 3.2 km, and the largest angle it subtends is compared against the sun's own elevation.
Sun position comes from the NOAA solar algorithm, implemented directly; ground elevation from Open-Meteo's elevation API, which is free and needs no key.
At 3 pm in early September the sun is still about 49° up, so terrain shade comes out at zero almost everywhere — a real result, not a broken model. Sweeping the hour confirms it behaves: hold the route still and drop the sun, and terrain shade on Day 3 climbs steadily through the evening. During riding hours on these routes, though, tree cover is the only shade on offer.
Why the day pages no longer print a shade figure
They used to, and the numbers did not survive being checked. Three faults, all pushing the same way:
-
The canopy query counted
natural=scrub— chaparral a metre or two tall — alongside wood and forest, then labelled the total "tree cover". On Day 3 that is the whole figure: of five shaded sample points, four were scrub and one was woodland. The page said 8%; actual trees are 2%. - Canopy was tested in every direction, unlike terrain, which correctly probes along the sun's bearing. With the sun in the south-west, woodland on the north-east side of the road throws its shade away from the rider and still counted. Thirty metres is too generous besides: at a 49° sun a tree shades about 0.87 of its own height, so reaching the road from 30 m away takes a 34 m tree.
- Each percentage rested on one sample per mile — 39 to 72 binary observations per day, where a single sample is worth 1.4 to 2.6 points. Day 4's "4%" was two data points. That is not a figure that supports two significant digits.
Under-mapping cuts the other way — OSM woodland is thin inland, so real shade
can read as none — but the errors do not cancel, and the model is least
trustworthy exactly where it matters most: the hot inland days. What survives is
the warning on a near-shadeless day, which no error found here would overturn.
A bare day is bare under every correction. build_shade.py is still in
the repo, and would earn its place again for an October trip or a dawn start,
when a lower sun makes terrain shade real.
Live conditions
Two feeds, fetched differently for one reason: whether a key is involved.
Weather comes straight from the National Weather Service API. No key, CORS open, so the browser can call it directly. It takes three hops: the grid point for the coordinate, that grid's forecast, then the nearest station's latest observation — the forecast payload carries no humidity.
Air quality comes from AirNow, and cannot be fetched the same way. Unlike a Mapbox public token, an AirNow key cannot be restricted to a domain, and its rate limit is enforced per key. Publish it and anyone can drain the hourly quota, after which AirNow returns nothing until the next hour — the panel would go blank, plausibly right when someone on the road is checking for smoke. So the key stays on the server behind a small PHP proxy, which caches for ten minutes, refuses coordinates outside this route so it cannot be used as a free general-purpose proxy, and serves a stale-but-labelled reading rather than nothing when the upstream is rate-limiting.
Every air quality reading names the reporting station and its distance. That is deliberate: the network is sparse inland — Monterey has no station within 75 miles — and smoke varies sharply over short distances. A bare number would imply a precision the reading does not have.
What this does not tell you
OpenStreetMap is volunteer-mapped, and thin in rural California. A day showing no water means nobody has mapped any, not that there is none; Day 4 comes back with zero mapped water across 62 miles, which is a reason to load the support car rather than a survey result. The same caveat applies to canopy: comparing two routes in the same region is far more trustworthy than any single absolute figure.
The route notes mix measured figures with rider knowledge. Distances, gradients and percentages are all traceable to the data above. Judgements about traffic and road surface are not — OpenStreetMap has no shoulder, speed limit or width tag on most of these roads — so those come from people who have ridden them, and are marked as unchecked where nobody has.
Source
All of it is on GitHub: scottgruber/pacific-coast-2026. The interesting parts, if you want to check the working:
-
scripts/build_data.py— GPX parsing, Ramer–Douglas–Peucker simplification, elevation resampling, climb detection -
scripts/build_shade.py— NOAA solar position and the horizon-angle terrain test -
scripts/add_services.py— Overpass queries, the access filtering, and per-category thinning -
scripts/prepare_gpx.py— readying the GPX for Ride with GPS and a Garmin head unit -
api/airnow.php— the air quality proxy, and why it has to exist -
js/basemap.js— Leaflet, the Mapbox tile layers and the map-app handoff -
scripts/generate_pages.py— templates to HTML, the elevation SVGs, and asset cache busting
The two algorithms worth reading up on rather than taking on trust: Ramer–Douglas–Peucker for line simplification, and solar position for the shade model — the implementation here follows NOAA’s solar calculator equations.
Credits
- OpenStreetMap contributors — base map data, stops, woodland, towns
- Mapbox — basemap tiles
- Leaflet — map rendering
- BRouter — cycling routes for the fire detour
- Overpass API — OpenStreetMap queries
- Open-Meteo — elevation data for the shade model
- National Weather Service — forecasts and observations
- AirNow — air quality
- Adventure Cycling Association — the Pacific Coast reference route on the compare page