CalTopo + PNG map pipeline¶
A report is not done until the CalTopo map AND the PNG overview are built and shipped with the markdown. Maps and reports always go together.
Every report ships three artifacts as one unit:
1. docs/peaks/<slug>.md (or docs/trips/<slug>.md) — the markdown report
2. CalTopo research map (https://caltopo.com/m/XXXXXXX) — interactive, linked from the report header
3. docs/maps/<slug>.png — overview PNG embedded near the top of the report
Fast path — kick off and walk away (preflight → build → publish)¶
The whole report build is designed around two human touch points: approve the flight-check at kickoff, approve the publish at the end. Everything in between runs without per-step permission prompts (the mechanical work lives inside allowlisted scripts, not raw Bash — no ad-hoc mkdir/cp/rm/heredocs).
0. Preflight (the flight check) — one attended moment.
scripts/preflight.py --peaks "Star, Taylor, Italian" --range Elk --climber kyle
objective_ids.
1. Sweep GPX in-chat (MCP browser), then file + scaffold.
Sweep all 3 sources via the logged-in MCP browser (browser_evaluate, allowlisted), saving the fetched tracks to a JSON blob (the evaluate filename), then:
scripts/ingest_gpx.py --slug <slug> --json <blob.json> # files tracks → gpx/<slug>/
scripts/scaffold_report.py --slug <slug> --objective-ids 301,365,420 \
--landmark "Mt Tilton Trail TH (end of CO 742)|38.9872|-106.7573|10750|trailhead" --no-nearby
2. One command does all the mechanical build.
scripts/build_report.py --slug <slug> --title "Star Peak Group" --climber kyle
caltopo_id / regional_map_id / PNG path / stats to drop into the report. (Re-run with --caltopo-id <ID> to append instead of making a duplicate map.)
3. Write the prose. The LLM fills docs/peaks/<slug>.md from the swept trip reports + the printed values (this is the only non-mechanical step).
4. Finalize + publish.
scripts/build_report.py --slug <slug> --finalize # climber-status + index + HOME PEAK MAP + all QA gates
git add -A && git commit -m "..." # allowlisted
git push # the ONE deliberate gate — review, then publish
Finalize always refreshes the home peak map.
--finalizerunsgen_peak_map.py, which re-reads peak_db + the climb log and rewritesdocs/data/peaks.jsonso the new (or edited) report's objective peaks turn green on the home//peak-map/map. This is not optional — any time a report is added or itsobjective_ids/peak_idschange (e.g. adding a peak to an existing report), run--finalize(orscripts/gen_peak_map.pydirectly) and commit the updateddocs/data/peaks.json, or the map will be stale. A peak only counts as "reported" via a report'sgpx/<slug>/peaks.ymlobjective_ids(or the frontmatterpeak_ids:fallback).
The detailed per-step reference is below; the fast path just chains it.
Only summiting tracks belong on a report's map (Kyle, 2026-06-08). A track goes on the PNG and the CalTopo map only if it actually summits one of the researched (objective) peaks — within ~240 m of an
objective_idssummit. A track that merely passes through the area (an adjacent peak's route, an approach-only fragment, another day of a trip) is not route beta and must be left off. This is enforced automatically now:caltopo_mytracks.pydrops non-summiting tracks at collection, andmake_overview_map.pyre-applies the filter at render. To clean a map built before this rule, usescripts/prune_caltopo_tracks.py --slug <slug>(dry-run) then--apply— it deletes line features that don't top out on an objective (markers/folders untouched). Pass--objective-idsfor older reports whosepeaks.ymlpredates the field.
Pipeline¶
1. Build waypoint GPX¶
scripts/build_peak_gpx.py --slug <slug> reads gpx/<slug>/peaks.yml and writes <slug>_peaks_only.gpx (objective summit(s) sym=peak + optionally nearby unclimbed ranked 13er+ neighbors, with an exclude list for different-drive peaks — the Bartlett rule) and <slug>_landmarks.gpx (trailheads + key drive-in landmarks: gates, closed roads, seasonal closures). Summit/neighbor coords come from peak_db; TH/landmark coords are hand-researched in the config. peaks.yml is the one tracked-in-git file under gpx/ (everything else there is gitignored derived/bulk data).
2. Download GPX tracks from ALL THREE sources¶
Primary path: sweep all three sources in-chat via the MCP browser (it's the login
source of truth and the only thing that clears peakbagger's Cloudflare check — see
CLAUDE.md). Optional headless convenience: scripts/sweep_gpx.py --slug <slug> for
the LoJ + 14ers portion only — it resolves cross-site IDs from peak_db, pulls every GPX,
dedupes by content hash, and writes a tr_manifest.md listing the named trip
reports per source. Do not rely on it (or check_sources_login.py --login) for
peakbagger: Cloudflare permanently blocks the automation profile (it detects the
webdriver/CDP connection regardless of Chrome binary — re-confirmed 2026-06-16), so
peakbagger is always pulled in-chat via the MCP browser.
Not just LoJ. "Always download the tracks from all sites not just LoJ. That's an important part of the research, finding all GPX files out there and pulling them together into one map." (Kyle, 2026-05-29)
- LoJ:
/gpx/<id>.gpxper trip report — pull every TR's GPX, each has route variations - 14ers.com: per-peak GPX library (
gpxlib_locator.php?peakid=<id>) covering TR uploads + member uploads + official routes; plus per-route official GPX - peakbagger: per-ascent GPX (
/climber/GPXFile.aspx?aid=<aid>&sep=1) — confirm logged in first (see source requirements)
Filename convention (keeps colors/groups distinct on upload):
<slug>_<author>_<year>_loj<trId>.gpx
<slug>_<author>_<year>_14ersTR<id>.gpx
<slug>_<author>_<year>_14ersGPXlib<id>.gpx
<slug>_<author>_<year>_pbAscent<aid>.gpx
2b. Cross-reference KYLE'S OWN CalTopo maps (required)¶
Requirement (Kyle, 2026-06-03): "There are missing tracks… you did not cross reference my own caltopo maps. Always look there also when building the report-specific maps." The three web sources are not enough — Kyle's CalTopo account holds his recorded GPS tracks + collected archive with on-the-ground beta the web misses. Those live in the per-range "GPS Tracks — C105AEV map, which Kyle may delete; everything in it also lives in the regionals).
scripts/fetch_caltopo.py --map <REGIONAL_ID> # refresh the range's regional map (auto-picked)
scripts/caltopo_mytracks.py --slug <slug> # add his tracks in the report's bbox
caltopo_mytracks.py resolves the regional map from the peak's range (peak_db), computes the bbox from <slug>_peaks_only.gpx (+ margin), scans that regional dump for LineStrings inside it, and writes the ones not already collected (geometry-deduped against the web sweep; running/biking activity tracks skipped by title) as <slug>_caltopo_<mapid>_<name>.gpx. Upload these to the research map in a distinct color (purple #9933CC) so his own tracks are visible vs. the web sources. Sanity-check the result — a stray far-away track can clip a generous bbox (drop it).
3. Upload to CalTopo¶
scripts/gpx_to_caltopo.py --gpx-dir gpx/<slug> --new-map "Research: <Peak>" --no-dedupe
--no-dedupe is required for research maps so summit/peak markers always render even if they exist in other maps. Track colors (Kyle, 2026-07-23): magenta #E6008C is RESERVED for the recommended climb route; pink #FF69B4 is RESERVED for the inter-trailhead driving route (a "recommendation of sorts" — magenta-adjacent but lighter, so it reads as related-but-not-the-climb; drawn dashed on the PNG, and now uploaded to CalTopo too, no longer PNG-only); blue #0066FF is RESERVED for Kyle's own recordings. Every other source track (recorded + OSM trail) gets a distinct color from a palette of safe hues (greens/oranges/purples/teals/yellows — NO magenta/pink/red/blue), assigned per-track via gpx_to_caltopo.track_color(i), which shifts lightness in tiers past the 8 base hues so >8 tracks stay distinct. There is NO fixed source→color convention anymore. An import-time guard rejects any palette color near magenta/red/blue. Z-order on the CalTopo map: source tracks first, then the pink drive route, then the magenta climb route on top. Capture the returned map ID.
3b. Also add the new tracks to the REGIONAL map¶
Requirement (Kyle, 2026-06): every external GPX track pulled during research goes into two CalTopo maps — the per-research map (above) and the regional map for the range those peaks sit in. The per-research map is the focused working view; the regional map is the durable, cumulative archive of every track for that range, built up over time across all research sessions.
scripts/gpx_to_caltopo.py --gpx-dir gpx/<slug> --map-id <REGIONAL_MAP_ID>
- Append to the existing regional map (
--map-id, not--new-map). - Leave dedupe ON here (omit
--no-dedupe) — the regional map accumulates many peaks, so duplicate tracks/markers already present should be skipped. Only the per-research map needs--no-dedupe(so its own summit markers always render). - Pick the regional map by the peaks'
rangefield in peak_db (Sangre de Cristo, Sawatch, San Juan, Elk, Gore, Mosquito, Tenmile, etc.). - For a multi-range objective, add to each relevant regional map.
Regional map registry¶
Regional maps follow the "GPS Tracks — scripts/fetch_caltopo.py --list (authoritative — don't rely on local caltopo/*.json dumps, which go stale after a re-render).
| Range | Regional map | CalTopo ID |
|---|---|---|
| Sangre de Cristo | GPS Tracks — Sangre De Cristo | VKGB00L |
| Sawatch | GPS Tracks — Sawatch | L5VH4BU |
| San Juan | GPS Tracks — San Juan | 06AR6BF |
| Elk | GPS Tracks — Elk | 1G2G7DM |
| Gore | GPS Tracks — Gore | 6E4GJV2 |
| Mosquito | GPS Tracks — Mosquito | LECF68J |
| Tenmile | GPS Tracks — Tenmile | 7QE01UK |
| Front | GPS Tracks — Front | DLES5CC |
| Weminuche | GPS Tracks — Weminuche | 7AQN6TS |
(Also non-CO archives: Washington, California, Nevada, Arizona, Utah, Oregon, Wyoming, Maine, NH, VT, NY, MA, Hawaii, Europe — same naming.)
Coordination note: these regional maps are maintained/re-rendered in a separate workflow. If another session is actively re-rendering one, hold off writing to it until that finishes (a concurrent append can be lost or conflict). Re-fetch with fetch_caltopo.py --map <ID> before deduping against it so the local snapshot is current.
4. Wire the map ID into the report¶
Update the header **CalTopo research map:** line and the caption *[Interactive CalTopo map](https://caltopo.com/m/<id>)*.
5. Render the PNG¶
scripts/make_overview_map.py <slug> --title "<Peak>"
6. Commit, push, verify deploy green¶
Do NOT mark a peak "researched" or update the index until all three artifacts are committed and the GitHub Pages deploy succeeds.
Imported-GPX marker handling — strip summits, gray the rest¶
Requirement (Kyle, 2026-06): trip-report GPX files arrive with their own embedded waypoints (the author's summit pins, camps, trailheads, junctions, water, random marks). These must not be uploaded as-is — they duplicate and clash with the climber's authoritative markers and clutter the map. On import:
- Summit markers → DROP, use mine. Any imported waypoint at/near a known summit (matches a
peaks_only.gpxsummit by name, or within ~75 m of one) is discarded. The objective summits are added frompeaks_only.gpxas neon-green mountain markers —symbol=peak,color=#39FF14(the canonical summit scheme on the regional maps). Dedupe ON, so they're added only if not already present. - All other imported markers → GRAY —
symbol=point,color=#9E9E9E. They stay on the map as useful context (camps, junctions, the TR author's trailhead) but are visually subordinated to the summit pins. - The tracks themselves are unaffected — kept and colored per-track from the safe palette (no magenta/pink/red/blue; magenta reserved for the recommended route, blue for Kyle's recordings). To re-apply the current palette to an existing map without a full rebuild, use
scripts/recolor_map_tracks.py <slug>(edits strokes in place — same map ID, no frontmatter/repo churn, idempotent).scripts/check_caltopo_complete.pyverifies a map still has all its recommended routes + source tracks.
Net effect: one clean set of neon-green summit pins + a quiet gray wash of secondary author waypoints, with the source-colored route lines on top.
Exact marker scheme (matches the regional maps):
| Marker kind | symbol |
color |
|---|---|---|
| Objective summit | peak |
#39FF14 (neon green) |
| Context summit — other named/ranked peak in the PNG frame (Kyle, 2026-07-12) | peak |
#000000 (black) |
Trailhead (peaks.yml kind: trailhead) |
hiking (CalTopo blue hiker w/ pack) |
#0066FF (blue) |
| Any other imported waypoint | point |
#9E9E9E (gray) |
The overview PNG uses the same convention — green mountain icons for objectives, black for other named/ranked summits in view — so PNG and CalTopo read identically.
Marker names + trailhead symbol (Kyle, 2026-06-15). Summit marker titles are just the peak name — no
(13,118', Class 2, UNCLIMBED)suffix (that lives in the report).build_peak_gpx.pywrites name-only summits and tagskind: trailheadlandmarks with<sym>hiking</sym>;gpx_to_caltopo.pyhonors each waypoint's<sym>(trailheads → the bluehikingicon). To restyle maps built before this:scripts/retro_restyle_markers.py(dry-run) then--apply— strips verbose summit titles and switches coord-matched trailheads tohiking/#0066FF. (Unknown CalTopo symbol codes fall back to an empty circle —hikingis the verified hiker-with-backpack code, nottrailhead/hiker.)
Track color convention:
| Source | Color |
|---|---|
| LoJ trip reports | #FF0000 red |
| 14ers.com | #00AA00 green |
| Peakbagger | #0066FF blue |
| Personal recordings (Kyle's GPS) | #9933CC purple — cycles per-track for multi-track GPX |
Summit-marker dedup gotcha (Kyle, 2026-06-09):
gpx_to_caltopo.pydedupes markers account-wide (it loads thecaltopo/*.jsonregional dumps and skips any marker within ~25 m of an existing one). So when a per-report research map is built after the objective already exists on its regional map, the report map's summit markers get silently skipped — the map ends up with trailhead/POI markers but no green summit peaks. (Surfaced on the Trinchera group: its map had only the Blue Lakes TH marker.) The PNG is unaffected —make_overview_map.pydrawspeaks_only.gpxdirectly. - In the pipeline:build_report.pyrunsfix_summit_markers.py --slug <slug> --map-id <new id> --applyafter the CalTopo upload — research maps always carry green objectives + black context summits.share_report.pyruns the same for each new share map. - Repairing an existing map:scripts/fix_summit_markers.py --slug <slug>(dry-run) /--apply, or--all --applyfor every report, or--map-idfor an arbitrary map (share maps). Idempotent: deletes any marker within ~120 m of a target summit, then re-adds onepeak/#39FF14marker per objective (frompeaks_only.gpx) and onepeak/#000000per context summit (from thecontext_peakslist indocs/maps/<slug>.extent.json— the PNG build is the single source of "what's in view"). Objectives = peaks.ymlobjective_ids+pass_over_summits. Skips reports lacking apeaks.ymlorcaltopo_id.Implementation: - New uploads:
scripts/sync_to_regional.pyenforces the marker scheme (reusesgpx_to_caltopo.py --marker-symbol, defaultpoint). - Personal activity ingestion:scripts/ingest_activity.pyauto-classifies by region and applies the same marker rules; tracks cycle through the 13-color palette starting at purple. - Normalizing existing maps:scripts/restyle_markers.pyrewrites every marker on a map in place viaeditFeature— summit-named or summit-located markers →peak/#39FF14, everything else →point/gray. Also renames markers to the canonical peak name when known. Run with--poi-color "#9E9E9E"to match the regional gray.- All regional maps were normalized to the prior blue scheme on 2026-06-02; re-runscripts/restyle_markers.py --export /tmp/peakdb_summits.gpx --map <ID> --poi-color "#9E9E9E" --applyrestyle_markers.py --applyto update to neon green.
Map waypoint scope — objective only¶
- Include: the summit(s), trailhead(s), key drive-in landmarks, and nearby unclimbed ranked 13ers+ that are plausibly same-outing (same drainage / ridge-connected / shared approach).
- Exclude: nearby peaks reached from a different drive entirely (opposite side of a pass/mine/wilderness boundary). Mention them in the report's cluster text if relevant, but they don't belong as map markers.
- Origin of this rule: putting Bartlett Mtn (across Climax Mine, a different drive) on the Jacque/Pennsylvania maps was noise. "Why are you making a map for Bartlett?" (Kyle, 2026-05-29)
PNG framing — avoid distortion / over-zoom¶
The recurring failure mode: distant tracks (a TR where the peak was a minor add to another range's day, a mega-traverse, a long sub-13k outback) drag the bbox out and shrink the actual peaks to dots.
make_overview_map.py handles this by sizing the bbox around the objective peak-marker bounding box + a ~1.5 mi margin (auto-sizing: tight for one peak, spanning for a combo; floored and capped). Tracks that wander far render off-canvas — that wider context lives on the interactive CalTopo map, not the PNG.
If a PNG comes out distorted or over-zoomed, the fix is in make_overview_map.py's bbox logic, not the GPX inputs.
PNG context summits — every ranked peak in view gets a marker¶
The PNG marks every ranked 13er/14er that falls inside the frame, not just the report's objectives (Kyle, 2026-06-09) — matching the CalTopo regional maps, which carry all ranked summits.
- Objective summits (from
peaks_only.gpx): gold star, always labeled. - Other ranked summits in view (queried from peak_db after the bbox is fixed, so
they never expand the frame): silver-gray star. To avoid label soup in dense or
oversized frames, only the nearest ~12 to the objective are labeled; the rest get
just a star (
MAX_CONTEXT_LABELSinmake_overview_map.py). - Disable with
--no-context-summitsif a particular map needs to stay minimal.
Note: a frame that pulls in dozens of ranked summits (e.g. crestolita_broken_hand caught 62) is usually a symptom of an oversized bbox — a long in-scope track dragging the extent — not a context-summit problem. Tighten the bbox, don't disable context summits.
Pending feature: PNG track lines are currently all rendered red. Target is to color them by source (LoJ/14ers/peakbagger) like the CalTopo map, and to add automated map-QA tests (distortion / missing-track / blank-tile / aspect-ratio checks) that gate deploys.
Syncing Kyle's recorded climbs onto the research maps¶
The separate peak_checklist project auto-syncs Kyle's Garmin climbs and drops each
as gpx/<slug>/_kyle_existing/<peaks> YYYY-MM-DD_actual.gpx. mtn_research owns getting
those onto the slug's CalTopo research map + report PNG via
scripts/sync_kyle_recordings.py (peak_checklist's phase12_pipeline.sh calls it
as its last step). It is ledger-gated (.caltopo_sync_ledger.json, gitignored — a clean
no-op when nothing is new), --dry-run-able, soft-fails per slug, and auto commits+pushes
the tracked artifacts (peaks.yml + PNGs; the GPX ride iCloud, gitignored).
Map resolution is duplicate-safe — a rebuild must never orphan a duplicate (see the
no-duplicate-maps hard rule). For each slug with new *_actual*.gpx:
1. gpx/<slug>/peaks.yml caltopo_map_id → append the recording to it;
2. else the report's frontmatter caltopo_id (the existing research map) → append to it,
and backfill caltopo_map_id into peaks.yml;
3. else → create a new map over --gpx-dir gpx/<slug>, capture the id, write it to peaks.yml.
caltopo_map_id (peaks.yml) is the same map as the report's caltopo_id — it lives in
peaks.yml so the sync (which reads peaks.yml, not the .md) can find it. delete_caltopo_map.py
and audit_caltopo_maps.py scan it too, so a sync-managed map isn't flagged orphaned.
Color / marker conventions on research maps:
- Kyle's recordings (_kyle_existing/): blue #0066FF — gpx_to_caltopo.py forces
KYLE_COLOR for these files (matches the PNG COLOR_KYLE). Fix any pre-convention
recordings in place with scripts/recolor_kyle_tracks.py --all --apply (matches a recording
by the filename date in its on-map title, or its <trk><name>). NOTE: gpx_to_caltopo titles
these tracks from the filename (<peaks> (<date>)), not the GPX <trk><name>.
- Objective summits: green #39FF14 peak symbol, ALL objectives (not climbed-only —
climbed reads from the blue recorded track passing through). build_report sets this; sync's
create-path re-applies it. restyle_markers.py is regional-only (its default is also
#39FF14).
- Recommended routes: magenta #E6008C; fix stragglers with scripts/recolor_recommended.py.