TopoScout is a browser-based terrain analysis tool for finding high points, comparing climbs, visualizing slope, and overlaying GPX routes 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.
- 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.
The built-in GPX overlay lets you add route context while inspecting the terrain.
- 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). - 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.
- 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.
- Expand Add Routes and POIs.
- Load a GPX file, or sign in and tap Add POI to drop a saved Point of Interest.
- Tune track styling and visibility options, and toggle pins with Show POIs.
- Compare routes and POIs against peak, climb, and slope results 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" toposcout- 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 backendgpx-files/- uploaded GPX storage (created at runtime; git-ignored)
- 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.