Nine days, 3,753 miles, one Garmin device recording every second. What came out were nine GPX files totaling about 58,000 GPS points. This is a record of the decisions made to turn those into four working visualizations — a full trip overview, linked elevation profiles, a speed heatmap, and a ridge plot — on a static site with no server and no paid APIs.
Every constraint in this project pushed toward the same thing: libraries that load from a CDN, render client-side, and don't require a build step. That constraint turned out to be a good design constraint.
The site is Astro with zero server-side logic — every page is a static HTML file. That rules out server-rendered tile proxies, API gateways, and any data processing at build time. Everything has to work in the browser, loaded cold, from public endpoints.
is:inline required on all CDN script tags
The Astro/Vite bundler hoists and processes <script> tags as ES modules, running them before CDN scripts finish loading. Adding is:inline tells Astro to leave the script alone and preserve document order. Without it, every Leaflet page produces L is not defined at runtime.
The decision was Leaflet 1.9.4. It wasn't a close call.
| Library | CDN size | Verdict | Reason |
|---|---|---|---|
| Leaflet 1.9.4 | 140 KB | Used | Best GPX ecosystem, small, BSD licensed, tiles work out of the box |
| MapLibre GL | ~900 KB | Skipped | WebGL-based, much heavier — appropriate for vector tiles, overkill here |
| OpenLayers | ~500 KB | Skipped | Capable but verbose API; no clear advantage for raster tile + GPX use |
Leaflet's plugin ecosystem is the main reason. leaflet-gpx parses GPX files and fires a loaded event with computed bounds, distance, and elevation stats — no custom parsing needed for the basic route map. For the more complex visualizations, GPX was parsed manually with fetch + DOMParser to get raw point access.
Eight providers were evaluated side by side on the same Day 1 track (Philadelphia to Charlotte, 536 miles). The test was legibility at zoom 5–7 — the scale that shows a full day's drive.
| Provider | Style | Notes |
|---|---|---|
| Carto Voyager | Colorful, detailed | Best for the full trip overview — road hierarchy clear, cities labeled, not overloaded |
| Carto Dark Matter | Dark, bold | Good for speed heatmap — dark base lets colored segments read clearly; loses road detail at zoom 5–6 |
| Carto Positron | Minimal, light | Clean but low contrast on the colored elevation tracks |
| OpenTopoMap | Terrain + contour | Beautiful for mountain days but too busy across the flat South |
| Stadia Watercolor | Painted texture | Visually striking but slow tile server; Stadia no longer rotates subdomains — omit {s} |
| Stadia Toner | High-contrast B&W | Punchy, works for data overlays; same subdomain note |
| ESRI NatGeo | Atlas reference | Matches the site's aesthetic but ESRI doesn't support subdomain rotation |
| ESRI Satellite | Aerial imagery | Too literal for a narrative map |
Final choices: Carto Voyager for the full trip overview and linked elevation view. Carto Dark Matter for the speed painting experiment. The ridge plot and elevation profiles have no map at all — the geographic context of the tile layer isn't needed when the data is already telling the story.
When the goal moved from "show the route" to "show the elevation" and "show the ridge," Leaflet stopped being the right tool. D3 v7 UMD was chosen for all SVG-based visualization.
| Library | CDN size | Verdict | Reason |
|---|---|---|---|
| D3 v7 (UMD) | 580 KB | Used | Full control over SVG paths, scales, and layouts. UMD build sets window.d3 — works with is:inline |
| Observable Plot 0.6 | 280 KB | Skipped | Good declarative API for standard charts; not flexible enough for the ridge plot's custom rendering order |
| Chart.js | 200 KB | Skipped | Canvas-based, good for standard charts, no direct SVG path control |
| Plotly.js | 3.5 MB | Skipped | Too large for CDN use on a static site |
| Three.js r170 | 650 KB | Future | 3D ribbon visualization is possible but complexity is high — build last |
D3's area generator and scale system made the ridge plot tractable. The key operations — d3.area() with y0/y1, d3.scaleLinear(), d3.bisectLeft() for hover — are well-matched to what the visualizations actually need.
Raw Garmin GPX exports contain ~58,000 points across 9 days — about 6,400 per day average, with Day 4 and Day 8 running to 14,000+. Rendering 6,400 SVG path segments per elevation profile is too slow; rendering them all at once for a ridge plot or animation is unusable. The pipeline reduces this without losing the visual signal.
fetch(url) → DOMParser → querySelectorAll('trkpt'). Each point: {lat, lon, ele, time}. No library needed for this step.
5–6 point symmetric rolling average on raw elevation values. Garmin records via GPS receiver, not barometric — urban segments are noisy. Smoothing before downsampling ensures LTTB works from clean data. Days 1–2 had GPS drift producing negative elevations on flat coastal roads; smoothing tames these without losing the genuine signal.
For the basic charts, every 6th–8th point is taken. Fast, sufficient for profile shape. For the ridge plot this produces ~400–600 points per day.
Largest Triangle Three Buckets — for the linked elevation profiles where accuracy matters. Divides the data into N equal buckets; in each bucket selects the point that maximizes the area of the triangle formed with the previously selected point and the average of the next bucket. Peaks, valleys, and inflection points are always preserved. Reduces 14,000 points to 480 with minimal visible distortion. ~40 lines of pure JS, no CDN needed.
Haversine formula accumulates distance between sampled points. Output: {dist, ele, lat, lon} per point. The dist value is cumulative miles from the start of the day — this is the x-axis for every elevation chart.
Subtract each day's starting elevation so every ridge begins at 0. This makes the shape (the change) readable rather than the absolute altitude. Day 1 starts near sea level; Day 6 starts at 3,800 ft in Terlingua. A global scale is meaningless without this step — every day would just be a flat line at its absolute elevation.
Known data anomalies: Days 1 and 4 contain GPS glitch points near −47m — multipath errors near highway overpasses. The Salton Sea (Day 9) has 115 genuine below-sea-level points; that's real, not noise. The speed heatmap caps at 90 mph to filter the remaining glitches.
The ridge plot (Joy Division / Unknown Pleasures style) had two non-obvious implementation bugs that are worth recording.
Bug 1: background fill covering everything. Each row needs a background-color fill from its ridge line down to its baseline, so that taller rows behind it don't bleed into its space. The initial version used y0 = SVG_H — filling all the way to the bottom of the SVG. Day 1 (rendered last, on top) had a nearly-flat ridge near y=138. Its fill covered everything below y=138, hiding all 8 other days. Fix: y0 = baseline. The fill now only covers this row's own elevation window. Taller rows behind it remain visible where they rise above the current baseline — which is exactly the intended effect.
Bug 2: scale zero-point drift. A single linear scale over [globalMinDrop, globalMaxGain] doesn't anchor 0 at the baseline unless both extremes are equal in magnitude. Day 5 climbs ~4,900 ft above its start; Day 9 drops ~2,630 ft below its start. A single scale maps 0 to ~20px above baseline — flat days appear visibly elevated. Fix: two separate linear scales, both anchored at zero. One for positive elevation (0→PEAK_PX), one for negative (0→−BELOW_PX). Flat days sit exactly at their baselines.
| Page | Libraries | What it shows |
|---|---|---|
| Full Trip | Leaflet + leaflet-gpx | All 9 days on Carto Voyager. Each day its theme color. Hover/click interaction between tracks and sidebar legend. |
| Elevation | Leaflet + D3 v7 | Split view: map left, 9 scrollable D3 profile cards right. Hovering a profile moves a crosshair marker on the map. Clicking a track highlights the card. |
| Experiments | Leaflet + D3 v7 | Three experiments: speed heatmap (color by mph bucket), SVG elevation profiles (pre-D3 version), animated replay with truck icon and speed modes. |
| The Ridge | D3 v7 only | Stacked elevation ridgelines, Joy Division style. No map — the data is the visualization. Zero-baselined, global y-scale, bottom-to-top render order. |