TopoScout is a browser-based terrain analysis tool for creating and editing routes, finding high points, comparing climbs, visualizing slope, and overlaying GPX tracks directly on the map. The app runs fully client-side, so terrain analysis happens in the browser without a custom backend. Its designed to work on mobile devices as well, and can be installed as an app.
🌐 Open the Live demo with GPX store.
- Live center elevation for the current map position.
- Find Highest Points within a configurable search radius.
- Find Climbs by scanning many directions and ranking routes by cumulative ascent.
- Slope Map overlay with opacity and slope-angle filtering.
- Create Route by clicking a start and an end on the map — the track is built along real roads and trails, then handed straight to the editor.
- Edit track by dragging handles along the route; each drag re-routes and snaps, or draw freehand with snapping off.
- GPX route overlay with customizable styling and route stats.
- Points of Interest (POIs) saved to your Google account, with a custom name, description, and color.
- Print map export of the framed area to a print-ready PDF (A4/A3/A2, WGS 84 or SWEREF 99), desktop only.
- Map tools for overzoom, tilt, and 3D terrain exaggeration.
- Share Map View links that restore language, center, zoom, and selected layer.
- Multiple map sources including topographic, satellite, national, and debug elevation layers.
- PWA install support for desktop and mobile.
- English and Swedish localization.
TopoScout focuses on terrain discovery rather than just displaying a single height sample.
- Highest-point scanning ranks the tallest candidates inside the current search radius.
- Climb analysis estimates the strongest uphill routes by summing positive elevation changes over a chosen distance.
- Slope visualization renders a color-coded raster overlay that highlights shallow terrain, steep hillsides, and very steep ground.
- Water filtering can exclude water-colored areas from analysis to reduce false positives.
Routes are first-class here: you can draw one from scratch, reshape it by hand, or bring in a GPX file — all in the browser, all under Create Routes and POIs at the top of the control panel.
- Create Route: click a start point, click an end point, and the app builds the track between them along real roads and paths via self-hosted routing, then opens it in the track editor with start/midpoint/end handles already placed. Cancel and
Escwork throughout, including mid-request. Pairs more than 100 km apart, and second clicks landing on the start point, are refused before any network call. Without the routing backend it still works, as a straight editable line. The new route is an ordinary in-memory track, so Download GPX works immediately. - Edit track: reshape a loaded route by dragging handles along it. Three handles (start, midpoint, end) are placed automatically, and clicking the track adds more; right-click (or long-press) removes one. Each drag re-routes the two sub-segments either side of the moved handle, snapping to real trails and roads via self-hosted routing — turn Snap to roads/paths off for freehand editing. The panel has Undo, Redo, Save and Cancel, and the routing profile can be set to running/hiking (default), bike/mountain bike, walking (standard) or bike (standard), and applies per drag rather than per session.
- Load a local
.gpxfile directly in the browser. - Customize track color and line width.
- Toggle distance labels in kilometers or miles.
- Color the route by slope.
- Show waypoints and min/max elevation markers.
- View route summary stats including distance, elevation gain/loss, and min/max elevation.
- Open an elevation profile bar for the loaded route: hover or drag to scrub along the track, scroll to zoom the profile, and use the arrow keys to step (hold
Shiftfor larger steps). - Enable Sync Map with Profile to pan the map to a blue marker that follows the profile cursor.
- Download the loaded route back to a
.gpxfile (saved under its current name). After saving edits the file is regenerated from the edited geometry, so per-point timestamps and sensor extensions from the original are not preserved. - Optionally upload, list, share, rename, and delete GPX routes when the optional backend is running.
- To acess your GPX file you log i with your Google account
Save your own marked spots and keep them on every device.
- Sign in with Google, then tap Add POI and tap the map to drop a pin.
- Give each POI a name, a description (URLs become clickable links), and a color.
- POI pins use a star marker tinted with the chosen color, show the point's elevation, and include a copy-coordinates button.
- Open a POI from the list to recenter the map on it, or move, rename, edit, and delete it.
- POIs are stored per Google account through the optional backend and load automatically wherever you're signed in.
- Toggle all POI pins on or off with the Show POIs checkbox.
Export the current view as a print-ready PDF. Print map is desktop only (the framing UI needs the room) and is launched by clicking the app logo in the Control Panel header.
- A framing window is drawn over the live map (the area outside is shadowed), so you can pan and zoom to frame exactly what you want to print.
- Choose the paper size (A4, A3, or A2) and orientation (portrait or landscape).
- Pick the coordinate system — WGS 84 (default) or SWEREF 99 TM.
- Toggle each annotation independently: scale ruler, map source, coordinates, north arrow, and map border (border off by default). Disabling an annotation reclaims its margin for the map, so the printed area grows.
- The export captures your base layer, hillshade, contours, route overlays, and the GPX track at print resolution (~200 DPI), then composites POI pins, analysis result pins, and GPX labels/waypoints on top.
- The finished PDF shows the scale (
1:X) and map source by the scale ruler, a north arrow rotated to true north, a small TopoScout.org stamp, and corner coordinates at the upper-left and lower-right (northing horizontal, easting vertical).
- Search by place name or coordinates.
- Jump to your current position with the GPS button.
- Rotate the map with
Ctrl+ drag on desktop or two-finger rotation on touch devices. - Reset north using the compass control.
- Toggle 3D terrain with the 3D button next to the search box.
- Enable overzoom, tilt, and 3D exaggeration from Advanced settings in the About menu.
- Switch between multiple map layers without leaving the current map state.
Built-in layers include:
- OpenTopoMap
- Tracetrack Topo
- ThunderForest Outdoors
- Lantmateriet (Sweden)
- Norgeskart (Norway)
- OpenStreetMap
- Satellite (ESRI)
- Elevation Data (debug view)
Some third-party layers require an API key. When needed, the app prompts for the key and stores it locally in the browser.
Elevation analysis uses Terrarium-format DEM tiles from Mapterhorn.
Optional overlays can be drawn on top of any base layer from the Route Overlay dropdown:
- Waymarked Trails — hiking, cycling, MTB, and skating route networks, with a "Routes in view" legend (click a route to isolate just that trail).
- OSM Path layer — OpenStreetMap paths and trails from a Mapbox raster style, proxied through the Cloudflare worker so no API key reaches the browser.
- Strava Global Heatmap — aggregated activity heatmap
- The app loads terrain raster tiles for the current viewport into an off-screen analysis surface.
- Pixel values are decoded with the Terrarium elevation formula:
(R * 256 + G + B / 256) - 32768. - The same viewport data can then be reused by the peak scan, climb scan, and slope renderer.
- Optional water analysis masks out likely water pixels before ranking terrain results.
- The visible analysis surface is sampled for candidate elevations.
- Only candidates inside the selected search radius are kept.
- Candidates are sorted by elevation.
- A minimum-distance filter removes near-duplicates so the result list stays geographically useful.
- The best matches are rendered as numbered markers with result popups.
- Candidate start points are sampled across the analysis surface.
- Multiple headings are tested from each start point.
- Each path is walked in small elevation steps.
- A smoothing pass reduces tile noise.
- The route is scored by cumulative positive ascent.
- The best climbs are drawn on the map with distance, slope, vertical drop, and elevation details.
- The app compares neighboring elevation samples to estimate slope angle.
- Each pixel is assigned a slope class color.
- The overlay can be clipped to the search radius or shown across the full visible viewport.
- Users can filter by minimum and maximum slope angle, then adjust overlay opacity.
- Pick a base layer from the layer selector.
- Search for a place or center the map on your current location.
- Adjust the search radius and decide whether to show or lock it.
- Click the 3D button next to search to turn on 3D terrain relief.
- Use Advanced settings (in the About menu) for Overzoom, Tilt, and 3D Exaggeration.
- Expand Create Routes and POIs — the first section in the control panel.
- Click Create Route, then click a start and an end on the map to draw a track along real roads and paths, and drag its handles to shape it.
- Or load an existing GPX file, and use Edit to reshape it the same way.
- Sign in and tap Add POI to drop a saved Point of Interest; toggle pins with Show POIs.
- Tune track styling and visibility options, and open the elevation profile to scrub along the route.
- Open Find Highest Points to rank peaks inside the active radius.
- Open Find Climbs to look for strong uphill routes over a fixed measurement distance.
- Open Generate Slope Map to paint the terrain by steepness.
- Compare the results against any route or POIs already on the map.
- Click the app logo in the Control Panel header to open Print map.
- Pan and zoom to frame the area inside the print window, then pick the paper size, orientation, and coordinate system.
- Toggle the annotations you want and click Generate PDF to download the print-ready file.
- Click the share button in the header to copy a map-state link.
- Install the app from the About dialog or the mobile install prompt when supported.
TopoScout is a Progressive Web App, so you can install it to your home screen or desktop for a full-screen, app-like experience. Once installed, the core app shell works offline.
- Open toposcout.org in Chrome.
- Tap the ⋮ menu → Install app (or Add to Home screen).
- You can also use the in-app install prompt, or the Install as App button in the About dialog.
- Open toposcout.org in Safari (installing isn't available in other iOS browsers).
- Tap the Share button.
- Scroll down and tap Add to Home Screen, then tap Add.
- Click the install icon in the address bar, or use the Install as App button in the About dialog.
- The app remembers language, map position, zoom, and selected layer in
localStorage. - Shared URLs restore the current language and map state.
- API keys are stored locally in the browser.
- Points of Interest are saved per Google account on the optional backend, so they sync across devices.
- No terrain analysis results are uploaded to a project server.
The frontend works fully on static hosting (GitHub Pages and the live demo) with no backend. An optional FastAPI backend adds GPX upload, a per-browser upload history, shareable ?gpx=<id> links, and saved Points of Interest.
The frontend auto-detects the backend by probing /api/health on load. When it is reachable, the Load GPX Route button opens an upload/history modal and share links include the uploaded route. When it is not reachable, the same button opens the local file picker directly — no upload UI, no errors, and any ?gpx= parameter is stripped silently.
Saved Points of Interest also require the backend: each POI is tied to your Google account through the /api/pois endpoints, so signing in shows your pins on any device. Without the backend, the Add POI flow reports that POIs need the online backend.
Run it locally:
pip install -r requirements.txt
uvicorn main:app --host 0.0.0.0 --port 8000Then open http://localhost:8000/. The backend serves the static files and stores uploads under gpx-files/ (configurable via GPX_UPLOAD_DIR).
Or with Docker:
docker build -t toposcout .
docker run -p 8000:8000 -v "$(pwd)/gpx-files:/app/gpx-files" toposcoutThe track editor's Snap to route needs a routing engine. It talks to a self-hosted openrouteservice instance through the backend's /api/route/{profile} proxy — the browser never reaches ORS directly, so no port is published, no CORS is configured, and no host is added to the CSP. Without it (or on static hosting) the checkbox is disabled and editing falls back to freehand lines.
Compose file: ors/openrouteservice.yaml. Config: ors/ors-config.yml.
Four profiles are built, matching the editor's dropdown. The first two are trail-weighted, the last two are their plain street-network counterparts:
| Profile | Dropdown | Routes over |
|---|---|---|
foot-hiking |
Running/Hiking (default) | Footways, paths, tracks and residential/service/living-street roads unpenalised; cycleways and tertiary/secondary/primary allowed but penalised; motorway/trunk and anything foot=no excluded. Weighted by SAC scale and hiking-route relations, so it will route alpine trails that foot-walking refuses — at the cost of some trail bias on mixed street/trail terrain. |
cycling-mountain |
Bike/Mountain bike | Tracks, paths and roads suitable for an MTB — the legs a runner shares with bikes. |
foot-walking |
Walking (Standard) | Plain pedestrian routing with no trail weighting: footways, pavements and residential streets, taken at face value. The one to reach for in a town, where foot-hiking detours onto paths for the sake of the path. Correspondingly worse on alpine trails — see below. |
cycling-regular |
Bike (Standard) | Roads and cycleways at road-bike speeds — the tarmac counterpart to cycling-mountain, which prefers tracks. |
The profile applies per drag, not per session, so one leg can follow a trail and the next a road without leaving edit mode. The enabled set must stay identical in ors/ors-config.yml, ORS_PROFILES in main.py, and GPX_EDIT_PROFILES in the frontend — plus the <option> list and the optionKeys label map, which exist once per panel (track editor and Create Route), so five frontend sites in all. GPX_EDIT_PROFILES only relabels <option>s that already exist; it never creates them, so a profile added to the array alone is invisible, and one missing from ors-config.yml gives a dropdown entry that 502s.
The dropdown label says Running/Hiking and the profile is foot-hiking: the label names the use case, the profile is whichever ORS profile serves it best, and here the two agree. It read Running/Walking until v2.27.0, which was fine while it was the only foot profile and misleading the moment Walking (Standard) appeared below it. foot-hiking is the default rather than foot-walking because of a measured failure — at 45.846 N, 7.034 E foot-walking could not route at any snap radius while cycling-mountain at the same coordinates could, so the path is mapped in OSM and the walking profile was the blocker. That is a reason to default to foot-hiking, not a reason to withhold foot-walking: it is offered two rows down for the opposite case, street routing where the hiking weighting wanders. If a drag will not snap on a trail, switching from Walking (Standard) to Running/Hiking is the first thing to try. driving-car remains out; car routing has no use in a running app. Adding a profile means a full graph rebuild.
/api/route/{profile} tries two openrouteservice instances in order:
- On-prem (
ORS_BASE_URL) — the container below, holding a regional extract. Fast, private, no quota. - Public API (
ORS_API_KEY) —https://api.heigit.org/openrouteservice, tried only when the local instance cannot answer.
ORS returns 404 with code 2010 when a point has no routable way nearby, which is exactly what a point outside the local extract looks like — so any non-200 from the local instance is a reason to try the public API, not to give up. The response carries X-Route-Source: local or public so you can tell which answered.
A profile the local graph doesn't have. ORS_PROFILES in main.py and the enabled profiles in ors/ors-config.yml must match. If they drift — say the profile list changed but the graph was never rebuilt — the local instance answers every request with HTTP 400 / ORS code 2003 (Parameter 'profile' has incorrect value). That is a configuration mismatch, not an unroutable point: no radius or retry can help, and left alone it silently pushes every drag onto the metered public API.
The proxy detects that specific code, logs it once at ERROR naming the fix, and then skips the local instance for that profile for ORS_LOCAL_MISS_TTL_SECONDS (default 600). The mark lives in the app container, not in ORS, so a graph rebuild cannot clear it — the TTL is what lets a rebuild take effect without restarting toposcout, and the first successful local response drops the mark immediately. A 400 from the public leg never marks anything.
The cure is always the same: enable the profile in ors-config.yml and rebuild with REBUILD_GRAPHS: "True".
Snap radius widening. The editor asks for a 50 m snap radius, which suits dense mapping. In sparse terrain — alpine trails, forest tracks — the nearest routable way is routinely a few hundred metres from the recorded track, and 2010 there means "you did not look far enough", not "no path exists". So when every upstream declines, the proxy retries the whole ladder at progressively wider radii (ORS_RADIUS_ESCALATION_M, default 300,1500), and reports the one that worked as X-Route-Radius.
The cost is upstream calls: a drag over genuinely unroutable ground (a glacier, say) burns one call per radius per upstream — six with both legs configured, three of them against the public quota. ORS_FALLBACK_RATE_PER_MIN bounds the damage. Shorten or empty the ladder if you only ever edit in well-mapped areas.
Widening never moves a handle on its own: the editor adopts a routed endpoint only when it lands within GPX_EDIT_SNAP_ADOPT_MAX_M (80 m) of where the handle was dropped. Past that the routed path is still used, but the handle stays put and a straight connector bridges the gap — the same treatment neighbour handles already get.
This is what makes whole-world snapping possible on a host too small to build a planet graph: keep the area you run in on-prem, let the rest fall through. Both legs are optional — with only ORS_BASE_URL set, routing is strictly on-prem and stops at the extract boundary; with only ORS_API_KEY, no container is needed at all; with neither, /api/health reports routing:false and the editor degrades to freehand.
Get a free key at openrouteservice.org/dev and set ORS_API_KEY in toposcout.yaml. The key never reaches the browser — that is the whole point of the proxy — and toposcout.yaml is gitignored, so it stays out of the repo. The free tier allows 40 directions/minute and 2000/day at the time of writing; a handle drag costs up to two calls, so ORS_FALLBACK_RATE_PER_MIN (default 30) caps how fast a burst of editing outside the extract can spend that quota. Note that coordinates sent to the public API leave your network.
1. Create the data directories and download an OSM extract. Build time, RAM and disk all scale with the .pbf, and the local instance only needs to cover where you edit most — anything outside it falls through to the public API (see Two upstreams below). From Geofabrik:
sudo mkdir -p /share/www/openrouteservice/{config,files,graphs,elevation_cache,logs}
sudo wget -O /share/www/openrouteservice/files/sweden-latest.osm.pbf \
https://download.geofabrik.de/europe/sweden-latest.osm.pbf2. Install the config. ORS v9 reads it from /home/ors/config/ors-config.yml, not the v7-era root location.
sudo cp ors/ors-config.yml /share/www/openrouteservice/config/ors-config.yml
# Only if you downloaded something other than sweden-latest.osm.pbf.
# sudo sed -i 's#sweden-latest.osm.pbf#region-latest.osm.pbf#' \
# /share/www/openrouteservice/config/ors-config.yml
# The image runs as uid/gid 1000; without this ORS cannot write graphs or the elevation cache.
sudo chown -R 1000:1000 /share/www/openrouteservice3. Size the JVM. Set XMX in ors/openrouteservice.yaml from the .pbf size, and give the host at least XMX + 6 GB — the graph build holds its index in heap, and graphs_data_access: MMAP pages graphs off disk at query time, so the OS still needs page cache.
Only the Sweden row is measured (16 GB host, XMX 10g, two profiles — the measurement predates the two standard profiles); the rest are scaled from it and are rough. Contraction is superlinear in graph size, so the large rows are the least trustworthy:
| Extract | .pbf size |
XMX |
Host RAM | Graph build (2 profiles) | Disk (graphs) |
|---|---|---|---|---|---|
| Single region (Västra Götaland) | ~120 MB | 6g |
8 GB | ~5 min | ~2–4 GB |
| Small country (Denmark, Switzerland) | ~400 MB | 8g |
16 GB | ~10–15 min | ~5–8 GB |
| Sweden / Norway | ~700 MB–1 GB | 10g |
16 GB | ~20 min (measured) | ~10–17 GB |
| Germany / France | ~4 GB | 28g+ |
48 GB+ | ~2–4 h | ~40 GB+ |
| Europe | ~28 GB | 96g+ |
128 GB+ | ~12–24 h | ~150 GB+ |
| Whole planet | ~80 GB | 200g+ |
256 GB+ | 1–3 days | ~300 GB+ |
The elevation cache adds roughly 1 GB per 10°×10° SRTM tile touched; a few GB for a country.
The table is for two profiles; ors-config.yml now builds four. Build time and graph disk scale roughly with the number of profiles, so read both of those columns as about double — the measured Sweden row becomes ~40 min and ~20–34 GB. Heap does not scale that way: init_threads: 1 builds one profile at a time, so XMX and the Host RAM column stand as written. Disk is the column that bites; check free space before a rebuild.
The ceiling is memory, not time. A country extract builds in minutes, so it is tempting to read the planet row as "just leave it overnight" — but the import holds its node index in heap, and an undersized heap does not fail fast: it thrashes and then OOMs, losing the whole build however long you waited. On a 16 GB host a country extract is the limit regardless of patience; use the public-API fallback for everything beyond it rather than trimming XMX.
4. First build. Set REBUILD_GRAPHS: "True", then:
docker compose -f ors/openrouteservice.yaml up -d
docker logs -f openrouteserviceWatch for one Building graph ... block per profile, then Started Application in … seconds. The first start also downloads SRTM tiles into elevation_cache — normal and one-off. A country extract finishes in minutes, but the healthcheck's start_period in openrouteservice.yaml must still cover the whole build or Docker restarts the container partway through and you start over; use nohup/tmux for anything larger than a country.
5. Turn the rebuild off. Set REBUILD_GRAPHS: "False" and bring it up again. Subsequent restarts then load the prebuilt graphs in seconds. Forgetting this is the most common ORS operational mistake — every restart would otherwise rebuild from scratch.
6. Verify from inside the network, the way the backend reaches it:
docker exec toposcout python - <<'PY'
import requests
print(requests.get("http://ors-app:8082/ors/v2/health", timeout=10).text)
r = requests.post("http://ors-app:8082/ors/v2/directions/foot-hiking/geojson",
json={"coordinates": [[12.0918, 57.8113], [12.1000, 57.8150]],
"elevation": True, "instructions": False, "radiuses": [50, 50]}, timeout=30)
print(r.status_code, r.json()["features"][0]["geometry"]["coordinates"][:3])
PYExpect {"status":"ready"} and 3-element [lon, lat, ele] coordinates. Two-element coordinates mean the elevation config in step 2 did not take effect — routed points would land in the GPX with no <ele> and flatline the elevation profile.
7. Wire it into the app. toposcout.yaml already sets ORS_BASE_URL=http://ors-app:8082/ors on the gpx-editor service, plus an empty ORS_API_KEY for the public fallback — paste your key there to enable it. Recreate the container (up -d, not restart, or the environment is reused) and confirm:
curl -s http://localhost:8003/api/health # -> {"status":"ok","routing":true}8. Refreshing map data. Replace the .pbf, set REBUILD_GRAPHS: "True", restart, wait for the build, then set it back to "False". The local instance is unavailable for the whole rebuild; with ORS_API_KEY set, snapping keeps working through the public fallback in the meantime, otherwise the editor degrades to freehand.
Both services join the pre-existing external immich_default network. Docker's embedded DNS resolves container aliases within a user-defined network, and the explicit aliases: [ors-app] guarantees the name regardless of compose project prefixing, so http://ors-app:8082/ors resolves from gpx-editor without publishing any port.
Backend environment variables: ORS_BASE_URL (on-prem instance), ORS_API_KEY (enables the public fallback), ORS_FALLBACK_URL (default https://api.heigit.org/openrouteservice), ORS_FALLBACK_RATE_PER_MIN (default 30), ORS_RADIUS_ESCALATION_M (default 300), ORS_MAX_RADIUS_M (default 2000), ORS_LOCAL_MISS_TTL_SECONDS (default 600), ORS_TIMEOUT_SECONDS (default 20), ORS_RATE_PER_MIN (default 120), ORS_MAX_RESPONSE_BYTES (default 2 MiB). With neither ORS_BASE_URL nor ORS_API_KEY set, routing is disabled.
Routing failures are logged to the container log with the upstream, radius and status — docker logs -f toposcout — because from the browser every failure looks identical: the editor just draws a straight line.
- The app can be installed on mobile and desktop.
- A service worker caches the core app shell for faster repeat visits.
- When shipping a new release, bump both the displayed app version and the cache name so clients refresh cleanly.
- "Works offline" means the installed app, served over http/https and backed by the service worker. Opening
index.htmlstraight from disk (afile://URL) is not supported: MapLibre GL JS 6 is ESM-only, so the app loads it through a module script, and browsers block module fetches fromfile://(null origin). The app detects this and says so instead of showing a blank page. To run from a local checkout, serve the folder over http — theuvicorncommand under Optional Backend, or any static server (python -m http.server 8000,npx serve).
index.html- application shell and modal markupscript.js- map adapter, terrain analysis, GPX overlay, elevation profile, localization, and app logicstyle.css- control panel, modal, and map stylingservice-worker.js- offline asset cachingmanifest.json- PWA metadatalang/en.js- English stringslang/sv.js- Swedish stringsvendor/jspdf.umd.min.js- vendored jsPDF library for Print map PDF export (precached for offline use)fonts/- self-hosted, same-origin glyph sets for contour labels (noto-sans-regular,open-sans-regular)icon.svg- app icon sourceicon-set.html- helper page for generating the app icon setmain.py- optional FastAPI backend for GPX upload/list/delete/share/renamerequirements.txt- Python dependencies for the optional backendDockerfile- container image for the optional backendors/openrouteservice.yaml- compose file for the self-hosted openrouteservice used by track editingors/ors-config.yml- openrouteservice configuration (profiles, elevation, source extract)gpx-files/- uploaded GPX storage (created at runtime; git-ignored)tests/- self-contained Playwright end-to-end suite: specs,playwright.config.ts, its ownpackage.json, andstatic-server.mjs(serves the repo root over http, since the app cannot boot fromfile://). Run it withcd tests && npm ci && npm test; report and results land intests/playwright-report/andtests/test-results/.
-
v2.27.3: Fixed the hillshade quietly losing detail whenever 3D terrain was switched on. Since v2.8.0 both features read the one
raster-demsourceelevation-dem, which MapLibre has always warned about ("You are using the same source for a hillshade layer and for 3D terrain") — and the warning turned out to be describing a real regression rather than a style nit. Enabling terrain setsusedForTerrainon the shared tile manager and doubles itstileSizefrom 512 to 1024;TileManager.update()then computes its tile cover with that inflated size (plusroundZoomdisabled and an extra_addTerrainIdealTiles()pass), so the whole source drops a zoom level. The terrain mesh wants exactly that; the hillshade layer, rendering from the same manager, just got coarser DEM tiles for as long as 3D stayed on, and silently sharpened again when it was turned off. The hillshade layer now has its own source,hillshade-dem, built from the samegetTerrainSourceDefinition()(same Mapterhorn tiles,terrarium,tileSize512, maxzoom 15) — which is upstream's own recommended fix.ensureTerrainSource()was generalized toensureDemSource(sourceId), so terrain's three call sites are unchanged. Unlikeelevation-dem, the new source is created lazily byapplyHillshade()only while the layer is on and removed with it, so a hillshade-off style carries no idle source. Layer ordering is untouched — the hillshade still sits directly above the basemap and below every overlay, route and marker. The extra network cost is small: the hillshade now loads the same tiles it already loaded with 3D off, terrain adds only its coarser (~¼ count) set, andtiles.mapterhorn.comis already in the service worker's runtime tile cache, so the overlap is served from cache. -
v2.27.2: Redesigned the social preview card (
og-image.png) around the route tools. The mountain fromicon.svgnow runs at 218px instead of 96px — the bare glyph without the rounded-square chip, so it stays the same mark as the app icon at more than twice the size — and a track sits on the terrain drawn exactly the way the editor draws it: the slope ramp fromslopeToColor()and the handle colours from.gpx-edit-handle, green start, purple middles, red end. The contour lines behind it are generated rather than decorative, traced with marching squares over a height field so they crowd on steep ground and spread over flats the way a printed map does. The copy beside the card moved with it. The description is a single string used in five places —meta name="description",og:description,twitter:description, and theWebSiteandWebApplicationnodes in the JSON-LD — and now opens on building routes and reshaping GPX tracks before the terrain keywords it always carried, front-loaded so the ~160 characters Google renders are the route half.og:title/twitter:titlebecame "Route Planning and GPX Editing on a Free Topographic Map", matching the tagline printed on the card. The JSON-LDfeatureListsplit its single "Overlay and edit GPX routes" line into four entries covering Create Route, the drag-to-reshape editor, slope colouring and download, and the elevation profile, and moved them above the terrain features. The page<title>swapped Peaks for Routes in both languages (toppar→rutterin Swedish, rather than the UI's ownspår, which a button needs but which is ambiguous on its own in a title), keepingmeta_titleinlang/en.jsbyte-identical to the static tag as that file's comment requires.og:image:altandtwitter:image:altdescribe the new artwork, andapp_title_sr— the visually hidden half of the page's one<h1>, which is what a screen reader and a crawler read as the heading — now leads with editing routes/GPX before the terrain features, in both languages and in the static copy inindex.htmlthat non-JS crawlers see. Addedtools/og-image/, which regenerates the PNG deterministically fromcard.html— the previous card was committed with no source and so could never be adjusted; it is build-time only, and nothing undertools/is served, imported or precached. Note that social scrapers cache by URL: the filename is unchanged, so Facebook and LinkedIn keep serving the old card until the page is re-scraped in their sharing debuggers. -
v2.27.1: Updated the vendored map engine from MapLibre GL JS 6.5.0 to 6.6.0 — a drop-in replacement of the self-hosted
vendor/maplibre-gl*dist files, taken verbatim from the npm package as before. No app code changed: the exported API surface is identical between the two releases (85 exports, nothing added or removed),vendor/maplibre-gl.cssis byte-for-byte identical to the 6.5.0 copy so the.maplibregl-*overrides instyle.cssare untouched, and the ESM loading path throughmaplibre-boot.mjsis unchanged — both the main bundle and the worker still import./maplibre-gl-shared.mjs, so the three-file service-worker precache set still holds. Every version-sensitive call the app makes is still present:setWorkerUrl,canvasContextAttributes/setPixelRatioon the offscreen print map,addProtocol(via maplibre-contour),setTerrainandhillshade-exaggeration. The headline upstream change lands directly on this app's core feature and is the reason to take this release: terrain coordinates are now picked by a CPU raycast against the DEM instead of a coords-framebuffer readback, so the point under the pointer is resolved at full DEM resolution, pointer events no longer stall the GPU, and about 4MB of GPU memory is freed. Note that the v2.25.3 entry below claimed this change for 6.5.0 — it was written against the upstreammainchangelog before the release cut, and the raycast actually shipped in 6.6.0. It is genuinely in the app as of this build; the About-modal wording for v2.25.3 was corrected to match. Also fixed upstream: a style'scenter/zoom/bearing/pitch/rollbeing ignored whenever the map was constructed with aminZoomorminPitch(applying the limit marked the transform as modified, so the saved view was discarded — this app passes both), labels briefly drawing too large during a fast multi-level zoom out, globe tile selection measuring from the ground point below the camera rather than the camera, and thecolor-reliefDEM texture being re-uploaded every frame instead of once per tile. New upstream but unused here: thesymbol-height-offset/symbol-height-anchorlayout properties, which raise icons and text above the terrain surface. Requirements are unchanged from v2.17.0 — WebGL2, and served over http/https. -
v2.27.0: Two more routing profiles in the track editor and Create Route. The picker offered only trail-weighted profiles —
foot-hikingandcycling-mountain— both of which prefer paths and tracks on principle, so drawing a route through a town detoured onto footways for the sake of the footway. Walking (Standard) (foot-walking) and Bike (Standard) (cycling-regular) now sit below them, giving plain street-network routing for the same two modes. The existing two entries keep their position and their order, andfoot-hikingis still the default. Its English label changed from Running/Walking to Running/Hiking: that was accurate while it was the only foot profile, but with a plain Walking (Standard) sitting below it the old label competed for the same word, and Hiking is whatfoot-hikingactually weights for. The Swedish label already read Löpning/Vandring and is unchanged.foot-hikingis still the default — it remains the one of the four that reliably snaps on alpine trails, for the reason recorded under Self-Hosted Routing. The choice still applies per drag rather than per session, so one route can leave town on tarmac and finish on a trail. Both panels were updated: the<option>list and the label map exist once per panel, andGPX_EDIT_PROFILESonly relabels options that already exist rather than building them, so a profile added to the array alone would have been invisible. On-prem routing needs the new profiles in the openrouteservice graph —ors/ors-config.ymlenables all four now, which is a full rebuild, and roughly doubles graph build time and disk. Until that rebuild lands the two new profiles are answered by the public API fallback (the backend detects the missing-profile error once, logs it naming the fix, and stops retrying the local instance for ten minutes), or degrade to freehand where no key is configured. -
v2.26.0: Saving track edits now updates the stored route it was opened from. Previously, pressing Save in the track editor wrote the new geometry only into the browser: the copy on the server still held the unedited track, and because that copy no longer matched what was on screen the app deliberately dropped the
?gpx=share link. The only way to persist an edit was Download GPX followed by re-picking the file from disk. Save now pushes the edited GPX back over the record it came from, in place — same id, same share link, same file on disk, so a reload of the shared URL returns the edited track. ReusingPOST /api/upload, which already upserts on(owner_id, filename), means no new endpoint and no change to the storage format. Only routes that are genuinely yours are touched: ownership is checked against the owner-filtered/api/fileslist, so opening somebody else's shared link, editing it and saving behaves exactly as before (nothing is written, the link is dropped) rather than silently forking a copy into your own storage. The existing "this rewrites the GPX and loses timestamps and sensor data" confirmation gained one extra line naming the stored file it will replace — one dialog, not two. If the upload fails, the edits are still saved locally, the stale share link is dropped and the status line says the stored copy could not be updated. Routes drawn with Create Route are unchanged: they have no stored record, so they still leave via Download GPX. On a build with no backend nothing changes at all. -
v2.25.3: Updated the vendored map engine from MapLibre GL JS 6.3.0 to 6.5.0 — a drop-in replacement of the four self-hosted
vendor/maplibre-gl*dist files (.mjs,-shared.mjs,-worker.mjs,.css), taken verbatim from the npm package as before. No app code changed:vendor/maplibre-gl.cssis byte-for-byte identical to the 6.3.0 copy, so the.maplibregl-*overrides instyle.cssare untouched; the ESM loading path throughmaplibre-boot.mjsis unchanged (both bundles still import./maplibre-gl-shared.mjs, so the three-file service-worker precache set still holds); and every version-sensitive call the app makes is still present —setWorkerUrl,canvasContextAttributes/setPixelRatioon the offscreen print map,addProtocol(via maplibre-contour),setTerrainandhillshade-exaggeration. The headline upstream change lands directly on this app's core feature: terrain coordinates are now resolved by a CPU raycast against the DEM instead of a GPU framebuffer readback, so the point under the pointer is resolved at full DEM resolution and pointer events no longer stall the GPU — 3D/tilt reads are both more accurate and smoother. Also fixed upstream: frame-rate degradation after a style switch (images were being permanently marked for update, which this app hit on every basemap change), raster tiles flashing the background colour when reloaded, labels briefly drawing too large during a fast multi-level zoom, and aDOM.sanitizehole where several consecutive dangerous attributes were not all stripped.Markeralso became keyboard-focusable and arrow-key movable, which reaches the draggable GPX edit handles. Requirements are unchanged from v2.17.0 — WebGL2, and served over http/https. -
v2.25.2: Cleared the two issues the browser had been reporting in the DevTools Issues panel. First, the Report-Only CSP staged in v2.15.2 was blocking two Google sign-in resources the app legitimately uses, so the policy could not have been promoted to enforced without visibly breaking sign-in. The signed-in user's avatar (
lh3.googleusercontent.com, assigned to#gpx-user-avatarand#poi-user-avatar) had no matching host inimg-src, which had been assembled from the map-tile allowlist alone and so never contained a Google host; and the stylesheet the Google Identity Services client injects to skin the "Sign in with Google" button (accounts.google.com/gsi/style) was missing fromstyle-src. GIS needs four directives, not the three that look obvious, andaccounts.google.comwas already allowlisted in the other three —script-src,connect-srcandframe-src— so this was one missed entry rather than an oversight about Google as a whole.img-srcnow allowshttps://*.googleusercontent.com, the wildcard covering the historicallh4/lh5/lh6avatar hosts in the same style the tile list already uses for*.tile.openstreetmap.org; it is added directly to the directive rather than to_CSP_REMOTE_HOSTS, since that constant mirrors the service worker's tile allowlist and also feedsconnect-src, and an avatar is neither a tile nor a fetch target. The policy deliberately stays Report-Only: this removes the known gaps, but promotion waits until the console is confirmed clean across every sign-in path (the One Tap prompt pulls resources the signed-in path does not), andscript-srcstill carries'unsafe-inline'for the app's inlineonclick=handlers — refactoring those out is what actually makes enforcement worth the deploy risk. Second, an accessibility gap of the same kind v2.25.0 fixed elsewhere in the panel: the POI form's colour picker was headed by a<label>that labelled nothing. A<label>can only be associated with a labelable control, and what follows this one is a<div>filled at runtime with<button>swatches, so a screen-reader user met a bare run of colour buttons with no "Color" heading — and, because selection was conveyed purely by.poi-color-swatch.selected's box-shadow, no way to tell which colour was active. The heading is now a<span>and the swatch container is arole="group"bound to it viaaria-labelledby, with each swatch carryingaria-pressedalongside the translated colour name it already had inaria-label. The swatches stay ordinary buttons rather than becoming arole="radiogroup", which would have demanded roving-tabindex keyboard handling for no practical gain: Tab and Space/Enter behave exactly as before, and the picker renders pixel-identically. -
v2.25.1: Add Routes and POIs is now Create Routes and POIs, and it has moved from the bottom of the control panel to the top, above the three analysis sections. The old name and the old position both dated from when the section did one thing — overlay a GPX file you had made somewhere else. Since v2.21.0 you can reshape a loaded track by dragging handles along it, and since v2.22.0 you can draw one from nothing by clicking a start and an end, so the section is where routes are authored, not where an import is parked; "Add" undersold it and last place buried it under three tools you may never open. The spotlight tutorial walks the panel in the new order — the routes step now follows Map Layers & Route Overlay and precedes Find Highest Points — so the tour still reads top to bottom; step count and every other step are unchanged. The radius controls stay with the analysis sections they belong to: they are re-parented into whichever one is open, and the routes section still switches Show Radius off when expanded, since it is not a radius-scoped tool. Swedish gains a fix along the way: the panel header said rutter while the tutorial step said spår for the same section, and both now read Skapa spår och POI:er. This README changed with the app — Create Route and Edit track are named in Core Capabilities, GPX route tools is now Route tools and leads with creating a route rather than mentioning editing sixteen bullets in, and Using The App creates routes before it runs analysis.
-
v2.25.0: The app was invisible to search engines and produced a blank preview when anyone shared a link, because
<head>carried nothing beyond a title and a viewport tag. It now has a descriptive<title>and meta description, a<link rel="canonical">pinned tohttps://toposcout.org/, full Open Graph and Twitter card tags backed by a real 1200x630 share image, and JSON-LD describing the app. The canonical matters more here than on a normal site: every share link this app generates carries a?lang=(often a?gpx=too) plus a#lat/lng/zoomhash, so the same single page was reachable at unlimited distinct URLs, and it answered on both the apex andwww.www.toposcout.orgnow 301s to the apex, androbots.txtandsitemap.xmlexist for the first time. Accessibility: the header title is now the page's one<h1>(rendered pixel-identically, with the keywords in a visually hidden span), and every icon-only button has a localized accessible name — the twelve +/− steppers, the install-bar dismiss, the elevation-profile toggle and the copy-coordinates buttons previously had none at all, and the 3D button's label was stuck in English after switching to Swedish.<html lang>now follows the chosen language instead of always claimingen. The four collapsible panel sections (“Find Highest Points” and friends) were click-only<div>s: unreachable by keyboard and invisible to a screen reader's heading list. They are now<h2>headings wrapping real disclosure<button>s witharia-expanded/aria-controlsand a focus ring, which also gives the panel a proper H1 → H2 outline. Rendering is unchanged — verified pixel-identical at DPR 3 for all four sections, collapsed and expanded. Also fixed the manual-climb result popup, whose header had shipped as mojibake (ߓanual Climb) since the feature landed incd36fcc— the 📏 glyph is restored and the label is translated instead of hard-coded English. Security headers: HSTS (staged at a short max-age, to be raised to a year once verified) and aPermissions-Policythat confines geolocation to this origin and switches off the camera, microphone and every other device API the app never touches. -
v2.24.0: The blue elevation box became a data box holding every live readout in the panel. Elevation keeps the 24 px hero slot; Zoom and Scale sit beside it as tiles behind thin dividers, and the optional distance to GPS and coordinates share a divided line below. Those two ship enabled now, alongside zoom — with the readouts no longer competing with the status text for the footer there is room to show them, and the GPS distance costs nothing until GPS is actually on. Scale stays opt-in. On phones the box is not a box at all: the same markup is flattened by a mobile-only media block into one line of small monospace values under the header, elevation holding the left edge and the optional readouts pushed right, pipe-separated. The box cost ~100 px at the top of a panel that is already full-bleed, and its second row existed only to carry labels a phone does not need — the values identify themselves. That drops a minimized panel from ~171 px to ~92 px: a header, a row of numbers and a status message. This replaces the separate readout row added in 2.23.1, which left the panel with two competing surfaces — a bare unlabelled monospace line floating on the panel background, and under it a bordered box that existed to hold one number. Blue is kept but re-read: it now means live data rather than elevation. The footer line carries no word labels at all — a pin glyph stands in for "Center to GPS" and a coordinate pair introduces itself — which is also what keeps that line inside the box in Swedish, where
Centrum till GPSandKoordinaterwere the two longest strings in the panel. Height tracks what you have switched on: the dividers are a CSS sibling rule so they appear and vanish with the tiles themselves, the footer line hides when both its items are off, and with every readout disabled the box collapses to exactly the elevation box it replaced. In the shipping default (Zoom only) it is 3 px shorter than 2.23.1, because the tile sits beside the elevation rather than on its own line. Two things fell out of the restructure. The box is no longer hidden below 600 px, so phones get Zoom, Scale and coordinates for the first time — andupdateCenterElevation()no longer mirrors the elevation into the status line there, a workaround that existed only because the box was hidden, which means the mobile status line is finally free for status. And because each readout is now a wrapper with its label written once by the translation pass and only its value rewritten on pan, the hardcoded English'Zoom: 'prefix is gone; the zoom label is translated like every other label in the panel. -
v2.23.1: GUI consistency pass on the two things v2.23.0 left mismatched. The Route info panel now wears the control panel's glass —
rgba(255,255,255,0.5)with a 5 px backdrop blur, a 12 px radius and the panel's shadow, in place of the near-opaque 94% card it shipped as — so the two cards flanking the map read as one system instead of two. Its minimize control changed with it: the bordered chevron that rotated on collapse is now the same borderless +/− button the control panel and every section header use, reusing the existing.toggle-btnrules rather than duplicating them. The phone sheet deliberately stays near-opaque, since a full-bleed sheet is read against whatever tile happens to be under it. Second, the optional Zoom, Scale, Center to GPS and Coordinates readouts moved out of the footer. They had been a right-hand column sharing.footer-rowwith the status text, and with all four enabled they took roughly a third of the panel's width and stood four rows tall, so anything longer than a few words wrapped — on a phone that is worse still, because the status line doubles as the elevation readout there. They are now a single right-aligned line under the header buttons, which keeps them on the edge they already occupied (flush left they would have read as a subtitle to the app name) and hands the status bar the full width. The row hides itself when every readout is switched off, so the header sits straight on the elevation box rather than over an empty gap, and it stays outside#controls-contentexactly as the footer was — still visible with the panel minimized, and tap-to-copy on the coordinates still works. -
v2.23.0: Moved the route metrics and the track-edit actions out of the control panel into a new route panel that floats over the map. On desktop it is a card in the map's top-left; under 600 px it becomes a collapsible bottom sheet stacked directly above the elevation profile, minimizing to a header strip the same way that bar does. It carries Length, Elevation Gain/Loss and Min/Max Elevation plus Undo, Redo, Save and Cancel, and it is headed with the loaded track's name — Route info (UTMB_2025) — so the panel says which route it is describing. The two layouts diverge deliberately: desktop has the vertical room, so it stacks every stat on its own line with full labels, while the phone sheet keeps one non-wrapping line of abbreviations and promotes the distance up into the header beside the name, which is what keeps a 172 km track's figures on screen without growing the sheet. A long name shrinks the header text down to a 9 px floor before the name (and only the name) ellipsizes, so the distance stays readable. It is read-only when you are simply viewing a loaded track — the four edit buttons appear only while editing, instead of sitting there dead. This fixes the awkwardness the track editor shipped with: on a phone the control panel is a full-width sheet, so reaching Save meant covering the track you were shaping, and Create Route's workaround of folding the panel away on entry then springing it back open on exit buried the route the moment you finished drawing it. That re-open is gone; the panel now stays out of the way, because nothing editing needs is left inside it. Under the hood both bottom panels moved into one
#bottom-dockflex column, so the browser stacks them and a singleResizeObserverreports their combined height to the code that keeps the zoom controls, attribution and slider stack clear of the bottom edge — previously that measured the elevation bar alone, and anything stacked above it would have been overlapped. The dock also carriesenv(safe-area-inset-bottom), which the elevation bar never had, so a collapsed bar no longer sits under the home indicator on a notched phone. The stats are now chips that wrap horizontally rather than five stacked rows, since vertical space is what a bottom sheet is short of, and disabled Undo/Redo finally have an explicit style instead of relying on the browser's default greying, which was near-invisible on the panel's translucent background. Print map hides the panel while it is open, since both want the same corner. Two fixes to the editor itself came along with it: the Start/End labels are hidden while editing, because the drag path deliberately skipsrebuildGpxMarkers()for performance and they were therefore left marking where the track used to begin and end — sitting on top of the very handles you were reaching for — and the end handle is now red where the start stays green, so the two ends of a track are no longer identical green rings. -
v2.22.1: Fixed the GPS button recentering the map twice.
locateUser()centered on the coarse first fix fromgetCurrentPosition, then centered again whenever the accuracy ring was removed because a laterwatchPositionupdate tightened to pinpoint accuracy (≤ 5 m) — and would repeat this on every subsequent tighten if accuracy degraded and recovered, even after the user had panned away. A singlegpsHasCenteredflag now gates centering to the first fix of a tracking session; the marker and accuracy ring keep updating live as before. -
v2.22.0: Added Create Route — drawing a route from scratch instead of importing one. A new button between Load GPX Route and Add POI enters a placement mode: click a start point, click an end point, and the app builds the track between them and hands it straight to the v2.21.0 track editor, seeded with the usual start/midpoint/end handles so the next thing you do is shape it. The geometry is deliberately made the same way an edit is — the same
/api/route/{profile}proxy, the same 150 m drift rejection (a route whose snapped endpoints land far from where you clicked is discarded in favour of a straight line rather than detouring to a valley road), the same 80 m adopt threshold for pulling an endpoint onto the path, and the same ~50 m densified straight line with DEM-sampled elevations when snapping is off or routing fails — so the first drag after creating a route behaves exactly like every drag after it. The routing profile and Snap to roads/paths toggle are now a single shared setting between the create panel and the editor, which also fixes the old annoyance of Edit track resetting to Running/Walking every time it opened. Nothing is committed until the second click resolves: Cancel and Esc work throughout, including mid-request (the backend can spend the better part of a minute retrying two upstreams at two radii), and a late response is discarded rather than dropped on top of whatever you did instead. Guards run before any network call — a second click within the editor's click tolerance of the first is refused as "that is the start point" (which also absorbs the twoclickevents a browser reports for one double-click-to-zoom, alongside adetail > 1check), and a pair more than 100 km apart is refused outright rather than left to 502 and then densify a continent-spanning straight line through 200 DEM lookups. Creating a route replaces the loaded one — the app holds a single track by design — so it asks first, before you start clicking rather than after. The result is an ordinary in-memory route: it is serialized to GPX immediately, so Download GPX works right away and Save skips the "this rewrite loses your file's extras" warning, since there was never a file. On a phone the control panel folds away on entry (as the POI modal already does) and unfolds on exit, with the step prompt in the status line, which stays visible while minimized. Without the routing backend the feature still works, as a straight editable line with the snap toggle disabled. -
v2.21.0: Added Edit track — reshaping an already-loaded GPX route in the browser. Clicking Edit (between Clear Route and Download GPX) seeds three draggable handles on the track — start, midpoint and end — and drops an editing panel below the button row with a routing-profile picker, a Snap to route toggle, and Undo / Redo / Save / Cancel. Clicking the track adds a handle at that spot (rejected beyond ~18 px of the line, measured in meters from the live map scale); right-clicking or long-pressing one removes it, never below three and never an endpoint. Dragging a handle shows a dashed rubber band and, on release, re-routes the two sub-segments either side of it. With snapping on, each sub-segment is fetched from openrouteservice — an on-prem instance for the local extract, falling back to the public API elsewhere;
foot-walkingfor running by default, pluscycling-mountainand the dragged handle adopts the routed position; with it off — or when routing fails — the leg becomes a straight line densified at ~50 m with elevations sampled from the existing DEM tiles, so gain/loss, the elevation profile and slope colouring keep working across hand-drawn stretches. ORS is reached only through a new/api/route/{profile}proxy in the backend (profile allowlist, two-coordinate validation, clamped snap radius, rate limit, upstream body built server-side), so the browser never talks to it: no published port, no CORS, no CSP change, and/api/healthnow reportsroutingso the frontend disables the snap toggle up front instead of discovering the absence through a failed drag — on static hosting the editor simply becomes freehand. Internally the working geometry is installed straight intogpxTrackData.segments, so the existing draw path renders edits live; every handle indexes a real track point, and a single splice primitive is the only thing that changes point counts and handle indices. Undo/redo keeps full geometry snapshots (capped at 20 and 400k points) and rebuilds handle markers from indices. Saving required a GPX 1.1 serializer — until now Download GPX re-emitted the bytes of the loaded file, which after an edit no longer matched what was on screen; it warns once per session that the rewrite drops per-point timestamps, sensor extensions and other extras the parser never kept, and it drops the?gpx=share link since the stored copy is still the original. Multi-segment tracks are edited on their longest segment with the rest untouched and exported verbatim. Slope colouring is paused during editing (it emits one map feature per vertex pair) and restored on exit; POI placement and Manual mode refuse to start mid-edit rather than silently discarding unsaved work, and Esc cancels with a confirm. See Self-Hosted Routing for the ORS install. -
v2.20.0: The search box now accepts four more coordinate formats on top of plain decimal degrees. DMS —
57° 44' 24.0", 12° 06' 36.0"— and DDM —57° 44.400', 12° 06.600'— are parsed with the degree mark optional, all the Unicode prime/quote variants accepted (′ ″ ’ ” ´, plus''for seconds), hemisphere letters allowed before or after the number, and the Swedish/NordicÖ/Ø/O(öst) andV(väst) understood alongsideN/S/E/W; if the letters say the first value is a longitude, the axes are swapped, so12°06'36"E, 57°44'24"Nlands in the same place as57°44'24"N, 12°06'36"E. Decimal degrees may now be written with a decimal comma —57,8112660, 12,0918247— with the pair separated by a comma+space, a plain space, or nothing at all; the legacy reading of a two-number57,81as latitude 57, longitude 81 is deliberately preserved, and genuinely ambiguous input like57,811,12is rejected rather than guessed at. Finally, Plus Codes (Open Location Code) are decoded locally: a full code (9C3XGV4C+X9) resolves offline, and a short code resolves against a reference point — the locality that follows it (R36R+GP4 Göteborg, geocoded through the same Nominatim endpoint the search box already used) or, with no locality given, the current map view. All of this applies to the search box only; share links, the coordinate readout and the PDF export are untouched. Every parser validates strictly — latitude ±90, longitude ±180, minutes and seconds under 60 — and returns nothing rather than a guess, so anything unrecognised still falls through to the place-name search exactly as before (E6andMalmöremain place lookups). A recognised coordinate now zooms to level 15 instead of 12, since a 2.5 m Plus Code cell is invisible at ~38 m/px; coarse input and place-name results keep the old zoom 12. Also fixed: a failed search used to leave the status bar stuck on "Searching…" forever when the network was down — it now reports the error. -
v2.19.2: Updated the vendored map engine from MapLibre GL JS 6.2.0 to 6.3.0 — a drop-in patch of the four self-hosted
vendor/maplibre-gl*dist files (.mjs,-shared.mjs,-worker.mjs,.css), taken verbatim from the npm package as before. No app code changed: the CSS class names are byte-for-byte the same set (the stylesheet grew ~13 kB purely because the inline SVG control icons are now fully percent-encoded), the ESM loading path throughmaplibre-boot.mjsis unchanged, and nothing the app calls was touched. Two upstream fixes land directly on features this app uses: terrain gestures are now solved against the elevation under the pointer instead of the frozen center elevation, so panning and zooming in 3D/tilt mode no longer drifts off the point you grabbed; andImageSourceno longer leaks a GPU texture on every image update and on removal (a resized texture also keeps its wrap and filter settings), which matters for the slope map — the Leaflet-compat shim's_renderOverlayremoves and re-adds the image layer on every opacity-slider tick, so each drag used to strand a texture. Also fixed upstream: globe scroll/pinch zoom drifting away from the pointer when the globe is small, projective rendering of non-parallelogram image quads, and an "Out of bounds" race inqueryRenderedFeatures()(unused here). Requirements are unchanged from v2.17.0 — WebGL2, and served over http/https. -
v2.19.1: Opening
index.htmldirectly from disk (afile://URL) has shown a blank page since the v2.17.0 MapLibre 6 upgrade. MapLibre 6 is ESM-only, so the library is loaded through themaplibre-boot.mjsmodule script — and module scripts are CORS-fetched, which browsers refuse for afile://page (null origin: "Cross origin requests are only supported for protocol schemes: chrome, data, http, https").maplibreglwas therefore never published to the global scope and the first top-levelL.map('map', …)threwmaplibregl is not defined, aborting the rest ofscript.jswith nothing on screen. The v5 UMD bundle was a classic script (not CORS-fetched) that inlined its worker as a blob, which is why this used to work. Restoring it is not practical — from afile://page a browser also blocks module workers from blob URLs and any worker loaded from afile://URL, so v6 would need both a classic main script and a classic blob worker, andmaplibre-gl@6.2.0publishes no UMD/CJS build. Sofile://stays unsupported, but it now fails loudly: a guard in front of the map construction renders a localized explanation over the map container (English and Swedish, inserted withtextContent) naming the fix — serve the folder over http. The installed-PWA offline mode is unaffected; it runs over http/https with the service worker. -
v2.19.0: Google sign-in now persists. Since v2.15.2 the Google ID token has been kept in memory only (an anti-XSS measure), which left Google One Tap as the sole way to restore a session on load — and under FedCM / third-party-cookie restrictions One Tap is routinely suppressed, so a reload, PWA relaunch, or the service worker's
controllerchangereload silently signed the user out. ID tokens also expire after ~1 h, so even a long-lived tab depended on the same flaky silent re-auth. On a verified sign-in the backend now issues its own HMAC-SHA256-signed, HttpOnly session cookie (elevf_session,SameSite=Lax,Secureby default) valid for 90 days with a sliding expiry — re-issued whenever a session passes the halfway mark, so a regularly used browser is never signed out while an abandoned one still ages out (GPX_SESSION_MAX_AGE_DAYS). The cookie is a statelessv1.<payload>.<sig>token verified withhmac.compare_digest; its signing key comes fromGPX_SESSION_SECRETor is generated once into.session-secretin the writable upload volume (the app directory is mounted read-only), so container restarts no longer sign everyone out. Owner resolution gainedresolve_account_owner()— a fresh Google bearer token first, the session cookie second — whichensure_owner_id,require_owner_idand the account-scopedrequire_google_owner_id(POIs) now use;/api/auth/loginreturnssession_expalongside the profile, and two endpoints join it:GET /api/auth/session(reports and slides the session; never 401s, and actively expires a tampered or stale cookie) andPOST /api/auth/logout(clears the session cookie only, leaving the anonymouselevf_ownercookie intact so pre-sign-in uploads stay reachable). No credential is exposed to JavaScript at any point — this is strictly safer than the pre-v2.15.2localStoragetoken. Client-side,googleAuthgained asourcefield ('token'vs'session'):initGoogleAuth()is now async and restores the server session before waiting on the Google script, so a returning user is signed in immediately even whenaccounts.google.comis slow or blocked, and One Tap only fires when there is no session to restore; after/api/auth/loginthe identity is handed over to the cookie, so the ID token's 1 h life stops mattering and the pre-expiry refresh timer is dropped. Two related sign-out bugs are fixed:clearGoogleAuthState()is now soft by default — the three automatic 401 paths no longer wipe thetopo_google_seenflag or calldisableAutoSelect(), so a single transient backend 401 can no longer permanently disable silent re-auth on that device (only an explicit Sign out does, via{ forget: true }, which also ends the server session) — and the backend health probe's 1500 ms timeout, which on a slow first load hid the entire sign-in UI for the session, is raised to 4000 ms with one automatic retry. -
v2.18.0: Added an OSM Path layer route overlay — OpenStreetMap paths and trails rendered from a Mapbox raster style. Because the Mapbox tile URL carries an
access_token, the layer is served through the existing Cloudflare worker rather than fetched directly:worker.js— until now a single-upstream Lantmäteriet proxy that parsed its path positionally as/{z}/{x}/{y}— now recognises a named/osmpaths/prefix and shifts the coordinate segments accordingly, so the original URL shape keeps working byte-for-byte for deployed clients and already-cached tiles. The worker appends the token and the@2xsuffix server-side, and the client only ever seeslm.clackspark.workers.dev/osmpaths/{z}/{x}/{y}. The upstream request differs per route: the Lantmäteriet branch keeps spoofingReferer: minkarta.lantmateriet.se, while the Mapbox branch forwards the page's own (already validated) Referer, so a URL-restricted Mapbox token still authorises through the proxy. Edge caching for the Mapbox branch usescacheTtlByStatusinstead of a flatcacheTtlso an error response (e.g. a401from a bad token) can't be pinned in Cloudflare's cache for a week, and the browserCache-Controlheader is now conditional onimageResponse.okfor both routes. The tile-bounds check gained a per-route zoom cap (22 for Mapbox, the previous 20 for Lantmäteriet). Client-side the change is a singleOVERLAY_SOURCESentry plus a dropdown<option>: the overlay is deliberately left out ofOVERLAY_WMT_ACTIVITY, sohandleExtraLayerChangetakes the non-Waymarkedtrails branch (no "Routes in view" legend, an isolated trail is preserved and re-lifted) exactly as the Strava heatmap does, and share links pick the new key up for free since&route=is validated againstOVERLAY_SOURCES. No service-worker or CSP change was needed —lm.clackspark.workers.devis already in both allowlists, so the new tiles join the offline tile cache automatically. -
v2.17.1: Fixed the GPX track being hidden underneath other map layers. The track's
gpx-line-0line layer was added without abeforeId, so it only sat on top of the style at the moment it was added — anything drawn afterwards covered it. The most visible case was the extra-overlay picker: selecting Waymarkedtrails or the Strava heatmap appended an opaque raster over the loaded track (applyExtraOverlay→L.tileOverlay(...).addTo(map)), but the same happened with the slope map (whose opacity slider re-added the image on every tick, since the Leaflet-compat shim's_renderOverlayremoves and re-adds a layer to re-render it), climb result / manual climb lines, the isolated trail (liftIsolatedTrailToTopexplicitly moved it to the very top) and the GPS accuracy circle. Rather than re-lifting the track from each of those call sites, the ordering is now enforced at the single choke point where overlays are inserted: a newgetGpxTopBeforeId()helper returnsGPX_LINE_LAYER_IDwhile a track is loaded (andundefinedotherwise), and all sixaddLayercalls in_renderOverlay— circle fill/line, circleMarker, polyline, image and tileOverlay — pass it asbeforeId, so every overlay slots underneath the track automatically, including on re-render.liftIsolatedTrailToTopnow moves its layers to directly below the track instead of to the absolute top (casing/line relative order is unchanged), andupdateGpxTrackLinere-raises the track withmoveLayeron its repaint path as a safety net. A newly loaded track is still appended on top, so it also wins when an overlay was enabled first. Basemap, hillshade and contours already used abeforeIdand are unaffected; GPX waypoint/start-end/min-max labels are DOM markers and were never subject to layer order. -
v2.17.0: Upgraded the map engine from MapLibre GL JS 5.24.0 to 6.2.0. v6 ships as ES modules only — the UMD bundle and the separate CSP build are gone — so the loading path changed: the vendored set under
vendor/is nowmaplibre-gl.mjs,maplibre-gl-shared.mjs(imported by both the main bundle and the worker) andmaplibre-gl-worker.mjs, all three precached by the service worker so the installed PWA still boots fully offline. Becausescript.jsis a classic script whose API lives in global scope (index.htmldrives it from inline handlers), a small first-party ES-module shim,maplibre-boot.mjs, imports the v6 namespace, pins the worker URL and republishes it as themaplibreglglobal; module scripts anddeferscripts share one execution queue, so it is guaranteed to run beforemaplibre-contour.min.jsandscript.js. It joinsstyle.css/script.js/lang/*.jsas the fifth?v=-stamped shell asset. The backend now registerstext/javascriptfor.mjsexplicitly (mimetypes.add_type, since the interpreter's table is version-dependent and the Windows registry can override it) and includes.mjsinSTATIC_ASSET_SUFFIXESso the modules get the same immutable cache header as.js. No CSP change was needed: a self-hosted, same-origin ESM build constructs its worker directly from the URL rather than laundering it through a blob, soworker-src 'self'remains valid. The rest of the app needed no changes — the v5→v6 CSS class names are identical (v6 only adds.maplibregl-marker-draggable), sostyle.cssand the mobile control-corner relocation are untouched; everyMap/ camera / style method and everyMapOptionskey the app passes survives;Evented.fire(string)still works for the syntheticzoomend/moveend; andaddProtocol's signature is unchanged, so maplibre-contour 0.1.0 keeps working as a UMD classic script. v6 also adoptszoomLevelsToOverscale: 4by default, slicing vector tiles instead of overscaling them — MapLibre reports this fixes a range of labelling issues, and the new default is taken as-is rather than pinned back to the v5 behaviour, so the contour overlay is the part of this release worth a visual pass. Separately fixed a latent bug this migration surfaced: the off-screen Print map map passedpreserveDrawingBuffer: trueas a top-level option, but MapLibre groups the WebGL context attributes undercanvasContextAttributes, so it had been silently ignored andgetCanvas()could read an empty buffer; it is now nested correctly. Note: v6 removes WebGL 1 support — the map now requires a WebGL2-capable browser. -
v2.16.0: Mobile usability around the route legend and the control panel. On phones (≤ 600 px), showing the route-names legend no longer hides the on-map GPS + zoom/compass controls —
updateZoomControlVisibility()now moves the two control groups into the bottom-left corner (GPS above the navigation group, same 10 px inset; MapLibre's own corner CSS provides the mirrored stacking) and moves them back when the legend is turned off or the viewport crosses the mobile breakpoint (re-checked from the sharedresizelistener, so device rotation is handled). While the controls occupy that corner the attribution banner is temporarily hidden; desktop behavior is unchanged (controls still hide under the legend, attribution untouched). Supporting fixes:updateMapSliderChrome()now hides only the attribution and slope legend via abody.map-sliders-onclass instead of hiding the whole bottom-left corner (so the relocated controls survive the on-map opacity/exaggeration sliders), andadjustMapControlsForElevation()raises the#map-slider-stackby the measured height of the occupied corner, so sliders, relocated controls and the elevation-profile bar stack without overlapping (the measurement tracks the compass auto-hiding while north-up, re-checked onrotateend). Also on mobile, tapping the minimized control panel now maximizes it — its own controls (Share, About, the minimize toggle and the tap-to-copy coordinates readout) keep their function — mirroring the elevation profile's expand-on-tap pattern. The Control Panel tutorial step now mentions both gestures (minimize by tapping the map outside the panel, maximize by tapping the minimized panel), in English and Swedish. -
v2.15.3: Contour elevation label density now follows the zoom level, like the Mapterhorn contour example. The
contour-labelssymbol layer's fixed major-only filter is replaced by a zoom-stepped filter (['step', ['zoom'], ['==', ['get', 'level'], 1], 14, true]): below native zoom 14 only major contours are labelled (as before), and from native zoom 14 — where the interval thresholds switch to 20 m / 40 ft minors — every contour line is labelled. Label spacing along each line is now zoom-interpolated viasymbol-spacing(500 px at native z11 → 250 px at z15), andtext-sizescales from 9 px to 11 px over the same range, so labels are sparse and unobtrusive when zoomed out and progressively denser when zoomed in. Works identically in metric and imperial since the layer is rebuilt on unit change. -
v2.15.2: Security hardening. Fixed a DOM cross-site scripting (XSS) hole where a waypoint
namefrom an uploaded or shared GPX file (?gpx=link) was rendered withinnerHTMLin the map label markers (rebuildGpxLayer); the six GPX label sinks (waypoint, start/end, min/max elevation) now usetextContent, so GPX-supplied text can no longer inject markup or scripts — closing a zero-click, cross-user account-takeover path (a malicious shared link could otherwise run script in the victim's origin and exfiltrate the Google ID token fromlocalStorage). Added HTTP security headers from the backend response middleware inmain.py: an enforcedContent-Security-Policy(frame-ancestors 'none',object-src 'none',base-uri 'self',form-action 'self') plus a Report-Only resource allowlist (mirroring the service-worker tile hosts) staged for enforcement once the app's inline event handlers are refactored, together withX-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin, andX-Frame-Options: DENYon HTML documents. The anonymous owner session cookie (elevf_owner) is now markedSecureby default (overridable withGPX_COOKIE_SECURE=falsefor plain-HTTP local development). The Google ID token is no longer written tolocalStorage— it is kept in memory only and returning users are re-authenticated silently via Google One Tap on load, so a future injection cannot read it from storage (any token an older build persisted is proactively cleared). On the backend: GPX uploads are parsed withdefusedxml(rejecting entity-expansion / XXE / DTD attacks); each owner has a file-count and total-byte quota (GPX_MAX_FILES_PER_OWNER/GPX_MAX_BYTES_PER_OWNER) so an anonymous cookie can't fill the disk;/api/uploadand/api/auth/loginare rate-limited per client IP; the diagnostic/api/auth/debugroute is disabled by default (enable withGPX_DEBUG_ENDPOINTS=true); and the reverse-proxy trust list is now configurable viaFORWARDED_ALLOW_IPSso--forwarded-allow-ipscan be narrowed from*to the proxy's address. -
v2.15.1: Reliability, performance and offline improvements to terrain analysis. The Scan / Climb / Slope buttons no longer get stuck disabled after a failed elevation-tile load (offline or no-data areas) —
updateCenterElevationre-enables them in afinally, a run-id guard prevents a slow older lookup from clobbering a newer result, and the three analysis entry points share ananalysisInProgresslock and always release throughfinishAnalysisRun(including on mobile and when the analysis throws). The on-thread scan loops are substantially faster: a newbuildRadiusLookupreplaces the per-pixel Mercator unproject + haversine radius check infindPeaks,calculateMaxClimband_renderSlopeMapwith precomputed per-row/per-column tables (mathematically identical results — zero inclusion mismatches, distances agree to ~1 nm),getClimbStepMeters()is read once per run instead of in the innermost loop, result coordinates/distances are computed only for the ranked winners, and analysis DEM tiles reuse the sharedloadElevationTileLRU cache. MapLibre GL 5.24.0 (JS + CSS) and maplibre-contour 0.1.0 are now vendored undervendor/(byte-identical to the unpkg copies) and precached by the service worker, so the installed PWA boots fully offline (previously it failed without unpkg); the unpkg preconnect hints were removed. Also removed dead code (_shadowUrl/ shadow icon options, the unusedgpxLayerand thegpx-line-{i}removal loop), extractedterrariumToMeters()to replace eight copy-pasted decode expressions, and corrected the stale Build fallback in the About modal. Separately fixed_renderSlopeMapso the slope cell size usesmetersPerPixelAtZoomrather thanmetersPerPixelAtZoom / 2, correcting computed slope values. -
v2.15.0: Added a third optional footer readout beneath Zoom: Coordinates, the WGS84 latitude/longitude of the map center/crosshair, formatted as decimal degrees (
lat, lngto 5 decimals). It is toggled by a new Show coordinates checkbox under Advanced settings (showCoords, persisted astopo_show_coords, default off) and honored byupdateUI()viaisCoordsShown(), reusing the same show/hide pattern as the Scale and Center-to-GPS readouts. Tapping the readout copieslat, lngto the clipboard through the existingcopyTextToClipboard()helper (robustexecCommandfallback + status confirmation). Newcoords_label/coords_copy_hintpluslbl_show_coords/tip_show_coords/status_coords_copiedstrings are localized in English and Swedish. -
v2.14.0: Added a Print map mode that exports the framed map area to a print-ready PDF. It is launched on desktop by clicking the app logo in the Control Panel header (the app title is not a trigger; not offered on mobile, where the modal has no room). A framing "window" is drawn over the live map (the area outside is shadowed out) so you can pan/zoom to frame the area, and a compact left-aligned settings panel offers A4 / A3 / A2, portrait / landscape, a coordinate-system selector (WGS 84 default, or SWEREF 99), and independent toggles for the scale ruler, map source, coordinates, north arrow and map border (border off by default). On Generate PDF, a dedicated off-screen MapLibre map is created with
preserveDrawingBuffer: true(the main map is not, so its WebGL canvas can't be read) by cloning the live style — capturing the base layer, hillshade, contours, route overlays and the GPX track line automatically at ~200 DPI; DOM markers (POI pins, analysis result pins, GPX labels/waypoints) are composited on top by projecting their coordinates onto the print canvas.jsPDF(vendored locally atvendor/jspdf.umd.min.jsand precached for offline use) assembles the page: the scale is drawn next to the scale ruler as1:X · <CRS>with the map source following it (all bottom-left, just below the map at the same tight gap as the coordinates); a north arrow (rendered from an SVG, rotated to true north) sits top-left inside the map and a small TopoScout.org stamp top-right; and corner coordinates print at the upper-left and lower-right corners just outside the map (compact font, tight gap), each with N (northing/latitude) horizontal and E (easting/longitude) vertical (rotated 90°), formatted per the chosen coordinate system (wgs84ToSweref99tm()Gauss-conformal conversion for the SWEREF grid).getPrintLayout()computes per-side margins from the enabled options, so disabling an annotation reclaims its margin for the map (all off ≈ full-page map), and the on-map framing window's aspect updates live as options change. Newprint_*strings are localized in English and Swedish. -
v2.13.1: Fixed an ordinary refresh sometimes loading an older build than the one deployed — in any browser, not just the installed PWA (desktop merely hid it behind Ctrl+F5). The service worker matched same-origin subresources with
ignoreSearch: true, which strips the?v=<build>stamp, soscript.js?v=<new>resolved to the cached query-less./script.jsof whatever cache generation controlled the page; a network-first (fresh)index.htmlwas stitched to a stale cachedscript.js, and since the About modal's build number lives inscript.js(BUILD_NUMBER), it read the old build. The four version-stamped shell assets (style.css,script.js,lang/en.js,lang/sv.js) are now precached under their?v=key (derived fromCACHE_NAME) and matched search-sensitively, so a new build's URL misses the old cache and falls through to the network while offline still hits;ignoreSearchis kept only for the navigation fallback (caches.match('./index.html', …), which may carry?app-refresh=). Install now precaches per-asset viaPromise.allSettledinstead of the all-or-nothingcache.addAll, so a single failed fetch can no longer abort the install and strand users on the previous worker. No backend change —main.pyalready serves and caches?v=URLs correctly. -
v2.13.0: Added two live readouts to the footer, stacked beneath the existing Zoom value. Scale shows the current map scale (e.g.
1:50 000):computeScaleDenominator()measures the ground distance across 100 CSS pixels at the map center (via twomap._map.unproject()points andhaversineDistance()), divides by the OGC standard pixel size (0.28 mm), andniceScaleDenominator()snaps the result to a readable round value formatted as1:Xwith space thousands separators. Center to GPS shows the straight-line distance from the live GPS fix to the map crosshair (center); a newlastGpsPositionis captured inupdateGpsMarker()and cleared instopGpsTracking(), andupdateUI()(already run on pan/zoom) reusesformatDistance()(whole metres below 1 km, then km) and hides the row whenever GPS tracking is off. Three Advanced settings checkboxes (showZoom/showScale/showCenterGps, persisted astopo_show_zoom/topo_show_scale/topo_show_center_gps; Zoom defaults on, Scale and Center to GPS default off) let each footer readout be shown or hidden individually;updateUI()honours them viaisZoomShown()/isScaleShown()/isCenterGpsShown(). Newscale_label/center_to_gps_labelplus thelbl_show_*/tip_show_*setting strings are localized in English and Swedish. -
v2.12.0: Added an in-app install path for iPhone & iPad. Because iOS/iPadOS Safari never fires
beforeinstallprompt, the Install as App button (and the bottom install bar) previously never appeared on Apple devices. A newisIOSInstallEligible()check now shows them on non-standalone iOS/iPadOS — including iPadOS that reports as desktopMacIntelwith touch points — and tapping either opens a new instructions modal (#ios-install-modal) with the manual Share → Add to Home Screen steps instead of the unavailable native prompt. The UI hides itself automatically once the app is already running standalone (navigator.standalone/display-mode: standalone), and the modal text is localized in English and Swedish. -
v2.11.0: The app now updates itself automatically — no more tapping Update. The service worker activates new builds immediately (
skipWaiting+clients.claim), and the page checks for a waiting update whenever it returns to the foreground (key for iOS home-screen PWAs) and periodically while it stays open; when an update applies it shows a brief "Updated to v{version}" note instead of an Update prompt. Static assets are served with explicit cache-control headers so refreshes reliably pick up the newest build. -
v2.10.0: Added an optional contour lines overlay, toggled by Enable contour line layer under Advanced settings (persisted as
topo_contours). Contours are generated client-side withmaplibre-contourfrom the same Mapterhorn terrarium DEM (tiles.mapterhorn.com,terrarium, maxzoom 15) the app already uses for terrain and hillshade — no extra backend or tile provider. Acontour-sourcevector source feeds a topographic-browncontour-lineslayer (thicker major contours, a low-zoom opacity fade) inserted directly above the basemap/hillshade but below every overlay, route and marker, so it reuses the same layer-ordering pattern as the hillshade. Elevation labels along the major contours are shown by a separatecontour-labelssymbol layer, toggled by Enable contour labels (persisted astopo_contour_labels, default on); rendering them required adding aglyphsfont source to the otherwise raster style, served from a self-hosted, same-origin glyph set bundled underfonts/(Noto Sans Regular, with Open Sans Regular also bundled to compare; precached by the service worker, so labels keep working offline with no third-party font CDN). The contour interval and the labels follow the global Metric/Imperial setting (metre intervals withmlabels, or feet intervals with'labels), regenerating viamap.refreshContours()when units change. The library is loaded from unpkg like MapLibre and degrades gracefully (the overlay simply no-ops) if it fails to load. -
v2.9.0: Added a global Metric/Imperial units setting in the About modal (a dropdown directly below the language selector, persisted as
topo_units, migrating the legacy per-routetopo_distance_uniton first load). Metric stays the canonical internal unit; a newgetUnitSystem()drivesgetDistanceUnit(),formatDistance(), and a newformatElevation(), whilegetRadiusMeters()/getClimbDistMeters()/getClimbStepMeters()convert the numeric inputs at the boundary. Switching to Imperial shows distances in mi/ft and all elevations in ft everywhere — live center elevation, peak/climb popups, GPX gain/loss/min-max and the min/max markers, and the elevation-profile axes + readout — and converts the Search Radius (mi), Measure Dist. (ft) and Climb Step Res. (ft) input fields and their labels (with unit-appropriate min/max/step). The old per-route Distance Unit (km/mi) dropdown is removed in favor of this single global control. Also polished in this release: result popups raise theirmaxWidthso long (4-digit) values size the box to fit instead of crowding the right padding, and the Add-routes-and-POIs checkboxes are arranged in a 2-column grid (3 per side). -
v2.8.2: Performance and fixes. The service worker now keeps a capped runtime cache (
toposcout-tiles-v1, ~400 tiles, stale-while-revalidate) for cross-origin map/elevation tiles, so revisited areas render instantly and the map keeps working offline; the cache is version-independent and preserved across releases (theactivatecleanup keeps both the shell cache and the tile cache). The render-blocking<script>tags (MapLibre, language files,script.js) are nowdeferred withpreconnect/dns-prefetchhints for the library CDN and the elevation-tile host, the per-framemap.on('move')UI work isrequestAnimationFrame-throttled, and the center/POI elevation lookups now share an LRU tile cache (loadElevationTile, ~64 tiles) instead of refetching a tile per call. Fixes: the popup copy-coordinates tooltip uses the active language instead of hardcoded Swedish, peak/climb popup distances honor the km/mi unit picker (via a sharedformatDistancehelper), the viewport meta no longer disables pinch-zoom (WCAG 1.4.4), the slope filter max is capped at 90°, and the[GPX auth]debug console logging (and its/api/auth/debugprobe) was removed. -
v2.8.1: Added a Max tilt angle slider to Advanced settings (0–85°, persisted as
topo_max_pitch, default 60°). It sets the map'smaxPitchso manual pitch gestures can go beyond MapLibre's default 60° cap (up to its 85° hard limit), and the Tilt and 3D buttons now ease to the chosen angle instead of a fixed 60°. While 3D is enabled, dragging the slider re-tilts the view live; the value is clamped to MapLibre's 0–85° range. -
v2.8.0: Added an optional hillshade relief layer. A Hillshade toggle button in the search bar (replacing the redundant GPS button there — GPS stays available via the on-map control) enables a MapLibre
hillshadelayer rendered from the existing Mapterhornraster-demsource (elevation-dem), inserted directly above the basemap and below every overlay and marker, so route overlays, climbs, GPX tracks, and POI/GPS markers are unaffected and the basemap stays beneath it across layer switches. An optional on-map opacity slider — shown by Enable Hillshade opacity slider under Advanced settings — adjusts the relief strength viahillshade-exaggeration(0–100%) live. The on/off state (topo_hillshade), slider visibility (topo_hillshade_slider), and strength (topo_hillshade_opacity) persist inlocalStorage, and the layer reuses the shared DEM source so 3D terrain keeps working alongside it. The 3D terrain exaggeration is now adjusted with the same kind of on-map slider (enabled via Enable 3D exaggeration slider, persisted astopo_3d_exaggeration), and the Advanced settings are sorted alphabetically. -
v2.7.4: The UI now defaults to Swedish automatically when the browser/device language is Swedish (detected from
navigator.languages/navigator.language). Detection re-runs on every visit until the user picks a language manually from the menu, which sets atopo_lang_chosenflag inlocalStoragethat pins their choice. An explicit?lang=URL parameter still takes precedence over both. -
v2.7.3: Added a dynamic accuracy ring around the live GPS marker. The shaded blue ring is sized to the reported margin of error (
pos.coords.accuracy): it shrinks as the fix tightens and disappears entirely for a pinpoint fix (accuracy of 5 m or better). The rendered radius is capped at 1 km so a coarse "Approximate Location" fix doesn't swamp the map. The ring reuses the existing meter-radius circle primitive and is removed when GPS tracking is toggled off. -
v2.7.2: Points of Interest now persist on your device. The most recently synced POIs are cached in
localStorage, so their pins stay visible on the map after you sign out of Google or reload the page. Signing in re-syncs and overwrites the cache; creating, editing, moving, and deleting POIs still require a signed-in Google account through the backend (/api/pois). Also fixed the copy-coordinates button in popups, whose clipboard icon had been corrupted into stray text. -
v2.7.1: Made the in-app Refresh app button and automatic updates refresh reliably on mobile browsers and the home-screen (PWA) app. The service worker now caches updated files with
cache: 'reload'so a new release never re-caches stale copies from the browser HTTP cache, and the local scripts/styles are version-stamped (?v=) so a refresh can no longer be served stale assets. The service worker matches requests withignoreSearchso the stamped URLs still resolve to their cached entries (offline still works). -
v2.7.0: Added saved Points of Interest (POIs). Sign in with Google, then tap the map to drop a colored star pin and give it a name, a description (URLs become clickable links), and a color. POI pins show the point's elevation and a copy-coordinates button, and can be opened (recenters the map), moved, edited, or deleted. POIs are stored per Google account through the optional backend (
/api/pois) and load automatically on every device while you're signed in. -
v2.6.2: Renamed the app to TopoScout.
-
v2.6.1: Made the "new version available" update prompt far more reliable for the iOS home-screen (PWA) app. The app now re-checks for updates when it's reopened or brought back to the foreground (not only on a cold start), surfaces an update that finished downloading in a previous session (previously it could sit unprompted until the browser's automatic ~24h check), and registers the service worker with
updateViaCache: 'none'so the worker script is always fetched fresh. Also removed a stray reload on first launch and hardened the worker's message handler. -
v2.6: Added a Strava Global Heatmap to the Route Overlay dropdown. Tiles are served privately through the optional backend (
/api/heatmap/...). -
v2.5.1: Moved the language switcher from the header into the About menu as a Select Language dropdown and removed the flag icons. Placed the Install as App button beside Refresh app, and put the GitHub Project and droidgren.github.io links on one row.
-
v2.5.0: The GPS button now toggles live positioning: it drops a moving marker that follows you in real time (tap again to stop). Added a center crosshair you can show/hide, with a selectable high-contrast color (Dark, White, Magenta, Cyan, Yellow, Red, Lime) under Advanced settings. The center dot now shows only when the search radius is locked, so it no longer overlaps the crosshair.
-
v2.4.0: Added a Download GPX button (next to Clear Route) that saves the currently loaded route back to a
.gpxfile, and a Rename action for uploaded routes in the GPX upload history (renames the file on the optional backend). Also unified some secondary button colors. -
v2.3.0: Redesigned the control icons: replaced all emoji and glyph icons with a crisp, consistent inline SVG icon set that highlights on hover, refreshed the Sweden/UK language flags, switched the collapsible sections and panel toggle to + / − icons, and gave the 3D toggle a clear active state.
-
v2.2.0: Added an elevation profile bar for loaded GPX routes (hover/drag to scrub, scroll to zoom, arrow keys to step, with an optional "Sync Map with Profile" marker), and an optional FastAPI backend for uploading, listing, and sharing GPX routes by link. The frontend auto-detects the backend and stays fully functional on static hosting when none is present.
-
v2.1.2: Misc GUI fixes: added an Advanced settings section and a 3D-terrain toggle button next to search, simplified the route overlay to a single dropdown (route names always shown, legend collapsed by default), and refined the panel layout, dropdowns, and tutorial.
-
v2.1.1: Route-names legend now shows each route's symbol with a manual refresh button, and you can click a route to show only that trail ("Show all" to restore). Plus compass-placement, tutorial, and Find Climbs refinements.
-
v2.1: Added a Waymarkedtrails route overlay (hiking, cycling, MTB, skating) and an optional route-names legend that lists the routes in the current view with their official route symbols.
-
v2.0.2: Reworked the analysis section accordion so only one section stays open at a time, moved the Search Radius / Show Radius / Lock Radius controls into the active analysis section, and auto-enabled Show Radius when opening analysis sections.
-
v2.0.1: Added Manual mode tutorial guidance (including a spotlight step), explained the difference between automatic and manual climb modes in the tutorial, and fixed manual-route ascent smoothing for multi-point routes.
-
v2.0: Migrated frontend map rendering to MapLibre GL JS and added overzoom, tilt, 3D terrain, and shareable map views.
-
v1.8.2: Added Norgeskart (Norway) map layer.
-
v1.8.1: Added map rotation with
Ctrl+ drag and two-finger touch support, plus a compass indicator with reset-north button. -
v1.8: Added GPX file upload with route overlay, track styling, distance labels, slope coloring, waypoints, and elevation stats.
-
v1.7: Added the Slope Map feature to color-code terrain by steepness, with filter and opacity controls.
-
v1.6: Added an interactive tutorial, reordered tutorial steps, and added the GitHub Project link in the info modal.
-
v1.5: Added a PWA install button in the info modal and a mobile install prompt bar.
-
v1.4: Improved Find Climbs accuracy with cumulative ascent, noise filtering, and higher scan resolution. Added detailed climb stats and new debug settings.
-
v1.3: Made the app installable, added custom numbered map pins, improved touch UI for number inputs, and fixed alignment on high-resolution screens.
-
v1.2.1: Fixed incorrect results at zoom level 15+ and added toggleable water analysis in debug settings.
-
v1.2: Migrated elevation tiles to Mapterhorn with 512 px terrain tiles.
-
v1.1: Added Find Climbs, the Lantmateriet map layer, and multilingual support.
-
v1.0: Initial release.
TopoScout is client-side by default.
- No location data is sent to the creator's server.
- No search history is stored on a backend.
- API keys are only stored locally in the browser and sent directly to the relevant map provider when used.
- The optional backend only stores the GPX files you explicitly upload, and only on the server you choose to run. The public live demo and static hosting run without it.
I'd love to hear from you — feedback helps shape where TopoScout goes next.
- Ideas, feature requests, and general feedback: start a thread in GitHub Discussions.
- Bug reports: open an issue on GitHub Issues.
Created by droidgren.github.io.
Libraries, services, and data sources used by the project include:
- MapLibre GL JS
- OpenTopoMap
- OpenStreetMap and Nominatim
- Esri World Imagery
- Lantmateriet
- Kartverket / Norgeskart
- ThunderForest
- Tracestrack
- Mapterhorn
- maplibre-contour (client-side contour generation)
- Noto Sans and Open Sans (SIL OFL 1.1 / Apache License 2.0) — bundled glyphs for contour labels
This project is open source. See the repository for the applicable license and distribution terms.