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 Constraints

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.

Framework
Astro static — no server, no SSR
JavaScript
Vanilla only — no React, Vue, or Svelte
Libraries
CDN only — no npm install on experiment pages
APIs
No paid keys — all tile providers free-tier
GPX data
Client-side parse — fetch + DOMParser
Astro quirk
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.

Choosing the Map Library

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.

Tile Providers

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.

D3 for Data Visualization

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.

The GPX Data Pipeline

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.

01
Parse

fetch(url)DOMParserquerySelectorAll('trkpt'). Each point: {lat, lon, ele, time}. No library needed for this step.

02
Smooth elevation

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.

03
Stride sample

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.

04
LTTB downsample

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.

05
Build distance series

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.

06
Zero-baseline (ridge only)

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 — Two Bugs Worth Noting

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.

What Was Built

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.