Compare commits
27
Commits
3f9c46e846
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efddc55ad0 | ||
|
|
33be95ef59 | ||
|
|
ede21454a2 | ||
|
|
9c80a20f3e | ||
|
|
43785a074b | ||
|
|
b0c93d10d7 | ||
|
|
3106f8c103 | ||
|
|
8eada07869 | ||
|
|
5d86cb1d04 | ||
|
|
b7618edc25 | ||
|
|
bd46b72144 | ||
|
|
6126b5055b | ||
|
|
9edc2d9534 | ||
|
|
eada464fb3 | ||
|
|
ca7772dd72 | ||
|
|
4c95927231 | ||
|
|
c6e4a455b6 | ||
|
|
079ac37787 | ||
|
|
b35ace9734 | ||
|
|
714795d52b | ||
|
|
cc3d8ca5f3 | ||
|
|
6f1577f957 | ||
|
|
9c45e7275c | ||
|
|
c894706140 | ||
|
|
21988c61da | ||
|
|
f18e5c19d3 | ||
|
|
237e5b2e54 |
No files matched your search
@@ -6,7 +6,14 @@ on:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
# brick-build's job container now points at Gitea's own official
|
||||
# runner image (docker.gitea.com/runner-images:ubuntu-latest, has
|
||||
# Node out of the box) — the earlier opensuse/leap:16.0 image that
|
||||
# failed here on 2026-09-12 (no Node, every step below is a JS-based
|
||||
# Action) got repointed. Pinned to brick specifically again, rather
|
||||
# than the shared ubuntu-latest label "Gitea Runner on Cube" also
|
||||
# answers to.
|
||||
runs-on: brick-build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -8,17 +8,25 @@ on:
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
# The native deploy runner (see docs/deployment.adoc) already has
|
||||
# Docker access — Renovate itself is a one-shot batch job, not a
|
||||
# long-running service, so `docker run`, not docker-compose.
|
||||
runs-on: deploy
|
||||
# Same reasoning as check's runs-on in ci.yml: brick-build's job
|
||||
# container now has Node (repointed at Gitea's own official runner
|
||||
# image), which setup-node/npm below need.
|
||||
runs-on: brick-build
|
||||
steps:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install Renovate
|
||||
run: npm install -g renovate
|
||||
|
||||
- name: Run Renovate
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e RENOVATE_PLATFORM=gitea \
|
||||
-e RENOVATE_ENDPOINT="${{ gitea.server_url }}/api/v1/" \
|
||||
-e RENOVATE_TOKEN="${{ secrets.RENOVATE_TOKEN }}" \
|
||||
-e RENOVATE_REPOSITORIES="${{ gitea.repository }}" \
|
||||
-e RENOVATE_ONBOARDING=false \
|
||||
renovate/renovate
|
||||
run: renovate
|
||||
env:
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: "${{ gitea.server_url }}/api/v1/"
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
RENOVATE_REPOSITORIES: "${{ gitea.repository }}"
|
||||
RENOVATE_ONBOARDING: "false"
|
||||
GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }}
|
||||
@@ -24,6 +24,22 @@ namespace :overpass do
|
||||
end
|
||||
end
|
||||
|
||||
namespace :hiking do
|
||||
desc "Extract route=hiking relations (name/colour/network/operator, full geometry) from a local " \
|
||||
".osm.pbf (e.g. one of PmtilesBuilder's own data/geofabrik_cache/*.osm.pbf) and write GeoJSON. " \
|
||||
"Prototype — see lib/maptool/hiking_trails.rb. Usage: rake hiking:trails[input.osm.pbf,out.geojson]"
|
||||
task :trails, %i[pbf out] do |_t, args|
|
||||
%i[pbf out].each { |key| abort("usage: rake hiking:trails[input.osm.pbf,out.geojson]") unless args[key] }
|
||||
|
||||
require_relative "lib/maptool/hiking_trails"
|
||||
|
||||
puts "Extracting hiking routes from #{args[:pbf]}..."
|
||||
geojson = Maptool::HikingTrails.extract(args[:pbf])
|
||||
File.write(args[:out], JSON.generate(geojson))
|
||||
puts "Wrote #{geojson["features"].size} route(s) to #{args[:out]}"
|
||||
end
|
||||
end
|
||||
|
||||
namespace :gpx do
|
||||
desc "Backfill properties.id on Note-category features saved before edit/delete (and its id targeting) existed"
|
||||
task :backfill_note_ids do
|
||||
|
||||
@@ -12,6 +12,8 @@ independence from connectivity.
|
||||
* [x] Shaded relief / DEM layer — hillshade toggle in the sidebar, sourced from public Terrarium tiles online; reads `data/terrain.pmtiles` for offline if one's been generated (see docs/development.adoc), otherwise the toggle is online-only for now
|
||||
** [ ] Terrain slope readout for parking (numeric, not just visual shading)
|
||||
* [ ] Contour lines from OSM for hikes and mountain passes
|
||||
* [x] Hiking trail overlay — Waymarked Trails raster tiles online (`public/js/hiking_trails.js`), colored per-route by the OSM `colour` tag they already render (the KST/KČT paint-blaze colors in Slovakia/Czechia, plain red elsewhere). Click queries a one-off Overpass `around` search (racing 3 public mirrors) for the route's name/colour/network/operator/distance
|
||||
** [x] Offline copy wired in for real, no custom Planetiler profile needed — `Maptool::HikingTrails` (`lib/maptool/hiking_trails.rb`) runs `osmium tags-filter r/route=hiking` + `add-locations-to-ways` + `cat -f opl` against the *same* per-country Geofabrik `.osm.pbf` already downloaded for `offline.pmtiles`, no new download. One `data/hiking_trails/<CODE>.geojson` per country (not one combined file), generated as the last step of every offline build and cached like `geofabrik_cache` (skipped once already newer than its source PBF) — a many-country selection no longer means one giant file, since `GET /api/hiking-trails` lists available countries and the Overlays panel's own checklist only fetches `GET /tiles/hiking_trails/<CODE>.geojson` for whichever ones get ticked. Offline click uses a local `queryRenderedFeatures` hit-test instead of Overpass — instant, no network. See docs/development.adoc
|
||||
* [x] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs) — `rake overpass:pois`, see docs/development.adoc; writes a `.geojson` to drop in `data/gpx/` or upload. Also runnable from inside the app itself now, see the import plugins entry below (`plugins/overpass-pois` wraps this same query). Each category now carries a real `POI_ICONS` glyph and color (not p4n2gpx's plain red marker) plus a popup description built from opening_hours/operator/website tags
|
||||
* [x] Import plugins: a pluggable way to add more data sources (Park4Night, iOverlander, Freecampsites, ...) without their scrapers necessarily living in this (public) repo — sidebar icon (logged in only) lists whatever's under `plugins/`, "Run for this area" uses the current map viewport as the bbox, no separate area-picker needed. Contract: a `plugin.json` manifest + a script that reads `--south`/`--west`/`--north`/`--east` and prints a GeoJSON `FeatureCollection` to stdout (`lib/maptool/plugins.rb`, `GET /api/plugins`, `POST /api/plugins/:name/run`) — output saved the same way an upload would be. `plugins/overpass-pois` ships as the reference example; a private/ToS-sensitive scraper just drops its own directory in locally and is never `git add`ed
|
||||
** [x] Park4Night — private plugin (`data/plugins/p4n2gpx/`, outside this repo per the "ToS-sensitive scraper" note above); iOverlander/Freecampsites still open, same story
|
||||
@@ -71,3 +73,12 @@ Deprioritized for now — revisit after the sections above land.
|
||||
* [ ] Level gauge from IMU (ESP32/RPi) — camper tilt, wheel-chock leveling calculation
|
||||
* [ ] Victron/Home Assistant via MQTT or WebSocket — battery SoC, tank levels, temperatures
|
||||
* [ ] Plot GPS track with historical battery/speed data
|
||||
|
||||
== Admin / ops tooling
|
||||
|
||||
* [ ] Offline build log readability — a failed pmtiles/terrain build currently surfaces as one
|
||||
~500k-character line; format it for the web UI instead (e.g. per-country/state name + status,
|
||||
not the raw combined stdout/stderr dump)
|
||||
* [ ] Expose the build log via an API endpoint, not just embedded in the admin page
|
||||
* [ ] Document all API endpoints somewhere and make them easy to browse (status of each) —
|
||||
maybe a status page, login-gated
|
||||
@@ -15,6 +15,40 @@ require_relative "lib/maptool/plugins"
|
||||
require_relative "lib/maptool/pmtiles_builder"
|
||||
|
||||
module Maptool
|
||||
# Only /login, /auth/callback and /logout ever write to session
|
||||
# (oauth_state, user, session.clear) — everything else only ever reads
|
||||
# it, if it touches session at all. That still matters:
|
||||
# Rack::Session::Cookie re-serializes and re-sets the cookie on *every*
|
||||
# response that so much as read session (Rack::Protection's own CSRF
|
||||
# check does, on nearly every request) — reflecting whatever cookie
|
||||
# that particular request happened to arrive with, whether or not
|
||||
# anything changed. A HAR capture caught the actual failure mode this
|
||||
# caused: seconds after a successful login, a background poll fired the
|
||||
# instant the page loads was still carrying the browser's pre-login
|
||||
# cookie (a client-side timing race across the OAuth redirect chain,
|
||||
# not something this server controls), and its own re-issued Set-Cookie
|
||||
# landed *after* the login's, silently overwriting session[:user] with
|
||||
# nothing logged anywhere to explain it.
|
||||
#
|
||||
# A plain Rack middleware, not a Sinatra `before` filter: static assets
|
||||
# (public_folder — every JS/CSS/font/vendor file) are served by
|
||||
# Sinatra's own built-in static-file shortcut, which bypasses route and
|
||||
# filter dispatch entirely but is still wrapped by this, same as every
|
||||
# other response — each one was independently capable of winning the
|
||||
# clobbering race a `before` filter alone left uncovered.
|
||||
class SkipSessionWrite
|
||||
WRITE_PATHS = %w[/login /auth/callback /logout].freeze
|
||||
|
||||
def initialize(app)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
env["rack.session.options"][:skip] = true unless WRITE_PATHS.include?(env["PATH_INFO"])
|
||||
@app.call(env)
|
||||
end
|
||||
end
|
||||
|
||||
class App < Sinatra::Base
|
||||
set :public_folder, File.join(__dir__, "public")
|
||||
set :views, File.join(__dir__, "views")
|
||||
@@ -33,6 +67,7 @@ module Maptool
|
||||
# don't survive a restart there, which is fine outside production.
|
||||
set :sessions, httponly: true, secure: production?, same_site: :lax,
|
||||
secret: ENV.fetch("SESSION_SECRET") { SecureRandom.hex(32) }
|
||||
use SkipSessionWrite
|
||||
|
||||
# Cache-busts /js and /css requests: browsers (and a caching proxy in
|
||||
# front of production, see docs/deployment.adoc) hang onto static
|
||||
@@ -356,6 +391,34 @@ module Maptool
|
||||
json ok: true
|
||||
end
|
||||
|
||||
# Generated as a step inside POST /api/offline-tiles/build (see
|
||||
# PmtilesBuilder.extract_hiking_trails) rather than its own separate
|
||||
# build — it's derived from the exact same per-country .osm.pbf cache
|
||||
# offline.pmtiles itself already downloads, so there's nothing of its
|
||||
# own to trigger; DELETE /api/offline-tiles removes it too. One file
|
||||
# per country (not one combined file) so a viewer only ever fetches
|
||||
# the country they actually pick in the Hiking trails overlay's own
|
||||
# checklist (public/js/hiking_trails.js), not every selected country's
|
||||
# trails at once — `countries` lists which ones exist to build that
|
||||
# checklist from.
|
||||
get "/api/hiking-trails" do
|
||||
json countries: PmtilesBuilder.hiking_trails_countries(DATA_DIR)
|
||||
end
|
||||
|
||||
# Fetched client-side only for a country the viewer actually ticks in
|
||||
# the Hiking trails checklist, not on page load. :code is constrained
|
||||
# to exactly what hiking_trails_countries can ever return (two
|
||||
# uppercase letters) — otherwise this would accept an arbitrary
|
||||
# ".."-laden path segment.
|
||||
get "/tiles/hiking_trails/:code.geojson" do
|
||||
halt 404 unless params[:code].match?(/\A[A-Z]{2}\z/)
|
||||
|
||||
path = PmtilesBuilder.hiking_trails_country_path(DATA_DIR, params[:code])
|
||||
halt 404 unless File.exist?(path)
|
||||
|
||||
send_file path, type: "application/geo+json"
|
||||
end
|
||||
|
||||
# Pinged once per page load (public/js/map.js) — url is a same-origin
|
||||
# path (not kiwix-serve's own address, which the browser can't reach
|
||||
# directly behind the reverse proxy in front of this app), proxied by
|
||||
@@ -680,10 +743,8 @@ module Maptool
|
||||
halt 502, json(error: "track has no positions") if coordinates.empty?
|
||||
last = points.last
|
||||
|
||||
json(
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{ type: "Feature", geometry: { type: "LineString", coordinates: coordinates }, properties: {} },
|
||||
features = [
|
||||
{ type: "Feature", geometry: { type: "LineString", coordinates: coordinates }, properties: { "kind" => "actual" } },
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: coordinates.last },
|
||||
@@ -697,7 +758,21 @@ module Maptool
|
||||
),
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
# Only present when the activity was started from a saved Garmin
|
||||
# course (see LiveTrackClient#fetch) — plotted separately from the
|
||||
# actual track ("kind" distinguishes the two LineStrings) since it's
|
||||
# the planned route, not where the person has actually been.
|
||||
course_coordinates = result.course&.[]("geoPoints")&.filter_map { |p| p.dig("position", "lon") && p.dig("position", "lat") && [p["position"]["lon"], p["position"]["lat"]] }
|
||||
if course_coordinates&.any?
|
||||
features << {
|
||||
type: "Feature",
|
||||
geometry: { type: "LineString", coordinates: course_coordinates },
|
||||
properties: { "kind" => "planned", "name" => result.course["name"] },
|
||||
}
|
||||
end
|
||||
|
||||
json(type: "FeatureCollection", features: features)
|
||||
end
|
||||
|
||||
get "/api/gnss/position" do
|
||||
|
||||
@@ -125,3 +125,7 @@ services:
|
||||
volumes:
|
||||
# :Z, same SELinux reasoning as maptool's own ./data mount above.
|
||||
- ./data/kiwix/wikipedia.zim:/data/wikipedia.zim:Z
|
||||
|
||||
networks:
|
||||
default:
|
||||
enable_ipv6: true
|
||||
+22
-7
@@ -7,9 +7,19 @@ MapTool deploys as a Podman container (via `podman compose`, driven by a
|
||||
the Gitea Actions runners run on. Two separate runners are registered
|
||||
against the Gitea instance:
|
||||
|
||||
* The existing Docker-based runner (`gitea/runner:3`), labels
|
||||
`ubuntu-latest`/`ubuntu-24.04`/`ubuntu-22.04` — runs the `check` job
|
||||
in a fresh, isolated container per job, same as before.
|
||||
* The existing Docker-based runner (`gitea/runner:3`), label
|
||||
`brick-build` — runs the `check` and `renovate` jobs in a fresh,
|
||||
isolated container per job. Every label on this runner (including
|
||||
`ubuntu-latest`/`ubuntu-24.04`/`ubuntu-22.04`, still registered but no
|
||||
longer what `check`/`renovate` pin to) now maps to Gitea's own
|
||||
official runner image (`docker.gitea.com/runner-images:ubuntu-latest`,
|
||||
has Node out of the box) — an earlier `opensuse/leap:16.0` mapping for
|
||||
`brick-build` specifically had no Node, which every step in
|
||||
`check`/`renovate` needs (they're JS-based Actions), and briefly broke
|
||||
both jobs (2026-09-12) until reverted to the shared `ubuntu-latest`
|
||||
label; now that the image itself is fixed, both are pinned back to
|
||||
`brick-build` — this machine specifically, not whichever runner
|
||||
happens to answer to the shared label (e.g. "Gitea Runner on Cube").
|
||||
* A dedicated native runner, installed directly on the host (no Docker
|
||||
around it), label `brick-host` — runs only the `deploy` job.
|
||||
|
||||
@@ -202,7 +212,7 @@ const PROCESSED_LABEL = "livetrack-processed";
|
||||
|
||||
function checkLiveTrackEmail() {
|
||||
const label = GmailApp.getUserLabelByName(PROCESSED_LABEL) || GmailApp.createLabel(PROCESSED_LABEL);
|
||||
const threads = GmailApp.search(`from:noreply@garmin.com subject:LiveTrack -label:${PROCESSED_LABEL}`);
|
||||
const threads = GmailApp.search(`from:noreply@garmin.com -label:${PROCESSED_LABEL}`);
|
||||
|
||||
threads.forEach(thread => {
|
||||
// getBody() (raw HTML), not getPlainBody(): the email has no text/plain
|
||||
@@ -225,9 +235,14 @@ function checkLiveTrackEmail() {
|
||||
|
||||
The Gmail label (rather than marking the message read/unread) tracks which
|
||||
threads are already handled without touching the user's own inbox state.
|
||||
`subject:LiveTrack` matches regardless of the account's language — Garmin
|
||||
keeps "LiveTrack" as-is in the subject even in a localized
|
||||
("Sledujte LiveTrack používateľa ...") one.
|
||||
No `subject:LiveTrack` filter on the search — despite Gmail's own
|
||||
substring-match documentation for `subject:`, that filter empirically
|
||||
returned zero threads against a real, currently-arriving LiveTrack email
|
||||
whose subject does contain the word ("Sledujte LiveTrack používateľa
|
||||
...", confirmed 2026-09-20), so it's left out entirely — the regex match
|
||||
below is what actually narrows this down to real LiveTrack emails with a
|
||||
usable session link, `from:noreply@garmin.com` alone is deliberately
|
||||
broad.
|
||||
|
||||
Set the trigger from the editor: clock icon on the left (Triggers) → *Add
|
||||
Trigger* → function `checkLiveTrackEmail`, time-driven, "Minutes timer",
|
||||
|
||||
+75
-2
@@ -71,18 +71,91 @@ and similar, varying by region):
|
||||
|
||||
[source,shell]
|
||||
----
|
||||
pmtiles extract https://download.mapterhorn.com/planet.pmtiles planet-extract.pmtiles --bbox=west,south,east,north --maxzoom=15
|
||||
pmtiles extract https://download.mapterhorn.com/planet.pmtiles planet-extract.pmtiles --bbox=west,south,east,north --maxzoom=13 --overfetch=0
|
||||
# only if a regional chunk covers the bbox too (see download_urls.json) —
|
||||
# most bboxes hit exactly one, a bbox straddling a chunk boundary hits two:
|
||||
pmtiles extract https://download.mapterhorn.com/<chunk>.pmtiles chunk-extract.pmtiles --bbox=west,south,east,north --maxzoom=15
|
||||
pmtiles extract https://download.mapterhorn.com/<chunk>.pmtiles chunk-extract.pmtiles --bbox=west,south,east,north --maxzoom=13 --overfetch=0
|
||||
pmtiles merge planet-extract.pmtiles chunk-extract.pmtiles data/terrain.pmtiles
|
||||
----
|
||||
|
||||
`--overfetch=0`: the default (0.05) merges nearby byte ranges into fewer,
|
||||
bigger requests to save round-trips — for `planet.pmtiles` (global z0-12,
|
||||
tens of GB) over a bbox spanning several countries this can collapse the
|
||||
whole extract into a *single* multi-GB request with no way to resume after
|
||||
a mid-transfer HTTP/2 stream reset. `--overfetch=0` trades that one
|
||||
fragile mega-request for many small ones instead (spread over
|
||||
`--download-threads`, default 4) — slower end to end, but a dropped
|
||||
connection only loses one small request's worth of progress, not the
|
||||
whole extract.
|
||||
|
||||
`--maxzoom=13`: raster DEM tiles don't compress away like vector ones do
|
||||
(every tile is a real image, even over flat/empty terrain), so each extra
|
||||
zoom level roughly quadruples storage — the app's own build
|
||||
(`PmtilesBuilder::TERRAIN_MAX_ZOOM`) caps at 13, each regional chunk's
|
||||
own native minzoom, rather than the online hillshade layer's z15. A
|
||||
12-country selection at z15 was headed well past 181GB; z13 is still
|
||||
plenty for the hillshade toggle and the planned slope readout.
|
||||
|
||||
Tiles are 512px WebP, not the 256px PNG this app's *online* Terrarium
|
||||
source uses — `public/js/dem.js`'s own point-elevation lookups
|
||||
(`shadow.js`'s terrain-shadow overlay) read the actual decoded tile
|
||||
size rather than assuming one, so this isn't a problem either way.
|
||||
|
||||
== Offline hiking trails
|
||||
|
||||
The sidebar's "Hiking trails" overlay works online out of the box
|
||||
(waymarkedtrails.org raster tiles, `public/js/hiking_trails.js`) —
|
||||
clicking a trail fires a one-off Overpass query for its name/colour/
|
||||
network/distance. For it to work offline too, one `data/hiking_trails/
|
||||
<CODE>.geojson` per country is generated automatically as the last step
|
||||
of every `offline.pmtiles` build (`Maptool::HikingTrails`,
|
||||
`lib/maptool/hiking_trails.rb`), straight from the *same* per-country
|
||||
`.osm.pbf` files already downloaded for `offline.pmtiles` itself — no
|
||||
separate download, no third-party service:
|
||||
|
||||
[source,shell]
|
||||
----
|
||||
osmium tags-filter country.osm.pbf r/route=hiking -o hiking.osm.pbf
|
||||
osmium add-locations-to-ways hiking.osm.pbf -o hiking-located.osm.pbf
|
||||
osmium cat hiking-located.osm.pbf -f opl,locations_on_ways=true,add_metadata=false -o hiking.opl
|
||||
----
|
||||
|
||||
`add-locations-to-ways` matters: a plain Geofabrik PBF's ways only carry
|
||||
bare node *references*, not coordinates, so skipping this step silently
|
||||
produces ways with empty x/y in the OPL output and every route gets
|
||||
dropped for having no usable geometry — not an error, just wrong output
|
||||
(verified against a real extract before this was caught). No general OSM
|
||||
tool stitches a route relation's member ways into one line for you
|
||||
(routes are a semantic grouping, not a geometry type), so
|
||||
`HikingTrails.parse_opl` does that walk itself — the same thing
|
||||
waymarkedtrails.org and this app's own online Overpass click handler
|
||||
already do.
|
||||
|
||||
Best-effort per country: an extraction problem on one country's PBF logs
|
||||
a warning and is skipped rather than failing the whole `offline.pmtiles`
|
||||
build, or even the other countries in the same selection, over what's a
|
||||
bonus overlay, not the core map (`PmtilesBuilder.extract_hiking_trails`).
|
||||
Cached the same way `geofabrik_cache` is (skipped once a country's own
|
||||
extract is already newer than its source `.osm.pbf`), so growing the
|
||||
region selection by one country only extracts that one. Coordinates are
|
||||
rounded to 5 decimal places (~1m, plenty for a rendered line) — Slovakia
|
||||
alone is ~3000 routes, ~30MB unrounded.
|
||||
|
||||
One file per country rather than one combined file: `GET /api/hiking-trails`
|
||||
lists which country codes have an extract (`countries: [...]`), and the
|
||||
Overlays panel's "Hiking trails" item turns that into a checklist —
|
||||
each ticked country fetches its own `GET /tiles/hiking_trails/<CODE>.geojson`
|
||||
only then, not on page load and not for countries never ticked. Offline
|
||||
click hit-tests the loaded vector layer(s) directly
|
||||
(`queryRenderedFeatures`) instead of querying Overpass, so it resolves
|
||||
instantly with no network at all. `rake hiking:trails[pbf,out]` runs the
|
||||
extraction by hand against one `.osm.pbf` for testing.
|
||||
|
||||
`DELETE /api/offline-tiles` removes the whole `data/hiking_trails/`
|
||||
directory along with `offline.pmtiles` and the Geofabrik cache — there's
|
||||
no separate delete/rebuild route for it, since it's not an independent
|
||||
build.
|
||||
|
||||
== In-app regeneration
|
||||
|
||||
A logged-in user can also generate both files above from the app
|
||||
|
||||
@@ -286,6 +286,50 @@ own work (a map viewer, not a publisher of NPS-branded content):
|
||||
resource — included here only because it was shared alongside the
|
||||
typography notes above.
|
||||
|
||||
== Arrowhead (shield) logo colors
|
||||
|
||||
Unrelated to anything this app renders — noted here only because it came up
|
||||
in conversation (wanting exact colors to 3D-print the NPS arrowhead) and
|
||||
this doc is already the dumping ground for NPS-ecosystem color/graphic-identity
|
||||
facts. Per NPS's own graphic identity standards (Harpers Ferry Center), the
|
||||
arrowhead uses three official Pantone spot colors plus white:
|
||||
|
||||
[cols="2,2,1,1"]
|
||||
|===
|
||||
| Element | Pantone | Hex (converted) | Nearest RAL Classic
|
||||
|
||||
| Mountain
|
||||
| Dark Rust, PMS 1615 C
|
||||
| `#8b4720`
|
||||
| RAL 2013 Pearl orange (`#923E25`)
|
||||
|
||||
| Sky
|
||||
| Medium Rust, PMS 1605 C
|
||||
| `#a65523`
|
||||
| RAL 8023 Orange brown (`#A45729`)
|
||||
|
||||
| Trees / grass
|
||||
| Dark Green, PMS 553 C
|
||||
| `#284734`
|
||||
| RAL 6012 Black green (`#2F3D3A`)
|
||||
|
||||
| Bison, lake, snowfield, type
|
||||
| White
|
||||
| `#ffffff`
|
||||
| RAL 9016 Traffic white (`#F1F1EA`) — RAL has no pure white
|
||||
|===
|
||||
|
||||
Hex values are third-party Pantone→RGB conversions, not something NPS
|
||||
publishes directly (their style guide only specifies PMS codes). RAL codes
|
||||
are the closest match by RGB distance against the RAL Classic chart — same
|
||||
caveat, not an official NPS-endorsed mapping, just the nearest available
|
||||
code for filament/paint shopping. Source: NPS Graphic Identity and Style
|
||||
Guides (https://www.nps.gov/subjects/hfc/nps-graphic-identity-and-style-guides.htm),
|
||||
FAQs About Arrowhead Artwork
|
||||
(https://www.nps.gov/subjects/hfc/faqs-about-arrowhead-artwork.htm). Use of
|
||||
the arrowhead symbol itself is controlled by law/regulation/policy — see
|
||||
the FAQ page before printing/distributing anything bearing it.
|
||||
|
||||
== NPMap4 (the older, Leaflet-based generation)
|
||||
|
||||
https://www.nps.gov/lib/npmap.js/4.2.1/ — predecessor to npmap5,
|
||||
|
||||
@@ -51,6 +51,20 @@ overview's polar pan limit, but MapLibre 6's `Map` no longer exposes
|
||||
`.transform` at all — guarded to skip that (cosmetic) adjustment
|
||||
instead of throwing when it's absent.
|
||||
|
||||
Also vendored under `public/vendor/` — MIT-licensed, attribution
|
||||
required (`LICENSE` included alongside):
|
||||
|
||||
[cols="1,1,2"]
|
||||
|===
|
||||
| Library | Version | Source
|
||||
|
||||
| maplibre-gl-lanes (offline hiking trails, `public/js/hiking_trails.js`
|
||||
— trails sharing a path drawn as parallel lanes); its `dist/*.mjs`
|
||||
build, minus the trailing `sourceMappingURL` comment
|
||||
| 1.3.0
|
||||
| https://github.com/c0nsumer/maplibre-gl-lanes
|
||||
|===
|
||||
|
||||
Also vendored under `public/vendor/` — MIT-licensed (© 2013 Panayiotis
|
||||
Lipiridis), attribution required (this notice satisfies that):
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
require "open3"
|
||||
require "tmpdir"
|
||||
require "json"
|
||||
require_relative "track_stats"
|
||||
|
||||
module Maptool
|
||||
# Prototype: extracts route=hiking relations (name/colour/network/operator,
|
||||
# full member-way geometry) straight out of a Geofabrik .osm.pbf — the same
|
||||
# file PmtilesBuilder already downloads per country for offline.pmtiles, so
|
||||
# this needs no separate download or third-party service. Three osmium
|
||||
# subprocess steps do the heavy lifting (osmium is already a server
|
||||
# dependency, see PmtilesBuilder/docs/development.adoc):
|
||||
#
|
||||
# 1. `osmium tags-filter FILE r/route=hiking -o hiking.osm.pbf` — pulls
|
||||
# out just the hiking relations *and* (by default, no -R) every way/
|
||||
# node they reference, so the output is small and self-contained.
|
||||
# 2. `osmium add-locations-to-ways` — a plain Geofabrik PBF's ways only
|
||||
# carry bare node *references* (ids), not coordinates; this resolves
|
||||
# each way's node ids against the node objects in the same file and
|
||||
# rewrites the way with real coordinates attached. Verified against a
|
||||
# real extract (Macedonia) that skipping this step silently produces
|
||||
# ways with empty x/y (`n123xy`, no numbers) in the OPL output below —
|
||||
# not an error, just wrong output, so easy to miss without checking.
|
||||
# 3. `osmium cat hiking.osm.pbf -f opl,locations_on_ways=true,add_metadata=false`
|
||||
# — OPL is a plain-text, one-object-per-line format (see
|
||||
# https://osmcode.org/opl-file-format/); locations_on_ways (now that
|
||||
# step 2 actually populated them) embeds each way's node coordinates
|
||||
# directly in its own line, so no separate node-id -> [lon,lat] table
|
||||
# needs building, just two passes over text.
|
||||
#
|
||||
# No general OSM tool stitches a route relation's member ways into one
|
||||
# line for you (routes are a semantic grouping, not a geometry type) —
|
||||
# every hiking-trail renderer, including this app's own Overpass-based
|
||||
# click handler (public/js/hiking_trails.js) and waymarkedtrails.org
|
||||
# itself, does this same member-way walk by hand.
|
||||
module HikingTrails
|
||||
class ExtractError < StandardError; end
|
||||
|
||||
ROUTE_TAGS = %w[name description ref colour network operator].freeze
|
||||
|
||||
def self.osmium_bin
|
||||
ENV.fetch("OSMIUM_BIN", "osmium")
|
||||
end
|
||||
|
||||
# pbf_path: a Geofabrik-style .osm.pbf, e.g. one of
|
||||
# PmtilesBuilder.cached_extracts' own files. Returns a GeoJSON
|
||||
# FeatureCollection (MultiLineString per relation, one sub-line per
|
||||
# member way — not one big concatenated LineString, since a relation's
|
||||
# ways aren't always contiguous/ordered and jumping across a gap would
|
||||
# draw a spurious straight connector).
|
||||
def self.extract(pbf_path)
|
||||
Dir.mktmpdir("maptool-hiking-trails") do |dir|
|
||||
filtered = File.join(dir, "hiking.osm.pbf")
|
||||
run!(osmium_bin, "tags-filter", pbf_path, "r/route=hiking", "-o", filtered)
|
||||
|
||||
located = File.join(dir, "hiking-located.osm.pbf")
|
||||
run!(osmium_bin, "add-locations-to-ways", filtered, "-o", located)
|
||||
|
||||
opl_path = File.join(dir, "hiking.opl")
|
||||
run!(osmium_bin, "cat", located, "-f", "opl,locations_on_ways=true,add_metadata=false", "-o", opl_path)
|
||||
|
||||
parse_opl(File.read(opl_path))
|
||||
end
|
||||
end
|
||||
|
||||
# Pure — no subprocess — so it's cheaply testable against a canned OPL
|
||||
# fixture, same "keep the real logic testable without the real tool
|
||||
# installed" reasoning OverpassPois.to_geojson already gives for staying
|
||||
# separate from OverpassPois.fetch.
|
||||
def self.parse_opl(text)
|
||||
ways = {} # way id -> [[lon, lat], ...]
|
||||
relations = [] # { tags:, members: [{type:, id:}, ...] }
|
||||
|
||||
text.each_line do |line|
|
||||
fields = line.chomp.split(" ")
|
||||
head = fields.first
|
||||
next unless head
|
||||
|
||||
case head[0]
|
||||
when "w"
|
||||
ways[head[1..].to_i] = way_coordinates(fields)
|
||||
when "r"
|
||||
tags = tags_field(fields)
|
||||
relations << { id: head[1..].to_i, tags: tags, members: members_field(fields) } if tags["route"] == "hiking"
|
||||
end
|
||||
end
|
||||
|
||||
features = relations.filter_map { |relation| relation_feature(relation, ways) }
|
||||
{ "type" => "FeatureCollection", "features" => features }
|
||||
end
|
||||
|
||||
def self.relation_feature(relation, ways)
|
||||
lines = relation[:members]
|
||||
.select { |member| member[:type] == "w" }
|
||||
.filter_map { |member| ways[member[:id]] }
|
||||
.select { |coords| coords.size >= 2 }
|
||||
return nil if lines.empty?
|
||||
|
||||
distance = lines.sum { |coords| TrackStats.compute(coords)["distance_meters"] }
|
||||
tags = relation[:tags]
|
||||
properties = {
|
||||
"id" => relation[:id],
|
||||
"name" => tags["name"] || tags["description"],
|
||||
"distance_meters" => distance.round(1),
|
||||
}
|
||||
ROUTE_TAGS.each { |key| properties[key] ||= tags[key] }
|
||||
|
||||
{
|
||||
"type" => "Feature",
|
||||
"properties" => properties.compact,
|
||||
# Rounded to 5 decimal places (~1m) — full OSM float precision
|
||||
# (~1cm) is pointless for a rendered line and roughly doubles the
|
||||
# size of every coordinate pair once this many-country GeoJSON is
|
||||
# actually served to a browser.
|
||||
"geometry" => { "type" => "MultiLineString", "coordinates" => round_coordinates(lines) },
|
||||
}
|
||||
end
|
||||
private_class_method :relation_feature
|
||||
|
||||
def self.round_coordinates(lines)
|
||||
lines.map { |coords| coords.map { |lon, lat| [lon.round(5), lat.round(5)] } }
|
||||
end
|
||||
private_class_method :round_coordinates
|
||||
|
||||
def self.field(fields, letter)
|
||||
fields.find { |f| f.start_with?(letter) }
|
||||
end
|
||||
private_class_method :field
|
||||
|
||||
def self.tags_field(fields)
|
||||
raw = field(fields, "T")&.delete_prefix("T") || ""
|
||||
return {} if raw.empty?
|
||||
|
||||
raw.split(",").to_h { |pair| pair.split("=", 2).map { |part| unescape(part.to_s) } }
|
||||
end
|
||||
private_class_method :tags_field
|
||||
|
||||
def self.members_field(fields)
|
||||
raw = field(fields, "M")&.delete_prefix("M") || ""
|
||||
return [] if raw.empty?
|
||||
|
||||
raw.split(",").map do |member|
|
||||
{ type: member[0], id: member[1..].split("@", 2).first.to_i }
|
||||
end
|
||||
end
|
||||
private_class_method :members_field
|
||||
|
||||
# "n123x14.123y49.456,n124x14.130y49.460,..." -> [[14.123, 49.456], ...].
|
||||
# A member id with no embedded location (shouldn't happen with
|
||||
# locations_on_ways=true and -R omitted, but a way with a dangling/
|
||||
# missing node reference is possible in real-world data) is skipped
|
||||
# rather than raising — one bad node shouldn't drop the whole route.
|
||||
def self.way_coordinates(fields)
|
||||
raw = field(fields, "N")&.delete_prefix("N") || ""
|
||||
return [] if raw.empty?
|
||||
|
||||
raw.split(",").filter_map do |node_ref|
|
||||
match = node_ref.match(/\An\d+x(?<lon>-?[\d.]+)y(?<lat>-?[\d.]+)\z/)
|
||||
[match[:lon].to_f, match[:lat].to_f] if match
|
||||
end
|
||||
end
|
||||
private_class_method :way_coordinates
|
||||
|
||||
# OPL escapes space/newline/comma/equals/@/% as %<hex>% (see
|
||||
# https://osmcode.org/opl-file-format/#_encoding) — only tag keys/
|
||||
# values and usernames can contain them; the field-letter prefixes and
|
||||
# id/comma/@ separators we split on above are never themselves escaped.
|
||||
def self.unescape(str)
|
||||
str.gsub(/%([0-9a-fA-F]{1,6})%/) { [Regexp.last_match(1).to_i(16)].pack("U") }
|
||||
end
|
||||
private_class_method :unescape
|
||||
|
||||
def self.run!(*args)
|
||||
stdout, stderr, status = Open3.capture3(*args)
|
||||
raise ExtractError, "#{args.first} exited with status #{status.exitstatus}: #{[stdout, stderr].map(&:strip).reject(&:empty?).join("\n")}" unless status.success?
|
||||
rescue Errno::ENOENT, Errno::EACCES => e
|
||||
raise ExtractError, "could not run '#{args.first}' (#{e.message}) — is osmium-tool installed and on PATH?"
|
||||
end
|
||||
private_class_method :run!
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,6 @@
|
||||
require "net/http"
|
||||
require "json"
|
||||
require "time"
|
||||
|
||||
module Maptool
|
||||
# Fetches a Garmin LiveTrack session's track and metadata.
|
||||
@@ -18,7 +19,7 @@ module Maptool
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
|
||||
PUSH_PATTERN = /self\.__next_f\.push\((\[.*?\])\)<\/script>/m
|
||||
|
||||
Result = Struct.new(:points, :session, keyword_init: true)
|
||||
Result = Struct.new(:points, :session, :course, keyword_init: true)
|
||||
|
||||
def self.fetch(session_id, token)
|
||||
new.fetch(session_id, token)
|
||||
@@ -29,6 +30,10 @@ module Maptool
|
||||
# if that part wasn't found even though points were (seen in practice:
|
||||
# session details are consistently present alongside points, but nothing
|
||||
# here depends on that holding forever).
|
||||
# `course`: the planned route (name/geoPoints), or nil — only present
|
||||
# when the activity was actually started from a saved Garmin course
|
||||
# (session["courseIds"] is how the page itself knows to fetch one; a
|
||||
# plain unplanned activity has neither).
|
||||
def fetch(session_id, token)
|
||||
payloads = parse_payloads(fetch_html(session_id, token))
|
||||
|
||||
@@ -36,18 +41,42 @@ module Maptool
|
||||
raise FetchError, "no track points found (session invalid or expired?)" unless points
|
||||
|
||||
session = search(payloads) { |node| node if node["sessionName"] }
|
||||
# First of possibly several — a LiveTrack session only ever follows
|
||||
# the one course it was started from.
|
||||
course = search(payloads) { |node| node["courses"].first if node["courses"].is_a?(Array) && !node["courses"].empty? }
|
||||
|
||||
Result.new(points: clean(points), session: clean(session))
|
||||
Result.new(points: clean(points), session: clean(session), course: clean(course))
|
||||
end
|
||||
|
||||
# Distance/duration/activity type come from the last track point (its
|
||||
# totals are cumulative for the whole session), name/type from the
|
||||
# session details — nil-safe throughout since either can be missing.
|
||||
#
|
||||
# "active": Garmin doesn't set a session's `end` until the activity is
|
||||
# actually stopped, so its absence means the session is still recording
|
||||
# — inferred from the one (already-ended) session this was built
|
||||
# against, not verified against a session actually caught mid-activity.
|
||||
# "active": originally `session["end"].nil?`, on the assumption Garmin
|
||||
# doesn't set `end` until the activity actually stops — disproven
|
||||
# against a real still-recording session (2026-09-20): `end` was
|
||||
# already non-nil, set to exactly `start + 24h`, the share link's own
|
||||
# expiry, not an actual end time. Two signals now, both verified live
|
||||
# against that same session:
|
||||
#
|
||||
# * `end` itself, once it's actually in the past — watched the same
|
||||
# session update it from that 24h placeholder to within a second of
|
||||
# the last point's own time, immediately after the activity was
|
||||
# saved on the watch. Authoritative and near-instant when present,
|
||||
# so this is checked first.
|
||||
# * failing that (still showing the future placeholder — an abandoned
|
||||
# session never gets saved, so `end` never moves), recency of the
|
||||
# last track point: Garmin posts one roughly every
|
||||
# `postTrackPointFrequency` seconds (~15, observed) while genuinely
|
||||
# still recording, so one older than ACTIVE_WITHIN means signal was
|
||||
# lost a while ago (same "looks ended until it isn't" behavior
|
||||
# Garmin's own LiveTrack page shows in that case).
|
||||
#
|
||||
# Both must agree the session is live — `end` already in the past
|
||||
# means stopped even if the last point is only seconds old (exactly
|
||||
# the save-immediately-after-the-last-point case above).
|
||||
ACTIVE_WITHIN = 30 * 60 # seconds
|
||||
|
||||
def self.summarize(result)
|
||||
last = result.points.last || {}
|
||||
session = result.session || {}
|
||||
@@ -56,12 +85,23 @@ module Maptool
|
||||
"activity_type" => last["activityType"],
|
||||
"distance_meters" => last["totalDistanceMeters"],
|
||||
"duration_secs" => last["totalDurationSecs"],
|
||||
"active" => session["end"].nil?,
|
||||
"active" => active?(last["dateTime"], session["end"]),
|
||||
"start" => session["start"],
|
||||
"end" => session["end"],
|
||||
}
|
||||
end
|
||||
|
||||
def self.active?(last_point_time, session_end)
|
||||
return false unless last_point_time
|
||||
|
||||
now = Time.now.utc
|
||||
return false if session_end && Time.parse(session_end) <= now
|
||||
|
||||
Time.parse(last_point_time) > now - ACTIVE_WITHIN
|
||||
rescue ArgumentError, TypeError
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# The RSC payload serializes an absent field as the literal string
|
||||
|
||||
@@ -12,7 +12,15 @@ module Maptool
|
||||
module OverpassPois
|
||||
class FetchError < StandardError; end
|
||||
|
||||
OVERPASS_API = "https://overpass-api.de/api/interpreter".freeze
|
||||
# Same three public mirrors public/js/hiking_trails.js races — tried in
|
||||
# turn here instead, falling through to the next on any failure: the
|
||||
# shared free instances regularly 504/429 under load, and a plugin run
|
||||
# is a one-off click, not worth threads to race them.
|
||||
OVERPASS_APIS = [
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
"https://overpass.kumi.systems/api/interpreter",
|
||||
"https://overpass.private.coffee/api/interpreter",
|
||||
].freeze
|
||||
|
||||
# Each entry: an Overpass tag filter -> { label:, icon:, color: }, used
|
||||
# both to build the query (unioning every filter) and to label/style
|
||||
@@ -53,8 +61,23 @@ module Maptool
|
||||
# bbox: [south, west, north, east] (Overpass's own axis order).
|
||||
def self.fetch(bbox, timeout: 60)
|
||||
query = build_query(bbox, timeout: timeout)
|
||||
uri = URI(OVERPASS_API)
|
||||
response = Net::HTTP.post_form(uri, "data" => query)
|
||||
errors = OVERPASS_APIS.map do |url|
|
||||
return post(URI(url), query, timeout)
|
||||
rescue FetchError, Net::OpenTimeout, Net::ReadTimeout, SystemCallError, SocketError, OpenSSL::SSL::SSLError, JSON::ParserError => e
|
||||
"#{URI(url).host}: #{e.message}"
|
||||
end
|
||||
raise FetchError, errors.join("; ")
|
||||
end
|
||||
|
||||
def self.post(uri, query, timeout)
|
||||
# overpass-api.de answers Net::HTTP's default "Ruby" User-Agent with
|
||||
# a 406 (verified 2026-10-09) — any identifying UA gets through.
|
||||
request = Net::HTTP::Post.new(uri, "User-Agent" => "maptool (Overpass camper POIs plugin)")
|
||||
request.set_form_data("data" => query)
|
||||
# read_timeout past the query's own [timeout:] — Net::HTTP's default
|
||||
# 60s equals it, so a big viewport could hit Net::ReadTimeout just as
|
||||
# Overpass was about to answer (a country-sized bbox measured ~57s).
|
||||
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: timeout + 30) { |http| http.request(request) }
|
||||
raise FetchError, "unexpected response #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
JSON.parse(response.body)
|
||||
|
||||
@@ -65,8 +65,13 @@ module Maptool
|
||||
south, west, north, east = bbox
|
||||
args = ["--south=#{south}", "--west=#{west}", "--north=#{north}", "--east=#{east}"]
|
||||
|
||||
stdout, _stderr, status = Open3.capture3(command, *args, chdir: plugin_dir)
|
||||
raise RunError, "plugin exited with status #{status.exitstatus}" unless status.success?
|
||||
stdout, stderr, status = Open3.capture3(command, *args, chdir: plugin_dir)
|
||||
# First stderr line too — for a Ruby plugin that's the exception
|
||||
# message (e.g. "...: unexpected response 406"), the only clue to why.
|
||||
unless status.success?
|
||||
reason = stderr.lines.first&.strip
|
||||
raise RunError, ["plugin exited with status #{status.exitstatus}", reason].compact.reject(&:empty?).join(": ")
|
||||
end
|
||||
|
||||
geojson = JSON.parse(stdout)
|
||||
raise RunError, "plugin did not print a GeoJSON FeatureCollection" unless geojson.is_a?(Hash) && geojson["type"] == "FeatureCollection"
|
||||
|
||||
@@ -5,6 +5,7 @@ require "json"
|
||||
require "tmpdir"
|
||||
require "fileutils"
|
||||
require "etc"
|
||||
require_relative "hiking_trails"
|
||||
|
||||
module Maptool
|
||||
# Regenerates data/offline.pmtiles and data/terrain.pmtiles for the
|
||||
@@ -88,11 +89,15 @@ module Maptool
|
||||
# archive" story via go-pmtiles, unrelated to the basemap swap.
|
||||
MAPTERHORN_MANIFEST_URL = "https://download.mapterhorn.com/download_urls.json"
|
||||
MAPTERHORN_PLANET_NAME = "planet.pmtiles"
|
||||
# Matches the online hillshade layer's own cap (public/js/overlays.js's
|
||||
# ONLINE_TERRAIN_TILES source, maxzoom: 15) — applied uniformly even
|
||||
# though some Mapterhorn chunks natively offer more, so the offline
|
||||
# experience doesn't vary in detail from one region to the next.
|
||||
TERRAIN_MAX_ZOOM = 15
|
||||
# Deliberately *below* the online hillshade layer's own cap
|
||||
# (public/js/overlays.js's ONLINE_TERRAIN_TILES source, maxzoom: 15) —
|
||||
# raster DEM tiles don't compress away like vector ones do, so every
|
||||
# extra zoom level roughly quadruples storage. z13 is each regional
|
||||
# chunk's own native minzoom (see the comment above), so this is the
|
||||
# cheapest useful cut: one raster level per chunk, not a 15-13=2-level
|
||||
# pyramid on top of it. Bumped into a 181GB terrain.pmtiles for a
|
||||
# 5-country selection before this was lowered — see docs/development.adoc.
|
||||
TERRAIN_MAX_ZOOM = 13
|
||||
|
||||
OFFLINE_STATUS = { state: "idle" }
|
||||
TERRAIN_STATUS = { state: "idle" }
|
||||
@@ -285,12 +290,66 @@ module Maptool
|
||||
run_planetiler!("--osm-path=#{osm_path}", "--output=#{tmp_path}", "--download", "--threads=#{Etc.nprocessors}")
|
||||
File.rename(tmp_path, File.join(data_dir, "offline.pmtiles"))
|
||||
File.write(offline_meta_path(data_dir), JSON.generate(sources: sources.map { |s| s.slice("code", "slug", "last_modified") }))
|
||||
|
||||
report_step.call("extracting hiking trails")
|
||||
extract_hiking_trails(sources, data_dir)
|
||||
ensure
|
||||
FileUtils.remove_entry(scratch_dir) if scratch_dir && Dir.exist?(scratch_dir)
|
||||
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
|
||||
end
|
||||
private_class_method :extract_offline
|
||||
|
||||
# One GeoJSON per country (data/hiking_trails/<CODE>.geojson), not one
|
||||
# merged file — the frontend only ever needs to show a country at a
|
||||
# time the viewer actually picks (see HikingTrailsControl's country
|
||||
# checklist), and a combined multi-country file was needlessly making
|
||||
# every viewer fetch every selected country's trails regardless.
|
||||
#
|
||||
# Runs over the same per-country .osm.pbf files `sources` above already
|
||||
# downloaded/cached for offline.pmtiles itself — no separate download.
|
||||
# Cached the same way geofabrik_cache is: skipped for a country whose
|
||||
# extract is already newer than its source .osm.pbf, so growing the
|
||||
# region selection by one country only extracts that one, not every
|
||||
# already-covered country all over again.
|
||||
#
|
||||
# Best-effort per country: an extraction problem (a malformed PBF
|
||||
# region, an osmium hiccup) shouldn't fail the whole offline.pmtiles
|
||||
# build, or even the other countries in this same selection, over
|
||||
# what's a bonus overlay, not the core map.
|
||||
def self.extract_hiking_trails(sources, data_dir)
|
||||
FileUtils.mkdir_p(hiking_trails_dir(data_dir))
|
||||
sources.each do |source|
|
||||
out_path = hiking_trails_country_path(data_dir, source["code"])
|
||||
next if File.exist?(out_path) && File.mtime(out_path) >= File.mtime(source["path"])
|
||||
|
||||
tmp_path = "#{out_path}.tmp"
|
||||
begin
|
||||
File.write(tmp_path, JSON.generate(HikingTrails.extract(source["path"])))
|
||||
File.rename(tmp_path, out_path)
|
||||
rescue HikingTrails::ExtractError => e
|
||||
File.delete(tmp_path) if File.exist?(tmp_path)
|
||||
warn "hiking trails extraction failed for #{source["code"]}, skipping: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
private_class_method :extract_hiking_trails
|
||||
|
||||
def self.hiking_trails_dir(data_dir)
|
||||
File.join(data_dir, "hiking_trails")
|
||||
end
|
||||
|
||||
def self.hiking_trails_country_path(data_dir, code)
|
||||
File.join(hiking_trails_dir(data_dir), "#{code}.geojson")
|
||||
end
|
||||
|
||||
# Country codes with an already-extracted offline hiking-trails file,
|
||||
# sorted — not necessarily the same as the *current* region selection
|
||||
# (accumulates like geofabrik_cache does; a country deselected after
|
||||
# being built once is left alone rather than swept up here).
|
||||
def self.hiking_trails_countries(data_dir)
|
||||
Dir.glob(File.join(hiking_trails_dir(data_dir), "*.geojson")).map { |path| File.basename(path, ".geojson") }.sort
|
||||
end
|
||||
|
||||
# Renders a report_step string like "fetching PL (3/4) — done: SK, CZ —
|
||||
# pending: HU" — so GET .../build-status shows not just "it's running"
|
||||
# but which of the current build's sources is in flight and which are
|
||||
@@ -438,6 +497,7 @@ module Maptool
|
||||
|
||||
File.delete(File.join(data_dir, "offline.pmtiles")) if File.exist?(File.join(data_dir, "offline.pmtiles"))
|
||||
File.delete(offline_meta_path(data_dir)) if File.exist?(offline_meta_path(data_dir))
|
||||
FileUtils.remove_entry(hiking_trails_dir(data_dir)) if Dir.exist?(hiking_trails_dir(data_dir))
|
||||
cache_dir = geofabrik_cache_dir(data_dir)
|
||||
FileUtils.remove_entry(cache_dir) if Dir.exist?(cache_dir)
|
||||
end
|
||||
@@ -513,7 +573,17 @@ module Maptool
|
||||
extracts = sources.each_with_index.map do |item, i|
|
||||
report_step.call(progress_step("extracting #{item["name"]}", labels, i))
|
||||
extract_path = File.join(scratch_dir, "extract-#{i}.pmtiles")
|
||||
run_pmtiles!("extract", item["url"], extract_path, "--region=#{region_path}", "--maxzoom=#{TERRAIN_MAX_ZOOM}")
|
||||
# --overfetch=0: go-pmtiles' default (0.05) merges nearby byte
|
||||
# ranges into fewer, bigger HTTP requests to save round-trips —
|
||||
# for planet.pmtiles (global z0-12, tens of GB) covering a
|
||||
# multi-country selection that collapsed the whole extract into
|
||||
# a *single* ~60GB request, which then had no way to resume after
|
||||
# an HTTP/2 stream reset 11 minutes in (see the "died again"
|
||||
# 2026-09-12 build). Disabling merging trades that one fragile,
|
||||
# unresumable mega-request for many small ones spread over
|
||||
# --download-threads (default 4) — slower on a clean run, but no
|
||||
# longer one dropped stream away from losing 20 minutes of transfer.
|
||||
run_pmtiles!("extract", item["url"], extract_path, "--region=#{region_path}", "--maxzoom=#{TERRAIN_MAX_ZOOM}", "--overfetch=0")
|
||||
extract_path
|
||||
end
|
||||
|
||||
@@ -621,11 +691,23 @@ module Maptool
|
||||
ENV.fetch("PLANETILER_JAR", "/usr/local/lib/planetiler.jar")
|
||||
end
|
||||
|
||||
# go-pmtiles' extract logs one progress line per chunk fetched
|
||||
# ("fetching N dirs, M chunks, K requests") — fine on a terminal where
|
||||
# it overwrites itself, but captured non-interactively that's one line
|
||||
# per chunk, easily hundreds of thousands of characters for a big
|
||||
# region. The actual failure is always at the end, so keep only the
|
||||
# tail — same story for osmium/planetiler's own chatty stdout.
|
||||
OUTPUT_TAIL_LINES = 40
|
||||
|
||||
# Some CLI tools (go-pmtiles included) write their actual error to
|
||||
# stdout rather than stderr — combine both rather than risk raising a
|
||||
# BuildError with nothing useful in it.
|
||||
def self.command_output(stdout, stderr)
|
||||
[stdout, stderr].map(&:strip).reject(&:empty?).join("\n")
|
||||
lines = [stdout, stderr].map(&:strip).reject(&:empty?).join("\n").lines
|
||||
return lines.join if lines.size <= OUTPUT_TAIL_LINES
|
||||
|
||||
omitted = lines.size - OUTPUT_TAIL_LINES
|
||||
"(#{omitted} earlier lines omitted)\n#{lines.last(OUTPUT_TAIL_LINES).join}"
|
||||
end
|
||||
private_class_method :command_output
|
||||
|
||||
|
||||
@@ -397,6 +397,68 @@ html, body {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Docked detail panel for a clicked hiking trail — replaces a map-anchored
|
||||
Popup (which routinely covered up the very trail line it described) with
|
||||
a fixed side panel, same "click info moves off the map" idea
|
||||
track_profile.js already established for clicking a GPX track line
|
||||
(TrackProfilePanel). .trail-panel__body gets the exact padding
|
||||
.maplibregl-popup-content used (15px 10px) so routeHtml()'s own output —
|
||||
built for that popup, including its header's canceling negative margin —
|
||||
renders here unchanged; no separate panel-flavored template needed. */
|
||||
.trail-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 300px;
|
||||
max-width: 90vw;
|
||||
background: #fff;
|
||||
border-left: 1px solid #ddd;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.trail-panel__body {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
padding: 15px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.trail-panel__close {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 2px 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* First breakpoint in this file. Below it the same panel docks to the
|
||||
bottom edge instead of the right one — a fixed-width side panel would
|
||||
otherwise eat most of a phone-width map, the exact "popup dominates the
|
||||
screen" problem this panel was meant to fix in the first place. */
|
||||
@media (max-width: 640px) {
|
||||
.trail-panel {
|
||||
top: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
max-height: 50vh;
|
||||
border-left: none;
|
||||
border-top: 1px solid #ddd;
|
||||
border-radius: 8px 8px 0 0;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.offline-data-panel__body {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
@@ -542,6 +604,14 @@ html, body {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.offline-data-panel__status--error {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
color: #b3261e;
|
||||
}
|
||||
|
||||
.offline-data-panel__update-badge {
|
||||
color: #b8860b;
|
||||
}
|
||||
@@ -1346,6 +1416,14 @@ html, body {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hiking-trail-popup__swatch {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.gpx-popup__header-icon {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
@@ -1563,6 +1641,22 @@ html, body {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hiking-trail-countries {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hiking-trail-countries__item label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.livetrack-panel__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import { WALKING_ICON } from "/js/icons.js";
|
||||
import { firstSymbolLayerId } from "/js/radar.js";
|
||||
import { escapeHtml } from "/js/gpx.js";
|
||||
import { lineLength, formatDistance } from "/js/measure.js";
|
||||
import { buildLineGraph, orderLanesAsync, LaneLayer } from "/vendor/maplibre-gl-lanes/maplibre-gl-lanes.mjs";
|
||||
|
||||
// Overpass has permissive CORS (verified: Access-Control-Allow-Origin: *),
|
||||
// so this queries it directly from the browser — no server-side proxy
|
||||
// needed, same "public API, no key" story as the Nominatim search already
|
||||
// hits from here. One query per click (not per pan/move like the
|
||||
// Wikipedia layer's live refetch), so this stays well within Overpass's
|
||||
// shared rate limits.
|
||||
//
|
||||
// Three independent public mirrors (also CORS-open, verified), raced with
|
||||
// Promise.any rather than called one at a time — the shared free instances
|
||||
// vary a lot in load from one moment to the next, and a multi-second wait
|
||||
// on a single one was the main complaint. Whichever answers first wins;
|
||||
// the others are simply left to finish and get GC'd, not worth the extra
|
||||
// AbortController plumbing for a single one-off per-click request.
|
||||
const OVERPASS_APIS = [
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
"https://overpass.kumi.systems/api/interpreter",
|
||||
"https://overpass.private.coffee/api/interpreter",
|
||||
];
|
||||
const CLICK_RADIUS_M = 40;
|
||||
// OSM colour tag values are free text (untrusted) — only used as a CSS
|
||||
// background if they look like an actual color (hex or a plain word),
|
||||
// never interpolated unchecked into a style attribute.
|
||||
const SAFE_COLOR = /^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)$/;
|
||||
|
||||
// Pre-rendered raster tiles, not vector — waymarkedtrails.org already
|
||||
// colors each route by its OSM `colour` tag (the KST/KČT-style paint-blaze
|
||||
// colors used in Slovakia/Czechia), falling back to plain red where a route
|
||||
// has none, so there's no styling to do here ourselves. Used whenever no
|
||||
// offline extract is available at all — same "quietly renders nothing
|
||||
// without a network" story as the Wikipedia/Radar/Contour lines overlays.
|
||||
const ONLINE_SOURCE_ID = "hiking-trails-online";
|
||||
const ONLINE_LAYER_ID = "hiking-trails-online-line";
|
||||
const TILE_URL = "https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png";
|
||||
const ATTRIBUTION =
|
||||
'Trails © <a href="https://waymarkedtrails.org" target="_blank">Waymarked Trails</a> (CC-BY-SA), map data © OpenStreetMap contributors';
|
||||
const OPACITY = 0.85;
|
||||
|
||||
// Offline: real vector trails for each country the viewer actually picks,
|
||||
// GeoJSON generated at build time by Maptool::HikingTrails straight
|
||||
// from the same Geofabrik .osm.pbf offline.pmtiles itself is built from
|
||||
// (see lib/maptool/hiking_trails.rb, PmtilesBuilder.extract_hiking_trails)
|
||||
// — no network needed, and no waiting on Overpass for click details either
|
||||
// (hit-tested locally via LaneLayer.queryLane instead, see _handleClick
|
||||
// below). One file per country rather than one combined file: a viewer
|
||||
// covering a dozen countries' worth of offline map data rarely wants all
|
||||
// of their hiking trails loaded at once, so this is opt-in per country
|
||||
// instead.
|
||||
//
|
||||
// Drawn with maplibre-gl-lanes rather than a plain line layer: a stretch
|
||||
// of path several marked routes share (very common with KST blazes) is
|
||||
// otherwise one line drawn over and over, only the topmost color visible.
|
||||
// Lanes puts each route in its own parallel lane instead. It needs routes
|
||||
// sharing a path to share exact vertices, which OSM relations over shared
|
||||
// ways already do. One LaneLayer over every picked country, not one per
|
||||
// country, so lanes at a border stay ordered together. Doesn't drape over
|
||||
// 3D terrain (sinks under raised ground, warns on the console) — a known
|
||||
// limitation of the library, not worth working around for an overlay.
|
||||
const LANES_LAYER_ID = "hiking-trails-lanes";
|
||||
const LANE_SIZES = () => ({ spacing: 3.5, width: 3, casingWidth: 0.5 });
|
||||
function offlineUrl(code) {
|
||||
return `/tiles/hiking_trails/${code}.geojson`;
|
||||
}
|
||||
|
||||
// waymarkedtrails.org's own fallback for an untagged route.
|
||||
const DEFAULT_TRAIL_COLOR = "#c1272d";
|
||||
// Real-world `colour` values seen in production data (see the 2026-09-13
|
||||
// extraction against SK.osm.pbf) — anything outside this set (typos like
|
||||
// "yelllow", multi-value combos like "blue, yellow", stray hex codes) falls
|
||||
// back to DEFAULT_TRAIL_COLOR rather than being trusted as a CSS value
|
||||
// straight from OSM tag text.
|
||||
const KNOWN_TRAIL_COLORS = new Set(["red", "yellow", "blue", "green", "orange", "purple", "brown", "black", "white", "grey", "gray", "darkblue"]);
|
||||
|
||||
function normalizedTrailColor(raw) {
|
||||
if (!raw) return DEFAULT_TRAIL_COLOR;
|
||||
const trimmed = raw.trim();
|
||||
if (KNOWN_TRAIL_COLORS.has(trimmed.toLowerCase())) return trimmed.toLowerCase();
|
||||
return /^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : DEFAULT_TRAIL_COLOR;
|
||||
}
|
||||
|
||||
// Online mode's highlight outline for whichever route(s) the last click
|
||||
// matched — drawn as its own separate source/layer (real geometry, from the
|
||||
// Overpass response), since the raster tiles have no paintable features to
|
||||
// highlight at all. Offline lanes use LaneLayer.setHighlight instead. Same "yellow outline on a picked
|
||||
// route" treatment the earlier SVG prototype visualization used.
|
||||
const HIGHLIGHT_SOURCE_ID = "hiking-trails-highlight";
|
||||
const HIGHLIGHT_LAYER_ID = "hiking-trails-highlight-line";
|
||||
|
||||
// An Overlays panel item — a plain on/off checkbox (raster overlay) until
|
||||
// offline extracts exist for at least one country, at which point it
|
||||
// becomes an expandable per-country checklist instead (same expand shape
|
||||
// as RadarControl), since a viewer with a dozen countries' worth of
|
||||
// offline map data rarely wants all of their hiking trails loaded, let
|
||||
// alone parsed by the browser, at once.
|
||||
export class HikingTrailsControl {
|
||||
constructor(map) {
|
||||
this._map = map;
|
||||
this._active = false; // the online-mode checkbox's own state
|
||||
this._offlineCountries = []; // codes with an extract available on the server
|
||||
this._selectedCountries = new Set(); // codes currently shown, offline mode
|
||||
this._trailData = new Map(); // code -> fetched GeoJSON, kept across toggles/style swaps
|
||||
this._routeInfo = new Map(); // String(relation id) -> feature properties, for the panel
|
||||
this._lanes = null; // the current LaneLayer, null when no country is shown
|
||||
|
||||
map.on("click", (event) => this._handleClick(event));
|
||||
|
||||
this._panel = document.getElementById("trail-panel");
|
||||
this._panelBody = this._panel.querySelector(".trail-panel__body");
|
||||
this._panel.querySelector(".trail-panel__close").addEventListener("click", () => this._hidePanel());
|
||||
|
||||
// setStyle() (switching Online/Offline) tears down all custom sources/
|
||||
// layers — put whatever was showing back, same as every other
|
||||
// Overlays control. The highlight is transient (tied to whichever
|
||||
// trail is showing in the panel, if any) — simpler to just drop it
|
||||
// here than try to carry it across a style swap.
|
||||
map.on("style.load", () => {
|
||||
if (this._active) this._addOnlineLayer();
|
||||
this._clearHighlight();
|
||||
if (this._selectedCountries.size > 0) this._rebuildLanes();
|
||||
});
|
||||
}
|
||||
|
||||
mount(li) {
|
||||
li.innerHTML = `
|
||||
<div class="overlays-panel__row">
|
||||
<input type="checkbox" class="overlays-panel__toggle">
|
||||
<span class="overlays-panel__icon">${WALKING_ICON}</span>
|
||||
<button type="button" class="overlays-panel__name">Hiking trails</button>
|
||||
</div>
|
||||
<div class="overlays-panel__details" hidden>
|
||||
<ul class="hiking-trail-countries"></ul>
|
||||
</div>
|
||||
`;
|
||||
this._checkbox = li.querySelector(".overlays-panel__toggle");
|
||||
this._nameButton = li.querySelector(".overlays-panel__name");
|
||||
this._details = li.querySelector(".overlays-panel__details");
|
||||
this._countryList = li.querySelector(".hiking-trail-countries");
|
||||
|
||||
this._checkbox.addEventListener("change", (event) => this._toggleOnline(event.target.checked));
|
||||
this._nameButton.addEventListener("click", () => {
|
||||
if (this._offlineCountries.length === 0) return; // nothing to expand into yet
|
||||
const expanding = this._details.hidden;
|
||||
this._details.hidden = !expanding;
|
||||
this._nameButton.setAttribute("aria-expanded", String(expanding));
|
||||
});
|
||||
}
|
||||
|
||||
// Called once map.js's GET /api/hiking-trails check resolves. Switches
|
||||
// this item from "online raster, plain on/off" to "offline vector,
|
||||
// pick your countries" — the two are mutually exclusive rather than
|
||||
// both offered at once, since once a real local extract exists there's
|
||||
// no reason to still fall back to a third-party tile server for any of
|
||||
// it.
|
||||
setOfflineCountries(countries) {
|
||||
this._offlineCountries = countries;
|
||||
if (countries.length === 0) return;
|
||||
|
||||
if (this._active) this._toggleOnline(false);
|
||||
this._checkbox.hidden = true;
|
||||
|
||||
this._countryList.innerHTML = countries
|
||||
.map(
|
||||
(code) => `
|
||||
<li class="hiking-trail-countries__item">
|
||||
<label>
|
||||
<input type="checkbox" class="hiking-trail-countries__toggle" value="${code}">
|
||||
${code}
|
||||
</label>
|
||||
</li>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
this._countryList.querySelectorAll(".hiking-trail-countries__toggle").forEach((input) => {
|
||||
input.addEventListener("change", (event) => this._toggleCountry(event.target.value, event.target.checked));
|
||||
});
|
||||
}
|
||||
|
||||
_toggleOnline(active) {
|
||||
this._active = active;
|
||||
if (active) this._addOnlineLayer();
|
||||
else this._removeOnlineLayer();
|
||||
}
|
||||
|
||||
async _toggleCountry(code, active) {
|
||||
if (!active) {
|
||||
this._selectedCountries.delete(code);
|
||||
this._rebuildLanes();
|
||||
return;
|
||||
}
|
||||
this._selectedCountries.add(code);
|
||||
if (!this._trailData.has(code)) {
|
||||
let geojson;
|
||||
try {
|
||||
geojson = await (await fetch(offlineUrl(code))).json();
|
||||
} catch {
|
||||
return; // degrade quietly, same story as every other optional layer here
|
||||
}
|
||||
// Normalized once here so an untrustworthy `colour` tag value never
|
||||
// reaches the layer's color directly — same reasoning gpx.js's own
|
||||
// feature.properties.color ||= TRACK_COLOR normalization gives.
|
||||
for (const feature of geojson.features) {
|
||||
feature.properties.color = normalizedTrailColor(feature.properties.colour);
|
||||
}
|
||||
this._trailData.set(code, geojson);
|
||||
if (!this._selectedCountries.has(code)) return; // toggled back off while fetching
|
||||
}
|
||||
this._rebuildLanes();
|
||||
}
|
||||
|
||||
// Rebuilt from scratch on every country toggle and style swap (setStyle
|
||||
// drops custom layers too) — lane order depends on every route drawn, so
|
||||
// there's no cheaper incremental path. Ordering runs in a worker; a newer
|
||||
// rebuild started meanwhile wins, same token guard as _handleOnlineClick.
|
||||
async _rebuildLanes() {
|
||||
const build = (this._lanesBuild = {});
|
||||
const features = [...this._selectedCountries].flatMap((code) => this._trailData.get(code)?.features || []);
|
||||
let graph = null;
|
||||
if (features.length > 0) {
|
||||
graph = buildLineGraph(features, { routeProperty: "id" });
|
||||
await orderLanesAsync(graph);
|
||||
}
|
||||
if (this._lanesBuild !== build) return;
|
||||
|
||||
this._hidePanel();
|
||||
if (this._map.getLayer(LANES_LAYER_ID)) this._map.removeLayer(LANES_LAYER_ID);
|
||||
this._lanes = null;
|
||||
this._routeInfo = new Map(features.map((feature) => [String(feature.properties.id), feature.properties]));
|
||||
if (!graph) return;
|
||||
|
||||
this._lanes = new LaneLayer({ id: LANES_LAYER_ID, graph, sizes: LANE_SIZES, casingColor: "rgba(255, 255, 255, 0.8)" });
|
||||
// Below labels (same firstSymbolLayerId spot contour.js/radar.js use),
|
||||
// so place names/POI icons stay readable on top of the trail lines.
|
||||
this._map.addLayer(this._lanes, firstSymbolLayerId(this._map));
|
||||
}
|
||||
|
||||
_handleClick(event) {
|
||||
if (this._selectedCountries.size > 0) this._handleOfflineClick(event);
|
||||
else if (this._active) this._handleOnlineClick(event);
|
||||
}
|
||||
|
||||
// The lanes have real geometry to hit-test directly — no network
|
||||
// round-trip, so this resolves instantly. Each route has its own lane, so
|
||||
// a click picks exactly one, highlighted by the layer itself. A genuine
|
||||
// miss just closes an already-open panel rather than showing a "not
|
||||
// found" message, same as clicking empty space anywhere else on the map.
|
||||
_handleOfflineClick(event) {
|
||||
const hit = this._lanes?.queryLane(event.point, 8);
|
||||
const info = hit && this._routeInfo.get(String(hit.route));
|
||||
if (!info) {
|
||||
this._hidePanel();
|
||||
return;
|
||||
}
|
||||
this._lanes.setHighlight(hit.route);
|
||||
this._showPanel(routeHtml(info));
|
||||
}
|
||||
|
||||
_showPanel(html) {
|
||||
this._panelBody.innerHTML = html;
|
||||
this._panel.hidden = false;
|
||||
}
|
||||
|
||||
_hidePanel() {
|
||||
if (this._panel.hidden) return;
|
||||
this._panel.hidden = true;
|
||||
this._clearHighlight();
|
||||
}
|
||||
|
||||
// The raster tiles have no queryable features (they're pixels), so
|
||||
// "what trail is this" is answered with a one-off Overpass query instead
|
||||
// of a MapLibre feature hit-test — an `around` search rather than an
|
||||
// exact point match, since a click rarely lands exactly on the (already
|
||||
// pixel-thick) rendered line.
|
||||
async _handleOnlineClick(event) {
|
||||
const { lng, lat } = event.lngLat;
|
||||
this._showPanel(`<p class="gpx-popup__category">Looking for trails…</p>`);
|
||||
// A newer click's request replaces this one while Overpass is still
|
||||
// answering — same "in-flight response arrives after it's already
|
||||
// stale" guard the old Popup-identity check gave, just keyed off a
|
||||
// fresh token each click instead of a fresh Popup instance.
|
||||
const request = (this._onlineRequest = {});
|
||||
|
||||
let relations;
|
||||
try {
|
||||
relations = await fetchNearbyRoutes(lng, lat);
|
||||
} catch {
|
||||
if (this._onlineRequest === request) this._panelBody.innerHTML = `<p class="gpx-popup__category">Could not reach Overpass.</p>`;
|
||||
return;
|
||||
}
|
||||
if (this._onlineRequest !== request) return;
|
||||
|
||||
if (relations.length === 0) {
|
||||
this._panelBody.innerHTML = `<p class="gpx-popup__category">No hiking trail found here.</p>`;
|
||||
return;
|
||||
}
|
||||
this._panelBody.innerHTML = relations.map((relation) => routeHtml(overpassRouteInfo(relation))).join("<hr>");
|
||||
this._setHighlight(relations.map((relation) => ({ geometry: routeGeometry(relation), color: normalizedTrailColor(relation.tags?.colour) })));
|
||||
}
|
||||
|
||||
_addOnlineLayer() {
|
||||
if (this._map.getSource(ONLINE_SOURCE_ID)) return;
|
||||
|
||||
this._map.addSource(ONLINE_SOURCE_ID, {
|
||||
type: "raster",
|
||||
tiles: [TILE_URL],
|
||||
tileSize: 256,
|
||||
maxzoom: 17,
|
||||
attribution: ATTRIBUTION,
|
||||
});
|
||||
// Below labels (same firstSymbolLayerId spot contour.js/radar.js use),
|
||||
// so place names/POI icons stay readable on top of the trail lines.
|
||||
this._map.addLayer(
|
||||
{ id: ONLINE_LAYER_ID, type: "raster", source: ONLINE_SOURCE_ID, paint: { "raster-opacity": OPACITY } },
|
||||
firstSymbolLayerId(this._map),
|
||||
);
|
||||
}
|
||||
|
||||
_removeOnlineLayer() {
|
||||
if (this._map.getLayer(ONLINE_LAYER_ID)) this._map.removeLayer(ONLINE_LAYER_ID);
|
||||
if (this._map.getSource(ONLINE_SOURCE_ID)) this._map.removeSource(ONLINE_SOURCE_ID);
|
||||
}
|
||||
|
||||
// features: an array of { geometry, color } — geometry a GeoJSON
|
||||
// LineString or MultiLineString (mixed is fine), color the trail's own
|
||||
// (already-normalized) colour — everything the last click matched, drawn
|
||||
// as one highlight layer (a wider outline in the trail's own color,
|
||||
// rather than a fixed color that would repaint it) on top of the base
|
||||
// trail rendering.
|
||||
_setHighlight(features) {
|
||||
const data = {
|
||||
type: "FeatureCollection",
|
||||
features: features.map(({ geometry, color }) => ({ type: "Feature", properties: { color }, geometry })),
|
||||
};
|
||||
const source = this._map.getSource(HIGHLIGHT_SOURCE_ID);
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
return;
|
||||
}
|
||||
this._map.addSource(HIGHLIGHT_SOURCE_ID, { type: "geojson", data });
|
||||
this._map.addLayer({
|
||||
id: HIGHLIGHT_LAYER_ID,
|
||||
type: "line",
|
||||
source: HIGHLIGHT_SOURCE_ID,
|
||||
layout: { "line-cap": "round", "line-join": "round" },
|
||||
paint: { "line-color": ["get", "color"], "line-width": 5, "line-opacity": 0.9 },
|
||||
});
|
||||
}
|
||||
|
||||
_clearHighlight() {
|
||||
this._lanes?.setHighlight(null);
|
||||
if (this._map.getLayer(HIGHLIGHT_LAYER_ID)) this._map.removeLayer(HIGHLIGHT_LAYER_ID);
|
||||
if (this._map.getSource(HIGHLIGHT_SOURCE_ID)) this._map.removeSource(HIGHLIGHT_SOURCE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
// way(around:...) finds candidate ways near the click, then rel(bw...)
|
||||
// walks backward from those to their parent relations — the same
|
||||
// find-the-way-then-its-relation shape as clicking a route on
|
||||
// waymarkedtrails.org's own site. `out geom` inlines each member way's
|
||||
// full coordinate list right on the relation, so no separate stitching
|
||||
// step is needed to get real geometry back.
|
||||
async function queryOverpass(url, query) {
|
||||
const response = await fetch(url, { method: "POST", body: `data=${encodeURIComponent(query)}` });
|
||||
if (!response.ok) throw new Error(`overpass ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchNearbyRoutes(lng, lat) {
|
||||
const query = `[out:json][timeout:15];way(around:${CLICK_RADIUS_M},${lat},${lng})->.w;rel(bw.w)["route"="hiking"];out geom;`;
|
||||
const data = await Promise.any(OVERPASS_APIS.map((url) => queryOverpass(url, query)));
|
||||
return (data.elements || []).filter((element) => element.type === "relation");
|
||||
}
|
||||
|
||||
// OSM doesn't tag a route's length directly — summed from each member
|
||||
// way's own geometry instead, same haversine-sum measure.js's own Measure
|
||||
// tool uses for a hand-drawn line. Shared endpoints between adjacent ways
|
||||
// get walked twice, same as how a real hike over those ways would. Mirrors
|
||||
// Maptool::HikingTrails.relation_feature's own distance calc server-side
|
||||
// (lib/maptool/hiking_trails.rb) — this is the online, no-server-file path.
|
||||
function routeDistance(relation) {
|
||||
return (relation.members || [])
|
||||
.filter((member) => member.type === "way" && member.geometry)
|
||||
.reduce((total, member) => total + lineLength(member.geometry.map((point) => [point.lon, point.lat])), 0);
|
||||
}
|
||||
|
||||
// Same member-way walk as routeDistance above, collecting coordinates
|
||||
// instead of summing them — the online click highlight's geometry source,
|
||||
// since there's no vector feature to read it back from the way the offline
|
||||
// layer's own queryRenderedFeatures result already carries one.
|
||||
function routeGeometry(relation) {
|
||||
const lines = (relation.members || [])
|
||||
.filter((member) => member.type === "way" && member.geometry)
|
||||
.map((member) => member.geometry.map((point) => [point.lon, point.lat]));
|
||||
return { type: "MultiLineString", coordinates: lines };
|
||||
}
|
||||
|
||||
// Adapts a raw Overpass relation into the same shape offline GeoJSON
|
||||
// features' properties already carry (lib/maptool/hiking_trails.rb's own
|
||||
// ROUTE_TAGS + distance_meters + id) — one HTML builder (routeHtml) below
|
||||
// serves both instead of two near-identical ones.
|
||||
function overpassRouteInfo(relation) {
|
||||
const tags = relation.tags || {};
|
||||
return {
|
||||
id: relation.id,
|
||||
name: tags.name,
|
||||
description: tags.description,
|
||||
colour: tags.colour,
|
||||
network: tags.network,
|
||||
operator: tags.operator,
|
||||
ref: tags.ref,
|
||||
distance_meters: routeDistance(relation),
|
||||
};
|
||||
}
|
||||
|
||||
function routeHtml(info) {
|
||||
const name = info.name || info.description || "Hiking trail";
|
||||
const swatch = SAFE_COLOR.test(info.colour || "")
|
||||
? `<span class="hiking-trail-popup__swatch" style="background:${info.colour}"></span>`
|
||||
: "";
|
||||
const meta = [formatDistance(info.distance_meters || 0)];
|
||||
if (info.ref) meta.push(`#${info.ref}`);
|
||||
if (info.network) meta.push(info.network);
|
||||
if (info.operator) meta.push(escapeHtml(info.operator));
|
||||
|
||||
const parts = [
|
||||
`<div class="gpx-popup__header"><strong>${escapeHtml(name)}</strong>${swatch}</div>`,
|
||||
`<p class="gpx-popup__category">${meta.filter(Boolean).join(" · ")}</p>`,
|
||||
];
|
||||
// description already used as the title above when there's no name — only
|
||||
// shown again here as its own line if there both is a name *and* a
|
||||
// description to add, so it's never rendered twice for the common
|
||||
// no-name case.
|
||||
if (info.name && info.description) parts.push(`<p class="gpx-popup__description">${escapeHtml(info.description)}</p>`);
|
||||
parts.push(
|
||||
`<p class="gpx-popup__link"><a href="https://www.openstreetmap.org/relation/${info.id}" target="_blank" rel="noopener noreferrer">View on OpenStreetMap →</a></p>`,
|
||||
);
|
||||
return parts.join("");
|
||||
}
|
||||
+5
-1
@@ -328,7 +328,11 @@ const FISHING_ICON = `
|
||||
const SCENIC_VIEWPOINT_ICON = `
|
||||
<svg viewBox="0 0 22 22" fill="currentColor"><g transform="translate(4.000000, 0.000000)"><path d="M3.2,11c0,1.2-0.9,2.2-2.1,2.2c-1.2,0-2.1-1-2.1-2.2 c0-1.2,0.9-2.2,2.1-2.2C2.2,8.8,3.2,9.8,3.2,11"/><path d="M4.6,10.4v1.3l10.6,0.8v-3L4.6,10.4"/><path d="M2.3,7.5l1.1,0.7L7.7,2L5.3,0.5L2.3,7.5"/><path d="M4.4,9.8l7.9-3.8l-1.4-2.6L3.8,8.6L4.4,9.8z"/><path d="M2.3,14.5l1.1-0.7l4.3,6.2l-2.4,1.5L2.3,14.5"/><path d="M3.8,13.4l0.6-1.2l7.9,3.8l-1.4,2.6L3.8,13.4"/></g></svg>`;
|
||||
|
||||
const WALKING_ICON = `
|
||||
// Exported (unlike the other POI glyphs around it) — also used as the
|
||||
// Overlays panel's Hiking trails glyph (hiking_trails.js), same "reuse an
|
||||
// existing NPS-sourced pictogram" story as WILDERNESS_ICON above rather
|
||||
// than hand-drawing another one.
|
||||
export const WALKING_ICON = `
|
||||
<svg viewBox="0 0 22 22" fill="currentColor"><path d="M10.4,3.6c1,0,1.8-0.8,1.7-1.8c0-1-0.8-1.8-1.8-1.7c-1,0-1.8,0.8-1.7,1.8C8.6,2.8,9.5,3.6,10.4,3.6z"/><path d="M16.5,9.5L13.9,8l-2.2-3.4c-0.3-0.4-0.9-0.8-1.5-0.7C9.7,4,9.3,4.2,9,4.4L5.8,7.7C5.7,7.8,5.6,8,5.6,8.1l-0.4,3.6 c0,0,0,0.1,0,0.1c0,0.4,0.4,0.8,0.8,0.8c0.4,0,0.7-0.4,0.8-0.8l0.3-3.1l1.1-1.1l-1,8.6l-1.9,4.1c-0.1,0.1-0.1,0.3-0.1,0.5 c0,0.7,0.6,1.2,1.2,1.2c0.5,0,0.9-0.3,1.1-0.7l2-4.4c0-0.1,0.2-0.4,0.2-0.5l0.2-2.1l2.1,6.9c0.2,0.5,0.6,0.8,1.2,0.8 c0.7,0,1.2-0.6,1.2-1.2c0-0.1,0-0.1,0-0.2L11.5,11l0.4-3.1l0.8,1.3c0.1,0.1,0.1,0.2,0.2,0.2l2.8,1.6c0.1,0,0.2,0.1,0.4,0.1 c0.4,0,0.8-0.4,0.8-0.8C16.9,9.8,16.7,9.6,16.5,9.5z"/></svg>`;
|
||||
|
||||
const GUIDED_TOURS_ICON = `
|
||||
|
||||
+17
-1
@@ -5,6 +5,11 @@ import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
||||
const SESSIONS_URL = "/api/livetrack/sessions";
|
||||
const TRACK_SOURCE = "livetrack-track";
|
||||
const TRACK_COLOR = "#7209b7";
|
||||
// The planned route (only present when the activity was started from a
|
||||
// saved Garmin course, see LiveTrackClient#fetch) — dashed and muted so it
|
||||
// reads as a reference line, not more recorded data next to the actual
|
||||
// track's own color.
|
||||
const PLANNED_COLOR = "#888";
|
||||
// Garmin devices post a new point roughly every 15s (postTrackPointFrequency
|
||||
// in the session data) — polling twice that often would just re-fetch the
|
||||
// same 400+KB page for nothing most of the time.
|
||||
@@ -171,11 +176,22 @@ export class LiveTrackPanelControl {
|
||||
if (this._map.getSource(TRACK_SOURCE)) return;
|
||||
|
||||
this._map.addSource(TRACK_SOURCE, { type: "geojson", data: geojson });
|
||||
// Added before the actual track's own line layer so it paints
|
||||
// underneath — the recorded track is the thing that actually matters
|
||||
// when the two overlap.
|
||||
this._map.addLayer({
|
||||
id: `${TRACK_SOURCE}-planned`,
|
||||
type: "line",
|
||||
source: TRACK_SOURCE,
|
||||
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "kind"], "planned"]],
|
||||
layout: { "line-cap": "round", "line-join": "round" },
|
||||
paint: { "line-color": PLANNED_COLOR, "line-width": 2, "line-dasharray": [2, 2] },
|
||||
});
|
||||
this._map.addLayer({
|
||||
id: `${TRACK_SOURCE}-line`,
|
||||
type: "line",
|
||||
source: TRACK_SOURCE,
|
||||
filter: ["==", ["geometry-type"], "LineString"],
|
||||
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "kind"], "actual"]],
|
||||
layout: { "line-cap": "round", "line-join": "round" },
|
||||
paint: { "line-color": TRACK_COLOR, "line-width": 3 },
|
||||
});
|
||||
|
||||
@@ -607,6 +607,11 @@ fetch("/api/terrain-tiles")
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch("/api/hiking-trails")
|
||||
.then((response) => response.json())
|
||||
.then(({ countries }) => overlaysControl.setHikingTrailsOfflineCountries(countries))
|
||||
.catch(() => {});
|
||||
|
||||
fetch("/api/kiwix")
|
||||
.then((response) => response.json())
|
||||
.then(({ available }) => overlaysControl.setWikiAvailable(available))
|
||||
|
||||
@@ -317,7 +317,9 @@ function haversine([lng1, lat1], [lng2, lat2]) {
|
||||
return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
function lineLength(points) {
|
||||
// Exported — also used by hiking_trails.js to total up an OSM route
|
||||
// relation's member ways into one distance, same haversine-sum approach.
|
||||
export function lineLength(points) {
|
||||
let total = 0;
|
||||
for (let i = 1; i < points.length; i++) total += haversine(points[i - 1], points[i]);
|
||||
return total;
|
||||
@@ -347,7 +349,8 @@ function polygonArea(points) {
|
||||
return Math.abs((area * EARTH_RADIUS_M * EARTH_RADIUS_M) / 2);
|
||||
}
|
||||
|
||||
function formatDistance(meters) {
|
||||
// Exported — also used by hiking_trails.js's route-details popup.
|
||||
export function formatDistance(meters) {
|
||||
if (meters === 0) return "";
|
||||
return meters >= 1000 ? `${(meters / 1000).toFixed(2)} km` : `${Math.round(meters)} m`;
|
||||
}
|
||||
|
||||
@@ -206,23 +206,31 @@ class DataFileRow {
|
||||
this._refreshStatus();
|
||||
}
|
||||
|
||||
// Single place that writes to _statusEl, so the error styling (pre-wrap,
|
||||
// scrollable, red — see style.css) never stays stuck on once a build's
|
||||
// failure message has scrolled past into a normal status line.
|
||||
_setStatus(text, isError = false) {
|
||||
this._statusEl.textContent = text;
|
||||
this._statusEl.classList.toggle("offline-data-panel__status--error", isError);
|
||||
}
|
||||
|
||||
async _refreshStatus() {
|
||||
let data;
|
||||
try {
|
||||
data = await (await fetch(this._statusUrl)).json();
|
||||
} catch {
|
||||
this._statusEl.textContent = "Could not check status.";
|
||||
this._setStatus("Could not check status.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.available) {
|
||||
this._statusEl.textContent = "Not generated yet.";
|
||||
this._setStatus("Not generated yet.");
|
||||
this._deleteButton.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = new Date(data.mtime).toLocaleString([], { dateStyle: "short", timeStyle: "short" });
|
||||
this._statusEl.textContent = `${formatBytes(data.size)} · updated ${updated}${this._formatMeta(data.meta)}`;
|
||||
this._setStatus(`${formatBytes(data.size)} · updated ${updated}${this._formatMeta(data.meta)}`);
|
||||
this._deleteButton.hidden = !isLoggedIn();
|
||||
this._checkUpdate();
|
||||
}
|
||||
@@ -274,7 +282,7 @@ class DataFileRow {
|
||||
// Called by OfflineDataControl once RegionPicker's Apply has saved a
|
||||
// new selection — not tied to a button of this row's own.
|
||||
async triggerBuild() {
|
||||
this._statusEl.textContent = "Checking…";
|
||||
this._setStatus("Checking…");
|
||||
try {
|
||||
// false (not null/true — see offline_update_available? in
|
||||
// pmtiles_builder.rb) means the just-saved selection matches what's
|
||||
@@ -291,17 +299,17 @@ class DataFileRow {
|
||||
// couldn't tell — fall through and build rather than silently skip
|
||||
}
|
||||
|
||||
this._statusEl.textContent = "Starting…";
|
||||
this._setStatus("Starting…");
|
||||
try {
|
||||
const response = await fetch(this._buildUrl, { method: "POST" });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `build failed: ${response.status}`);
|
||||
} catch (err) {
|
||||
this._statusEl.textContent = err.message;
|
||||
this._setStatus(err.message, true);
|
||||
return;
|
||||
}
|
||||
|
||||
this._statusEl.textContent = "Building…";
|
||||
this._setStatus("Building…");
|
||||
this._poll();
|
||||
}
|
||||
|
||||
@@ -318,13 +326,13 @@ class DataFileRow {
|
||||
}
|
||||
|
||||
if (status.state === "running") {
|
||||
this._statusEl.textContent = formatRunning(status);
|
||||
this._setStatus(formatRunning(status));
|
||||
this._poll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.state === "error") {
|
||||
this._statusEl.textContent = `Build failed: ${status.message}`;
|
||||
this._setStatus(`Build failed: ${status.message}`, true);
|
||||
} else {
|
||||
this._refreshStatus();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { svgToImageData, badgeImageId, escapeHtml } from "/js/gpx.js";
|
||||
import { RadarControl } from "/js/radar.js";
|
||||
import { ShadowControl } from "/js/shadow.js";
|
||||
import { ContourControl } from "/js/contour.js";
|
||||
import { HikingTrailsControl } from "/js/hiking_trails.js";
|
||||
|
||||
const ICON_IMAGE_SIZE = 48; // rasterized once at this size, rendered much smaller by icon-size below
|
||||
const ICON_SIZE = 0.3; // 48 * 0.3 = ~14px, sized to sit inside the marker badge below
|
||||
@@ -184,6 +185,7 @@ export class OverlaysControl {
|
||||
this._radar = new RadarControl(map);
|
||||
this._shadow = new ShadowControl(map);
|
||||
this._contour = new ContourControl(map);
|
||||
this._hikingTrails = new HikingTrailsControl(map);
|
||||
this._wikiEnabled = false; // the checkbox's own state
|
||||
this._wikiMoveHandler = null; // the moveend listener driving its live refetch, while enabled
|
||||
this._wikiRequestSeq = 0; // ignores an in-flight refetch a newer one has already superseded
|
||||
@@ -217,13 +219,15 @@ export class OverlaysControl {
|
||||
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
|
||||
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
|
||||
<li class="overlays-panel__item"></li>
|
||||
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
|
||||
`;
|
||||
const [hillshadeItem, wikiItem, radarItem, shadowItem, contourItem] = list.children;
|
||||
const [hillshadeItem, wikiItem, radarItem, shadowItem, contourItem, hikingTrailsItem] = list.children;
|
||||
this._hillshade.mount(hillshadeItem);
|
||||
this._mountWiki(wikiItem);
|
||||
this._radar.mount(radarItem);
|
||||
this._shadow.mount(shadowItem);
|
||||
this._contour.mount(contourItem);
|
||||
this._hikingTrails.mount(hikingTrailsItem);
|
||||
|
||||
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
|
||||
|
||||
@@ -241,6 +245,10 @@ export class OverlaysControl {
|
||||
this._hillshade.setOfflineAvailable(available);
|
||||
}
|
||||
|
||||
setHikingTrailsOfflineCountries(countries) {
|
||||
this._hikingTrails.setOfflineCountries(countries);
|
||||
}
|
||||
|
||||
// Called by map.js's PitchToggleControl whenever the 3D pitch toggle
|
||||
// changes, so raised terrain follows the camera tilt rather than needing
|
||||
// the "Terrain shading" checkbox re-clicked to notice.
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Steve Vigneau
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+3557
File diff suppressed because it is too large.
Load diff
@@ -39,6 +39,19 @@ class AppTest < Minitest::Test
|
||||
assert_equal({ "available" => false }, json_body(response))
|
||||
end
|
||||
|
||||
def test_hiking_trails_reports_no_countries_when_none_extracted_yet
|
||||
response = request.get("/api/hiking-trails")
|
||||
assert_equal({ "countries" => [] }, json_body(response))
|
||||
end
|
||||
|
||||
def test_hiking_trails_file_404s_for_a_country_with_no_extract
|
||||
assert_equal 404, request.get("/tiles/hiking_trails/SK.geojson").status
|
||||
end
|
||||
|
||||
def test_hiking_trails_file_404s_for_a_malformed_country_code
|
||||
assert_equal 404, request.get("/tiles/hiking_trails/../../etc/passwd.geojson").status
|
||||
end
|
||||
|
||||
def test_kiwix_reports_unavailable_when_no_url_configured
|
||||
response = request.get("/api/kiwix")
|
||||
assert_equal({ "available" => false, "url" => "" }, json_body(response))
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
require_relative "test_helper"
|
||||
require "maptool/hiking_trails"
|
||||
|
||||
# No real osmium calls — extract() is a thin subprocess-plus-parse wrapper
|
||||
# (same "not independently tested" reasoning OverpassPois.fetch gets), but
|
||||
# parse_opl is pure text in, GeoJSON out, and that's the part actually worth
|
||||
# protecting: the OPL escaping rules and the way-id -> relation-member
|
||||
# stitching are easy to get subtly wrong and osmium isn't installed in every
|
||||
# environment this test suite runs in.
|
||||
class HikingTrailsTest < Minitest::Test
|
||||
def test_parse_opl_builds_a_multilinestring_from_member_way_geometry
|
||||
opl = <<~OPL
|
||||
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
|
||||
w2 Thighway=path Nn11x20.088y49.18,n12x20.089y49.181
|
||||
r1 Troute=hiking,type=route,name=Rysy Mw1@,w2@
|
||||
OPL
|
||||
|
||||
features = Maptool::HikingTrails.parse_opl(opl)["features"]
|
||||
|
||||
assert_equal 1, features.size
|
||||
feature = features.first
|
||||
assert_equal "MultiLineString", feature["geometry"]["type"]
|
||||
assert_equal [[[20.087, 49.179], [20.088, 49.18]], [[20.088, 49.18], [20.089, 49.181]]], feature["geometry"]["coordinates"]
|
||||
assert_equal "Rysy", feature["properties"]["name"]
|
||||
end
|
||||
|
||||
def test_parse_opl_skips_relations_that_are_not_route_hiking
|
||||
opl = <<~OPL
|
||||
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
|
||||
r1 Ttype=multipolygon Mw1@outer
|
||||
OPL
|
||||
|
||||
assert_empty Maptool::HikingTrails.parse_opl(opl)["features"]
|
||||
end
|
||||
|
||||
def test_parse_opl_falls_back_to_description_when_there_is_no_name
|
||||
opl = <<~OPL
|
||||
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
|
||||
r1 Troute=hiking,type=route,description=Rázc.%20%pri%20%Žabom%20%potoku%20%-%20%Rysy Mw1@
|
||||
OPL
|
||||
|
||||
feature = Maptool::HikingTrails.parse_opl(opl)["features"].first
|
||||
|
||||
assert_equal "Rázc. pri Žabom potoku - Rysy", feature["properties"]["name"]
|
||||
end
|
||||
|
||||
def test_parse_opl_unescapes_commas_and_equals_in_tag_values
|
||||
opl = <<~OPL
|
||||
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
|
||||
r1 Troute=hiking,type=route,name=Salt%2c%%20%Pepper%3d%Co Mw1@
|
||||
OPL
|
||||
|
||||
feature = Maptool::HikingTrails.parse_opl(opl)["features"].first
|
||||
|
||||
assert_equal "Salt, Pepper=Co", feature["properties"]["name"]
|
||||
end
|
||||
|
||||
def test_parse_opl_carries_over_colour_network_operator_ref
|
||||
opl = <<~OPL
|
||||
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
|
||||
r1 Troute=hiking,type=route,name=Trail,colour=red,network=rwn,operator=KST,ref=0933 Mw1@
|
||||
OPL
|
||||
|
||||
properties = Maptool::HikingTrails.parse_opl(opl)["features"].first["properties"]
|
||||
|
||||
assert_equal "red", properties["colour"]
|
||||
assert_equal "rwn", properties["network"]
|
||||
assert_equal "KST", properties["operator"]
|
||||
assert_equal "0933", properties["ref"]
|
||||
end
|
||||
|
||||
def test_parse_opl_computes_distance_from_member_way_geometry
|
||||
# Roughly 111.2m per 0.001 degree of latitude at the equator.
|
||||
opl = <<~OPL
|
||||
w1 Thighway=path Nn10x0.0y0.0,n11x0.0y0.001
|
||||
r1 Troute=hiking,type=route,name=Trail Mw1@
|
||||
OPL
|
||||
|
||||
distance = Maptool::HikingTrails.parse_opl(opl)["features"].first["properties"]["distance_meters"]
|
||||
|
||||
assert_in_delta 111.2, distance, 1.0
|
||||
end
|
||||
|
||||
def test_parse_opl_drops_a_relation_whose_member_ways_are_all_missing
|
||||
opl = <<~OPL
|
||||
r1 Troute=hiking,type=route,name=Ghost Mw999@
|
||||
OPL
|
||||
|
||||
assert_empty Maptool::HikingTrails.parse_opl(opl)["features"]
|
||||
end
|
||||
|
||||
def test_parse_opl_carries_the_osm_relation_id_for_cross_country_dedup
|
||||
opl = <<~OPL
|
||||
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
|
||||
r1246205 Troute=hiking,type=route,name=Trail Mw1@
|
||||
OPL
|
||||
|
||||
feature = Maptool::HikingTrails.parse_opl(opl)["features"].first
|
||||
|
||||
assert_equal 1246205, feature["properties"]["id"]
|
||||
end
|
||||
|
||||
def test_parse_opl_rounds_coordinates_to_5_decimal_places
|
||||
opl = <<~OPL
|
||||
w1 Thighway=path Nn10x20.0876543210y49.1791234567,n11x20.0880000001y49.1800000009
|
||||
r1 Troute=hiking,type=route,name=Trail Mw1@
|
||||
OPL
|
||||
|
||||
coords = Maptool::HikingTrails.parse_opl(opl)["features"].first["geometry"]["coordinates"]
|
||||
|
||||
assert_equal [[[20.08765, 49.17912], [20.088, 49.18]]], coords
|
||||
end
|
||||
|
||||
def test_parse_opl_ignores_a_dangling_member_reference_but_keeps_the_rest
|
||||
opl = <<~OPL
|
||||
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
|
||||
r1 Troute=hiking,type=route,name=Trail Mw1@,w999@
|
||||
OPL
|
||||
|
||||
features = Maptool::HikingTrails.parse_opl(opl)["features"]
|
||||
|
||||
assert_equal 1, features.size
|
||||
assert_equal 1, features.first["geometry"]["coordinates"].size
|
||||
end
|
||||
end
|
||||
@@ -16,6 +16,7 @@ class LiveTrackClientTest < Minitest::Test
|
||||
<script>self.__next_f.push([1,"6:[\\"$\\",\\"div\\",null,{}]"])</script>
|
||||
<script>self.__next_f.push([1,"9:{\\"session\\":{\\"sessionName\\":\\"Test Ride\\",\\"start\\":\\"2026-01-01T00:00:00Z\\",\\"end\\":\\"2026-01-01T01:00:00Z\\"}}"])</script>
|
||||
<script>self.__next_f.push([1,"10:{\\"unrelated\\":true,\\"nested\\":{\\"data\\":{\\"pages\\":[{\\"trackPoints\\":[{\\"position\\":{\\"lat\\":48.1,\\"lon\\":17.2},\\"dateTime\\":\\"2026-01-01T00:00:00Z\\",\\"totalDistanceMeters\\":\\"$undefined\\"},{\\"position\\":{\\"lat\\":48.2,\\"lon\\":17.3},\\"dateTime\\":\\"2026-01-01T00:01:00Z\\",\\"activityType\\":\\"CYCLING\\",\\"totalDistanceMeters\\":5000,\\"totalDurationSecs\\":600}]}]}}}"])</script>
|
||||
<script>self.__next_f.push([1,"11:{\\"courses\\":[{\\"name\\":\\"40 km okruh\\",\\"geoPoints\\":[{\\"position\\":{\\"lat\\":48.05,\\"lon\\":17.35},\\"distanceMeters\\":0},{\\"position\\":{\\"lat\\":48.06,\\"lon\\":17.36},\\"distanceMeters\\":1000}]}]}"])</script>
|
||||
</body></html>
|
||||
HTML
|
||||
|
||||
@@ -49,18 +50,52 @@ class LiveTrackClientTest < Minitest::Test
|
||||
assert_equal "Test Ride", summary["session_name"]
|
||||
assert_equal "CYCLING", summary["activity_type"]
|
||||
assert_equal 5000, summary["distance_meters"]
|
||||
refute summary["active"], "session has an end timestamp"
|
||||
refute summary["active"], "last track point is long in the past"
|
||||
end
|
||||
|
||||
def test_summarize_marks_active_when_session_has_no_end
|
||||
# Not session["end"].nil? (the original signal, before 2026-09-20 — see
|
||||
# LiveTrackClient's own comment): verified live against a real,
|
||||
# still-recording session that day, Garmin had already set `end` to
|
||||
# exactly start + 24h (the share link's own expiry, not an end time),
|
||||
# so that check called an active session "ended".
|
||||
def test_summarize_marks_active_when_last_point_is_recent
|
||||
payloads = @client.send(:parse_payloads, FAKE_PAGE)
|
||||
points = @client.send(:clean, @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) })
|
||||
session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] }).merge("end" => nil)
|
||||
points[-1] = points[-1].merge("dateTime" => Time.now.utc.iso8601)
|
||||
# end present and in the future, same shape Garmin actually sends for a
|
||||
# session that's still live — active must come from the point, not this.
|
||||
session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] })
|
||||
.merge("end" => (Time.now.utc + 24 * 60 * 60).iso8601)
|
||||
result = Maptool::LiveTrackClient::Result.new(points: points, session: session)
|
||||
|
||||
assert Maptool::LiveTrackClient.summarize(result)["active"]
|
||||
end
|
||||
|
||||
# Verified live the same day: Garmin updates a real session's `end` from
|
||||
# the future placeholder to within a second of the last point's own
|
||||
# time immediately once the activity is saved on the watch — so a fresh
|
||||
# last point alone isn't enough, `end` already being in the past has to
|
||||
# win even then.
|
||||
def test_summarize_marks_inactive_once_end_is_in_the_past_even_with_a_fresh_point
|
||||
payloads = @client.send(:parse_payloads, FAKE_PAGE)
|
||||
points = @client.send(:clean, @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) })
|
||||
points[-1] = points[-1].merge("dateTime" => Time.now.utc.iso8601)
|
||||
session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] })
|
||||
.merge("end" => (Time.now.utc - 1).iso8601)
|
||||
result = Maptool::LiveTrackClient::Result.new(points: points, session: session)
|
||||
|
||||
refute Maptool::LiveTrackClient.summarize(result)["active"]
|
||||
end
|
||||
|
||||
def test_finds_the_planned_course_alongside_the_actual_track
|
||||
payloads = @client.send(:parse_payloads, FAKE_PAGE)
|
||||
course = @client.send(:clean, @client.send(:search, payloads) { |n| n["courses"].first if n["courses"].is_a?(Array) && !n["courses"].empty? })
|
||||
|
||||
assert_equal "40 km okruh", course["name"]
|
||||
assert_equal 2, course["geoPoints"].size
|
||||
assert_in_delta 48.06, course["geoPoints"].last["position"]["lat"]
|
||||
end
|
||||
|
||||
def test_no_track_points_found_in_a_page_without_them
|
||||
points = @client.send(:search, @client.send(:parse_payloads, "<html></html>")) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }
|
||||
assert_nil points
|
||||
|
||||
@@ -47,6 +47,21 @@ class PmtilesBuilderTest < Minitest::Test
|
||||
File.write(@pmtiles_fail, "#!/bin/sh\necho 'boom' >&2\nexit 1\n")
|
||||
File.chmod(0o755, @pmtiles_fail)
|
||||
|
||||
# Mimics go-pmtiles' real extract failure mode: hundreds of progress
|
||||
# lines before the actual error — see command_output's tail-truncation.
|
||||
@pmtiles_fail_noisy = File.join(@bin_dir, "pmtiles-fail-noisy")
|
||||
File.write(@pmtiles_fail_noisy, <<~SH)
|
||||
#!/bin/sh
|
||||
i=0
|
||||
while [ "$i" -lt 200 ]; do
|
||||
echo "fetching $i dirs, $i chunks, 1 requests" >&2
|
||||
i=$((i + 1))
|
||||
done
|
||||
echo "stream error: stream ID 9; INTERNAL_ERROR" >&2
|
||||
exit 1
|
||||
SH
|
||||
File.chmod(0o755, @pmtiles_fail_noisy)
|
||||
|
||||
# osmium merge PBF1 PBF2 ... -o OUTPUT — output is always "-o"'s next
|
||||
# arg, unlike pmtiles' varying-position output above.
|
||||
@osmium_ok = File.join(@bin_dir, "osmium-ok")
|
||||
@@ -61,6 +76,27 @@ class PmtilesBuilderTest < Minitest::Test
|
||||
SH
|
||||
File.chmod(0o755, @osmium_ok)
|
||||
|
||||
# Same shape as osmium-ok, but fails on tags-filter specifically (the
|
||||
# first subcommand HikingTrails.extract runs) while still succeeding
|
||||
# on merge — lets a single-country build (which never calls merge)
|
||||
# exercise "hiking-trails extraction failed" without also breaking
|
||||
# offline.pmtiles' own osmium use in a multi-country test.
|
||||
@osmium_fail_tags_filter = File.join(@bin_dir, "osmium-fail-tags-filter")
|
||||
File.write(@osmium_fail_tags_filter, <<~SH)
|
||||
#!/bin/sh
|
||||
echo "osmium $1" >> "$PMTILES_LOG"
|
||||
if [ "$1" = "tags-filter" ]; then
|
||||
echo "boom" >&2
|
||||
exit 1
|
||||
fi
|
||||
prev=""
|
||||
for arg; do
|
||||
if [ "$prev" = "-o" ]; then echo fake > "$arg"; fi
|
||||
prev="$arg"
|
||||
done
|
||||
SH
|
||||
File.chmod(0o755, @osmium_fail_tags_filter)
|
||||
|
||||
# Stands in for `java -jar planetiler.jar --osm-path=... --output=OUT`
|
||||
# — PLANETILER_JAR itself is irrelevant here (this fake ignores -jar's
|
||||
# argument entirely), only JAVA_BIN needs pointing at it.
|
||||
@@ -173,13 +209,66 @@ class PmtilesBuilderTest < Minitest::Test
|
||||
assert_path_exists File.join(@data_dir, "offline.pmtiles")
|
||||
refute_path_exists File.join(@data_dir, "offline.tmp.pmtiles")
|
||||
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
|
||||
assert_equal ["planetiler --osm-path"], log_lines
|
||||
assert_equal ["planetiler --osm-path", "osmium tags-filter", "osmium add-locations-to-ways", "osmium cat"], log_lines
|
||||
assert_equal(
|
||||
{ "sources" => [{ "code" => "SK", "slug" => "europe/slovakia", "last_modified" => "Wed, 10 Sep 2026 08:00:01 GMT" }] },
|
||||
Maptool::PmtilesBuilder.offline_meta(@data_dir),
|
||||
)
|
||||
end
|
||||
|
||||
def test_build_offline_also_writes_a_per_country_hiking_trails_geojson
|
||||
select_regions("SK")
|
||||
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download)
|
||||
thread.join
|
||||
|
||||
# The fake osmium binary doesn't produce real OPL output, just
|
||||
# confirms the file lands (one per country) and is valid (empty)
|
||||
# GeoJSON — the real parsing logic is covered by test_hiking_trails.rb
|
||||
# against real OPL fixtures.
|
||||
assert_equal ["SK"], Maptool::PmtilesBuilder.hiking_trails_countries(@data_dir)
|
||||
path = Maptool::PmtilesBuilder.hiking_trails_country_path(@data_dir, "SK")
|
||||
assert_equal({ "type" => "FeatureCollection", "features" => [] }, JSON.parse(File.read(path)))
|
||||
end
|
||||
|
||||
def test_build_offline_only_re_extracts_hiking_trails_for_a_newly_added_country
|
||||
select_regions("SK")
|
||||
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download).join
|
||||
File.write(@log, "")
|
||||
|
||||
select_regions("SK", "AT")
|
||||
Maptool::PmtilesBuilder.build_offline(
|
||||
data_dir: @data_dir,
|
||||
download_extract: fake_download,
|
||||
fetch_last_modified: ->(_url) { "Wed, 10 Sep 2026 08:00:01 GMT" },
|
||||
).join
|
||||
|
||||
# SK's cached extract is already newer than SK.osm.pbf (untouched
|
||||
# since the first build), so only AT's hiking trails get (re-)extracted
|
||||
# — SK.osm.pbf itself is also skipped by cached_extract's own
|
||||
# Last-Modified check, "osmium merge" runs regardless since two
|
||||
# countries are now selected.
|
||||
assert_equal(
|
||||
["osmium merge", "planetiler --osm-path", "osmium tags-filter", "osmium add-locations-to-ways", "osmium cat"],
|
||||
log_lines,
|
||||
)
|
||||
assert_equal %w[AT SK], Maptool::PmtilesBuilder.hiking_trails_countries(@data_dir)
|
||||
end
|
||||
|
||||
# The actual reasoning: hiking trails are a bonus overlay bolted onto the
|
||||
# existing offline.pmtiles build, not the core map — an osmium hiccup on
|
||||
# this one step shouldn't take down an otherwise-successful build.
|
||||
def test_build_offline_still_succeeds_when_hiking_trails_extraction_fails
|
||||
select_regions("SK")
|
||||
ENV["OSMIUM_BIN"] = @osmium_fail_tags_filter
|
||||
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download)
|
||||
thread.join
|
||||
|
||||
assert_path_exists File.join(@data_dir, "offline.pmtiles")
|
||||
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
|
||||
refute_path_exists File.join(@data_dir, "hiking_trails.geojson")
|
||||
refute_path_exists "#{File.join(@data_dir, "hiking_trails.geojson")}.tmp"
|
||||
end
|
||||
|
||||
# Two countries selected — osmium merge should run before planetiler,
|
||||
# each downloaded source recorded in the sidecar meta.
|
||||
def test_build_offline_merges_multiple_selected_countries
|
||||
@@ -188,7 +277,14 @@ class PmtilesBuilderTest < Minitest::Test
|
||||
thread.join
|
||||
|
||||
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
|
||||
assert_equal ["osmium merge", "planetiler --osm-path"], log_lines
|
||||
assert_equal(
|
||||
[
|
||||
"osmium merge", "planetiler --osm-path",
|
||||
"osmium tags-filter", "osmium add-locations-to-ways", "osmium cat", # SK's own hiking-trails extraction
|
||||
"osmium tags-filter", "osmium add-locations-to-ways", "osmium cat", # AT's own
|
||||
],
|
||||
log_lines,
|
||||
)
|
||||
codes = Maptool::PmtilesBuilder.offline_meta(@data_dir)["sources"].map { |s| s["code"] }
|
||||
assert_equal %w[SK AT], codes
|
||||
end
|
||||
@@ -545,6 +641,22 @@ class PmtilesBuilderTest < Minitest::Test
|
||||
assert_match(/exited with status/, status[:message])
|
||||
end
|
||||
|
||||
# The real trigger for this: a Mapterhorn extract that failed after ~7000
|
||||
# progress lines, producing a 500k+ character status[:message] that was
|
||||
# unreadable in the web UI.
|
||||
def test_build_terrain_truncates_a_noisy_command_failure
|
||||
select_regions("ES")
|
||||
ENV["PMTILES_BIN"] = @pmtiles_fail_noisy
|
||||
thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest })
|
||||
thread.join
|
||||
|
||||
status = Maptool::PmtilesBuilder.terrain_status
|
||||
assert_equal "error", status[:state]
|
||||
assert_match(/\(\d+ earlier lines omitted\)/, status[:message])
|
||||
assert_match(/stream error: stream ID 9/, status[:message])
|
||||
assert_operator status[:message].length, :<, 5000
|
||||
end
|
||||
|
||||
def test_build_terrain_raises_when_one_is_already_running
|
||||
select_regions("ES")
|
||||
ENV["PMTILES_BIN"] = @pmtiles_ok
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
<div class="profile-panel__charts"></div>
|
||||
</div>
|
||||
|
||||
<div id="trail-panel" class="trail-panel" hidden>
|
||||
<button type="button" class="trail-panel__close" aria-label="Close">×</button>
|
||||
<div class="trail-panel__body"></div>
|
||||
</div>
|
||||
|
||||
<div id="basemap-panel" class="basemap-panel" hidden>
|
||||
<div class="basemap-panel__header">
|
||||
<strong>Base map</strong>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"/js/dem.js": "/js/dem.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/gnss.js": "/js/gnss.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/gpx.js": "/js/gpx.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/hiking_trails.js": "/js/hiking_trails.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/icons.js": "/js/icons.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/livetrack.js": "/js/livetrack.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/locate.js": "/js/locate.js?v=<%= ASSET_VERSION %>",
|
||||
|
||||
Reference in new issue
Block a user