Add in-app regeneration of offline.pmtiles and terrain.pmtiles
A logged-in user can now trigger, from a new "Offline data" sidebar panel, what was previously a fully manual go-pmtiles workflow (docs/development.adoc): regenerate either file for the current map viewport, no area-picker (same "visible area is the area" convention the Overpass POI plugin already uses). - lib/maptool/pmtiles_builder.rb: two independent background jobs (Thread + Mutex-guarded status Hash, same shape as app.rb's own GNSS poller), each writing to a .tmp path and atomically renaming into place so a build never serves a partially-written file mid-run. offline.pmtiles is a single `pmtiles extract` against Protomaps' public daily basemap build (byte-range extraction, not a full ~120GB download). terrain.pmtiles has no equivalent public Terrarium-encoded archive to extract from, so it fetches individual Terrarium tiles itself, packs them into an intermediate MBTiles (new sqlite3 gem), then the same `pmtiles convert`. - 4 new app.rb routes (POST .../build, GET .../build-status) plus size/mtime added to the existing availability endpoints. - public/js/offline_data.js: new sidebar panel, polling build-status the same self-rescheduling-setTimeout shape livetrack.js already uses for its own refresh loop. - Both network-heavy pieces (the Protomaps build index, per-tile fetches) are injectable, so test/test_pmtiles_builder.rb exercises the actual build pipeline against a fake `pmtiles` binary without hitting a real network, matching this suite's existing keep-it-offline convention for external services.
This commit is contained in:
1 parent
fba8ca10a2
commit
cd56ebe0a1
14 files changed
+740
-15
No files matched your search
@@ -6,6 +6,7 @@ gem "puma", "~> 8.0"
|
|||||||
gem "rexml", "~> 3.4"
|
gem "rexml", "~> 3.4"
|
||||||
gem "oauth2", "~> 2.0"
|
gem "oauth2", "~> 2.0"
|
||||||
gem "rubyzip", "~> 2.4"
|
gem "rubyzip", "~> 2.4"
|
||||||
|
gem "sqlite3", "~> 2.0"
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
gem "rerun"
|
gem "rerun"
|
||||||
|
|||||||
@@ -95,6 +95,16 @@ GEM
|
|||||||
snaky_hash (2.0.7)
|
snaky_hash (2.0.7)
|
||||||
hashie (>= 0.1.0, < 6)
|
hashie (>= 0.1.0, < 6)
|
||||||
version_gem (~> 1.1, >= 1.1.14)
|
version_gem (~> 1.1, >= 1.1.14)
|
||||||
|
sqlite3 (2.9.6-aarch64-linux-gnu)
|
||||||
|
sqlite3 (2.9.6-aarch64-linux-musl)
|
||||||
|
sqlite3 (2.9.6-arm-linux-gnu)
|
||||||
|
sqlite3 (2.9.6-arm-linux-musl)
|
||||||
|
sqlite3 (2.9.6-arm64-darwin)
|
||||||
|
sqlite3 (2.9.6-x86-linux-gnu)
|
||||||
|
sqlite3 (2.9.6-x86-linux-musl)
|
||||||
|
sqlite3 (2.9.6-x86_64-darwin)
|
||||||
|
sqlite3 (2.9.6-x86_64-linux-gnu)
|
||||||
|
sqlite3 (2.9.6-x86_64-linux-musl)
|
||||||
tilt (2.9.0)
|
tilt (2.9.0)
|
||||||
uri (1.1.1)
|
uri (1.1.1)
|
||||||
version_gem (1.1.15)
|
version_gem (1.1.15)
|
||||||
@@ -122,6 +132,7 @@ DEPENDENCIES
|
|||||||
rubyzip (~> 2.4)
|
rubyzip (~> 2.4)
|
||||||
sinatra (~> 4.2)
|
sinatra (~> 4.2)
|
||||||
sinatra-contrib (~> 4.2)
|
sinatra-contrib (~> 4.2)
|
||||||
|
sqlite3 (~> 2.0)
|
||||||
|
|
||||||
CHECKSUMS
|
CHECKSUMS
|
||||||
anonymous_loader (0.1.3) sha256=084a18e2439144d955447dc11dfc982f41fcd1583ad32d4d55151325dc44cb55
|
anonymous_loader (0.1.3) sha256=084a18e2439144d955447dc11dfc982f41fcd1583ad32d4d55151325dc44cb55
|
||||||
@@ -171,6 +182,16 @@ CHECKSUMS
|
|||||||
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
|
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
|
||||||
sinatra-contrib (4.2.1) sha256=10d091c944d268aa910c618ea40a3c3ebe0533e6e32990d84af92235a3d26b4a
|
sinatra-contrib (4.2.1) sha256=10d091c944d268aa910c618ea40a3c3ebe0533e6e32990d84af92235a3d26b4a
|
||||||
snaky_hash (2.0.7) sha256=7d02c70012a3f932e48860cd024577908300c9aa615e0cb9b450aaa749cbcb4d
|
snaky_hash (2.0.7) sha256=7d02c70012a3f932e48860cd024577908300c9aa615e0cb9b450aaa749cbcb4d
|
||||||
|
sqlite3 (2.9.6-aarch64-linux-gnu) sha256=d8b1f7d23efd7abac285775a9566562fc7debfef79d594e3a20354406fb7907c
|
||||||
|
sqlite3 (2.9.6-aarch64-linux-musl) sha256=3579e1c98cdc7ff5c3722847bb63ed4e1efb7ff675cb5e1e48ef2d4da5fb3bc9
|
||||||
|
sqlite3 (2.9.6-arm-linux-gnu) sha256=33541500e3615da02afe54a9cc38b17a6985d3cf9d8b76d6d0a83002f114e7ec
|
||||||
|
sqlite3 (2.9.6-arm-linux-musl) sha256=c5490af48bb228fefa54314e9541375c3907e70f8109f3881b5ff97e1c93ae33
|
||||||
|
sqlite3 (2.9.6-arm64-darwin) sha256=849b5d7f795e60fe25076d62c72dd722beb45b3850b516ad978d60ee848ec15b
|
||||||
|
sqlite3 (2.9.6-x86-linux-gnu) sha256=fbaa9f46f9708f57dd8a459b37fc269f9613e0cacf1547df01aa439cc45c20c0
|
||||||
|
sqlite3 (2.9.6-x86-linux-musl) sha256=6715026fbb5530e810b28ef43b9c4f84cd3c991f67b9d808a31fcc32b847abbd
|
||||||
|
sqlite3 (2.9.6-x86_64-darwin) sha256=b5842fea77781c14da03fa7bc0feb82db03a69e135affcb6f5399cbd2797a5f3
|
||||||
|
sqlite3 (2.9.6-x86_64-linux-gnu) sha256=613188ce02f614126ddbc38c5e217ccffd6306d0dcd9adca9764547aa890a634
|
||||||
|
sqlite3 (2.9.6-x86_64-linux-musl) sha256=d493b11818a3573387a1d56e1ee8fa00da23a683a7a1cc063e7a0feeed843abf
|
||||||
tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5
|
tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5
|
||||||
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
|
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
|
||||||
version_gem (1.1.15) sha256=a73241587b29252e3567e0c818ded80730eb754d43b508f6ce4db22ca9ba27d0
|
version_gem (1.1.15) sha256=a73241587b29252e3567e0c818ded80730eb754d43b508f6ce4db22ca9ba27d0
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ require_relative "lib/maptool/livetrack_store"
|
|||||||
require_relative "lib/maptool/livetrack_client"
|
require_relative "lib/maptool/livetrack_client"
|
||||||
require_relative "lib/maptool/kiwix"
|
require_relative "lib/maptool/kiwix"
|
||||||
require_relative "lib/maptool/plugins"
|
require_relative "lib/maptool/plugins"
|
||||||
|
require_relative "lib/maptool/pmtiles_builder"
|
||||||
|
|
||||||
module Maptool
|
module Maptool
|
||||||
class App < Sinatra::Base
|
class App < Sinatra::Base
|
||||||
@@ -125,6 +126,12 @@ module Maptool
|
|||||||
halt 401, json(error: "login required", login_url: "/login") unless logged_in?
|
halt 401, json(error: "login required", login_url: "/login") unless logged_in?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def pmtiles_file_status(path)
|
||||||
|
return { available: false } unless File.exist?(path)
|
||||||
|
|
||||||
|
{ available: true, size: File.size(path), mtime: File.mtime(path).utc.iso8601 }
|
||||||
|
end
|
||||||
|
|
||||||
def callback_url
|
def callback_url
|
||||||
"#{request.base_url}/auth/callback"
|
"#{request.base_url}/auth/callback"
|
||||||
end
|
end
|
||||||
@@ -204,7 +211,7 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
get "/api/offline-tiles" do
|
get "/api/offline-tiles" do
|
||||||
json available: File.exist?(OFFLINE_PMTILES_PATH)
|
json pmtiles_file_status(OFFLINE_PMTILES_PATH)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Served as a plain range-request-capable file, not decoded server-side:
|
# Served as a plain range-request-capable file, not decoded server-side:
|
||||||
@@ -216,11 +223,33 @@ module Maptool
|
|||||||
send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream"
|
send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Regenerates offline.pmtiles for the given bbox (the current map
|
||||||
|
# viewport, same "visible area is the area" convention as the Overpass
|
||||||
|
# POI plugin) by extracting it from Protomaps' own daily full-planet
|
||||||
|
# basemap build — see lib/maptool/pmtiles_builder.rb. Runs in the
|
||||||
|
# background; poll build-status below for progress.
|
||||||
|
post "/api/offline-tiles/build" do
|
||||||
|
require_login!
|
||||||
|
bbox = %w[south west north east].map { |key| params[key].to_s }
|
||||||
|
halt 400, json(error: "south, west, north, east are required") if bbox.any?(&:empty?)
|
||||||
|
|
||||||
|
begin
|
||||||
|
PmtilesBuilder.build_offline(bbox.map(&:to_f), data_dir: DATA_DIR)
|
||||||
|
rescue PmtilesBuilder::BuildError => e
|
||||||
|
halt 409, json(error: e.message)
|
||||||
|
end
|
||||||
|
json status: "started"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/api/offline-tiles/build-status" do
|
||||||
|
json PmtilesBuilder.offline_status
|
||||||
|
end
|
||||||
|
|
||||||
# Same optional-local-file story as offline.pmtiles above — see
|
# Same optional-local-file story as offline.pmtiles above — see
|
||||||
# docs/development.adoc for how to generate one, until then hillshade
|
# docs/development.adoc for how to generate one, until then hillshade
|
||||||
# just stays unavailable while the offline basemap is active.
|
# just stays unavailable while the offline basemap is active.
|
||||||
get "/api/terrain-tiles" do
|
get "/api/terrain-tiles" do
|
||||||
json available: File.exist?(TERRAIN_PMTILES_PATH)
|
json pmtiles_file_status(TERRAIN_PMTILES_PATH)
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/tiles/terrain.pmtiles" do
|
get "/tiles/terrain.pmtiles" do
|
||||||
@@ -229,6 +258,23 @@ module Maptool
|
|||||||
send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream"
|
send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
post "/api/terrain-tiles/build" do
|
||||||
|
require_login!
|
||||||
|
bbox = %w[south west north east].map { |key| params[key].to_s }
|
||||||
|
halt 400, json(error: "south, west, north, east are required") if bbox.any?(&:empty?)
|
||||||
|
|
||||||
|
begin
|
||||||
|
PmtilesBuilder.build_terrain(bbox.map(&:to_f), data_dir: DATA_DIR)
|
||||||
|
rescue PmtilesBuilder::BuildError => e
|
||||||
|
halt 409, json(error: e.message)
|
||||||
|
end
|
||||||
|
json status: "started"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/api/terrain-tiles/build-status" do
|
||||||
|
json PmtilesBuilder.terrain_status
|
||||||
|
end
|
||||||
|
|
||||||
# Pinged once per page load (public/js/map.js) — url is a same-origin
|
# 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
|
# 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
|
# directly behind the reverse proxy in front of this app), proxied by
|
||||||
|
|||||||
+43
-13
@@ -27,18 +27,25 @@ reload (e.g. Ctrl/Cmd+Shift+R) if that happens.
|
|||||||
|
|
||||||
To enable the offline style, place a PMTiles file at
|
To enable the offline style, place a PMTiles file at
|
||||||
`data/offline.pmtiles` (see `docs/architecture.adoc` for how it is
|
`data/offline.pmtiles` (see `docs/architecture.adoc` for how it is
|
||||||
served and styled). It is not part of the repository — obtain or
|
served and styled). Logged-in users can generate this from the app
|
||||||
build one locally:
|
itself — see "In-app regeneration" below — or it can be built by hand:
|
||||||
|
|
||||||
* The simplest source is a pre-built extract from
|
* Extract a region from Protomaps' own daily full-planet basemap build
|
||||||
https://app.protomaps.com[protomaps.com's extract builder] — pick a
|
(https://build-metadata.protomaps.dev/builds.json[build index],
|
||||||
region and download it as `.pmtiles`. Its schema is what
|
files at `https://build.protomaps.com/<key>`, ~120GB, ODbL/OSM
|
||||||
`protomaps-themes-base` (the styling library used for the offline
|
attribution) with the
|
||||||
style) expects.
|
https://github.com/protomaps/go-pmtiles[go-pmtiles] CLI's `extract`
|
||||||
|
subcommand, which pulls only the byte ranges covering a bbox over
|
||||||
|
HTTP range requests rather than the whole file:
|
||||||
|
`pmtiles extract https://build.protomaps.com/<key> data/offline.pmtiles --bbox=west,south,east,north --maxzoom=15`.
|
||||||
|
(The old `app.protomaps.com` extract-builder UI this section used to
|
||||||
|
point at has since redirected elsewhere — this is the same
|
||||||
|
underlying mechanism, just the CLI instead of a web UI.)
|
||||||
* To convert some other vector tile source (e.g. an `.mbtiles` file)
|
* To convert some other vector tile source (e.g. an `.mbtiles` file)
|
||||||
into PMTiles, use the
|
into PMTiles instead, use the same CLI's `convert` subcommand:
|
||||||
https://github.com/protomaps/go-pmtiles[go-pmtiles] CLI:
|
`pmtiles convert in.mbtiles data/offline.pmtiles`. Its schema is
|
||||||
`pmtiles convert in.mbtiles data/offline.pmtiles`.
|
what `protomaps-themes-base` (the styling library used for the
|
||||||
|
offline style) expects.
|
||||||
|
|
||||||
Once the file is in place, `GET /api/offline-tiles` reports
|
Once the file is in place, `GET /api/offline-tiles` reports
|
||||||
`available: true` and the map defaults to the offline style.
|
`available: true` and the map defaults to the offline style.
|
||||||
@@ -50,15 +57,38 @@ The sidebar's "Terrain shading" toggle works online out of the box
|
|||||||
no API key). For it to work offline too, place a PMTiles archive of
|
no API key). For it to work offline too, place a PMTiles archive of
|
||||||
Terrarium-encoded `raster-dem` tiles at `data/terrain.pmtiles`
|
Terrarium-encoded `raster-dem` tiles at `data/terrain.pmtiles`
|
||||||
(`GET /api/terrain-tiles` reports `available: true` once it's there,
|
(`GET /api/terrain-tiles` reports `available: true` once it's there,
|
||||||
same pattern as offline map data above). It is not part of the
|
same pattern as offline map data above). Also generatable in-app (see
|
||||||
repository — build one locally from a DEM source (e.g. Copernicus
|
below) or by hand — build one locally from a DEM source (e.g.
|
||||||
GLO-30 or SRTM) with a tool like
|
Copernicus GLO-30 or SRTM) with a tool like
|
||||||
https://github.com/mapbox/rio-rgbify[rio-rgbify] (GeoTIFF ->
|
https://github.com/mapbox/rio-rgbify[rio-rgbify] (GeoTIFF ->
|
||||||
Terrarium-encoded PNG tiles in an `.mbtiles`), then
|
Terrarium-encoded PNG tiles in an `.mbtiles`), then
|
||||||
`pmtiles convert out.mbtiles data/terrain.pmtiles` with
|
`pmtiles convert out.mbtiles data/terrain.pmtiles` with
|
||||||
https://github.com/protomaps/go-pmtiles[go-pmtiles], same as
|
https://github.com/protomaps/go-pmtiles[go-pmtiles], same as
|
||||||
`data/offline.pmtiles`.
|
`data/offline.pmtiles`.
|
||||||
|
|
||||||
|
== In-app regeneration
|
||||||
|
|
||||||
|
A logged-in user can also generate both files above from the app
|
||||||
|
itself, no local tooling needed — the sidebar's "Offline data" icon
|
||||||
|
(Offline hub group) shows each file's status (size, last updated) and
|
||||||
|
a "Regenerate for current view" button. The region is always the
|
||||||
|
current map viewport, same "no area-picker, the visible area is the
|
||||||
|
area" convention the Overpass POI plugin already uses below. This
|
||||||
|
wraps exactly the manual `go-pmtiles` workflows documented above
|
||||||
|
(`lib/maptool/pmtiles_builder.rb`) — the server needs the
|
||||||
|
https://github.com/protomaps/go-pmtiles[go-pmtiles] CLI installed and
|
||||||
|
on `PATH` (override with the `PMTILES_BIN` env var if it lives
|
||||||
|
somewhere else); regenerating terrain.pmtiles this way fetches
|
||||||
|
individual Terrarium tiles itself and packs them into an MBTiles file
|
||||||
|
(the `sqlite3` gem) before the same `pmtiles convert` step, since no
|
||||||
|
public Terrarium-encoded PMTiles archive exists to `extract` from the
|
||||||
|
way offline.pmtiles above can.
|
||||||
|
|
||||||
|
A build runs in the background (`POST /api/offline-tiles/build` /
|
||||||
|
`/api/terrain-tiles/build`, polled via the matching `*/build-status`
|
||||||
|
route) — only one of each kind at a time; starting a second while one's
|
||||||
|
running gets a 409.
|
||||||
|
|
||||||
== Camper-relevant POIs (Overpass)
|
== Camper-relevant POIs (Overpass)
|
||||||
|
|
||||||
`rake overpass:pois[south,west,north,east,out.geojson]` queries
|
`rake overpass:pois[south,west,north,east,out.geojson]` queries
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
require "open3"
|
||||||
|
require "net/http"
|
||||||
|
require "uri"
|
||||||
|
require "json"
|
||||||
|
require "sqlite3"
|
||||||
|
require "tmpdir"
|
||||||
|
|
||||||
|
module Maptool
|
||||||
|
# Regenerates data/offline.pmtiles and data/terrain.pmtiles for a given
|
||||||
|
# bbox by shelling out to go-pmtiles — the same tool docs/development.adoc
|
||||||
|
# already documents running by hand. No job queue: each build runs on its
|
||||||
|
# own background Thread and reports into a Mutex-guarded status Hash a
|
||||||
|
# route can poll, the same shape app.rb's own GNSS poller (GNSS_LATEST/
|
||||||
|
# GNSS_LATEST_MUTEX) already uses.
|
||||||
|
module PmtilesBuilder
|
||||||
|
class BuildError < StandardError; end
|
||||||
|
|
||||||
|
# Protomaps' own daily full-planet basemap build index — extract_offline
|
||||||
|
# below only pulls the HTTP byte ranges covering the requested bbox
|
||||||
|
# (go-pmtiles's `extract` supports a remote source over range requests),
|
||||||
|
# never the whole ~120GB file.
|
||||||
|
PROTOMAPS_BUILDS_INDEX = "https://build-metadata.protomaps.dev/builds.json"
|
||||||
|
PROTOMAPS_BUILD_BASE = "https://build.protomaps.com"
|
||||||
|
|
||||||
|
# Same public, no-API-key Terrarium tile source public/js/overlays.js's
|
||||||
|
# ONLINE_TERRAIN_TILES already fetches client-side — no shared constant
|
||||||
|
# between Ruby and JS, just the same literal URL template. Matches that
|
||||||
|
# source's own online maxzoom.
|
||||||
|
TERRARIUM_URL_TEMPLATE = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/%<z>d/%<x>d/%<y>d.png"
|
||||||
|
TERRAIN_MAX_ZOOM = 15
|
||||||
|
|
||||||
|
OFFLINE_STATUS = { state: "idle" }
|
||||||
|
TERRAIN_STATUS = { state: "idle" }
|
||||||
|
OFFLINE_MUTEX = Mutex.new
|
||||||
|
TERRAIN_MUTEX = Mutex.new
|
||||||
|
|
||||||
|
def self.offline_status
|
||||||
|
OFFLINE_MUTEX.synchronize { OFFLINE_STATUS.dup }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.terrain_status
|
||||||
|
TERRAIN_MUTEX.synchronize { TERRAIN_STATUS.dup }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test-only: the status hashes are process-wide state (same caveat as
|
||||||
|
# GPX_STORE/LIVETRACK_STORE in app.rb), so the suite needs a way to
|
||||||
|
# reset them between tests the way AppTestHelper#teardown already does
|
||||||
|
# for those.
|
||||||
|
def self.reset!
|
||||||
|
OFFLINE_MUTEX.synchronize { OFFLINE_STATUS.replace(state: "idle") }
|
||||||
|
TERRAIN_MUTEX.synchronize { TERRAIN_STATUS.replace(state: "idle") }
|
||||||
|
end
|
||||||
|
|
||||||
|
# bounds: [south, west, north, east]. Returns once the background Thread
|
||||||
|
# has started, not once the build finishes — raises BuildError (the
|
||||||
|
# caller turns this into a 409) if one's already running.
|
||||||
|
#
|
||||||
|
# fetch_build_url exists as an injectable seam so tests can exercise the
|
||||||
|
# rest of this (temp path, go-pmtiles invocation, atomic rename) without
|
||||||
|
# a real network call — same reasoning test_helper.rb already gives for
|
||||||
|
# keeping /api/plugins route tests off the real Overpass API.
|
||||||
|
def self.build_offline(bounds, data_dir:, fetch_build_url: method(:latest_protomaps_build_url))
|
||||||
|
begin_run!(OFFLINE_MUTEX, OFFLINE_STATUS)
|
||||||
|
Thread.new { run_job(OFFLINE_MUTEX, OFFLINE_STATUS) { extract_offline(bounds, data_dir, fetch_build_url) } }
|
||||||
|
end
|
||||||
|
|
||||||
|
# fetch_tile: same injectable-seam reasoning as fetch_build_url above,
|
||||||
|
# for the many individual Terrarium tile fetches this needs instead of
|
||||||
|
# one index lookup.
|
||||||
|
def self.build_terrain(bounds, data_dir:, fetch_tile: method(:fetch_terrarium_tile))
|
||||||
|
begin_run!(TERRAIN_MUTEX, TERRAIN_STATUS)
|
||||||
|
Thread.new { run_job(TERRAIN_MUTEX, TERRAIN_STATUS) { build_terrain_pmtiles(bounds, data_dir, fetch_tile) } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.begin_run!(mutex, status)
|
||||||
|
mutex.synchronize do
|
||||||
|
raise BuildError, "a build is already running" if status[:state] == "running"
|
||||||
|
status.replace(state: "running", started_at: Time.now.utc.iso8601)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :begin_run!
|
||||||
|
|
||||||
|
def self.run_job(mutex, status)
|
||||||
|
yield
|
||||||
|
mutex.synchronize { status.merge!(state: "done", finished_at: Time.now.utc.iso8601).delete(:message) }
|
||||||
|
rescue => e
|
||||||
|
mutex.synchronize { status.merge!(state: "error", finished_at: Time.now.utc.iso8601, message: e.message) }
|
||||||
|
end
|
||||||
|
private_class_method :run_job
|
||||||
|
|
||||||
|
# --- offline.pmtiles ---
|
||||||
|
|
||||||
|
def self.extract_offline(bounds, data_dir, fetch_build_url)
|
||||||
|
source = fetch_build_url.call
|
||||||
|
south, west, north, east = bounds
|
||||||
|
tmp_path = File.join(data_dir, "offline.pmtiles.tmp")
|
||||||
|
|
||||||
|
run_pmtiles!("extract", source, tmp_path, "--bbox=#{west},#{south},#{east},#{north}", "--maxzoom=15")
|
||||||
|
File.rename(tmp_path, File.join(data_dir, "offline.pmtiles"))
|
||||||
|
ensure
|
||||||
|
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
|
||||||
|
end
|
||||||
|
private_class_method :extract_offline
|
||||||
|
|
||||||
|
def self.latest_protomaps_build_url
|
||||||
|
response = Net::HTTP.get_response(URI(PROTOMAPS_BUILDS_INDEX))
|
||||||
|
raise BuildError, "could not reach #{PROTOMAPS_BUILDS_INDEX}" unless response.is_a?(Net::HTTPSuccess)
|
||||||
|
|
||||||
|
builds = JSON.parse(response.body)
|
||||||
|
raise BuildError, "protomaps build index was empty" if builds.empty?
|
||||||
|
|
||||||
|
"#{PROTOMAPS_BUILD_BASE}/#{builds.last.fetch("key")}"
|
||||||
|
rescue JSON::ParserError
|
||||||
|
raise BuildError, "protomaps build index was not valid JSON"
|
||||||
|
end
|
||||||
|
private_class_method :latest_protomaps_build_url
|
||||||
|
|
||||||
|
# --- terrain.pmtiles ---
|
||||||
|
|
||||||
|
def self.build_terrain_pmtiles(bounds, data_dir, fetch_tile)
|
||||||
|
south, west, north, east = bounds
|
||||||
|
tmp_mbtiles = File.join(Dir.tmpdir, "maptool-terrain-#{Process.pid}-#{Time.now.to_i}.mbtiles")
|
||||||
|
tmp_path = File.join(data_dir, "terrain.pmtiles.tmp")
|
||||||
|
|
||||||
|
write_terrarium_mbtiles(tmp_mbtiles, south, west, north, east, fetch_tile)
|
||||||
|
run_pmtiles!("convert", tmp_mbtiles, tmp_path)
|
||||||
|
File.rename(tmp_path, File.join(data_dir, "terrain.pmtiles"))
|
||||||
|
ensure
|
||||||
|
File.delete(tmp_mbtiles) if tmp_mbtiles && File.exist?(tmp_mbtiles)
|
||||||
|
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
|
||||||
|
end
|
||||||
|
private_class_method :build_terrain_pmtiles
|
||||||
|
|
||||||
|
# Terrarium PNG tiles for z0..TERRAIN_MAX_ZOOM over the bbox, packed
|
||||||
|
# into a minimal MBTiles (SQLite) archive — go-pmtiles' own `convert`
|
||||||
|
# only accepts MBTiles/PMTiles as input, no raw tile directory, and no
|
||||||
|
# public Terrarium-encoded PMTiles archive is known to exist to
|
||||||
|
# `extract` from the way offline.pmtiles does above.
|
||||||
|
#
|
||||||
|
# ponytail: fetches tiles one at a time, serially — fine for a
|
||||||
|
# park-sized region, slow for a big one. Add a small bounded-concurrency
|
||||||
|
# fetch pool if regenerating a large area turns out to matter in
|
||||||
|
# practice.
|
||||||
|
def self.write_terrarium_mbtiles(path, south, west, north, east, fetch_tile)
|
||||||
|
db = SQLite3::Database.new(path)
|
||||||
|
db.execute_batch(<<~SQL)
|
||||||
|
CREATE TABLE metadata (name TEXT, value TEXT);
|
||||||
|
CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB);
|
||||||
|
CREATE UNIQUE INDEX tile_index ON tiles (zoom_level, tile_column, tile_row);
|
||||||
|
SQL
|
||||||
|
{ "format" => "png", "name" => "terrain", "type" => "baselayer", "minzoom" => "0", "maxzoom" => TERRAIN_MAX_ZOOM.to_s }.each do |name, value|
|
||||||
|
db.execute("INSERT INTO metadata (name, value) VALUES (?, ?)", [name, value])
|
||||||
|
end
|
||||||
|
|
||||||
|
(0..TERRAIN_MAX_ZOOM).each do |z|
|
||||||
|
tile_range(z, south, west, north, east).each do |x, y|
|
||||||
|
bytes = fetch_tile.call(z, x, y)
|
||||||
|
next unless bytes
|
||||||
|
|
||||||
|
# MBTiles uses TMS row numbering (0 at the south) — the opposite
|
||||||
|
# of the XYZ scheme both the Terrarium source and this app's own
|
||||||
|
# online raster-dem layer use (0 at the north) — flipped here so
|
||||||
|
# go-pmtiles ends up serving the same tile at the same z/x/y this
|
||||||
|
# app already requests online.
|
||||||
|
tms_y = (2**z) - 1 - y
|
||||||
|
db.execute("INSERT OR REPLACE INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)", [z, x, tms_y, bytes.b])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
ensure
|
||||||
|
db&.close
|
||||||
|
end
|
||||||
|
private_class_method :write_terrarium_mbtiles
|
||||||
|
|
||||||
|
def self.tile_range(z, south, west, north, east)
|
||||||
|
n = 2**z
|
||||||
|
x_min, x_max = [lng_to_tile_x(west, n), lng_to_tile_x(east, n)].minmax
|
||||||
|
y_min, y_max = [lat_to_tile_y(north, n), lat_to_tile_y(south, n)].minmax
|
||||||
|
x_min.upto(x_max).flat_map { |x| y_min.upto(y_max).map { |y| [x, y] } }
|
||||||
|
end
|
||||||
|
private_class_method :tile_range
|
||||||
|
|
||||||
|
def self.lng_to_tile_x(lng, n)
|
||||||
|
(((lng + 180) / 360) * n).floor.clamp(0, n - 1)
|
||||||
|
end
|
||||||
|
private_class_method :lng_to_tile_x
|
||||||
|
|
||||||
|
def self.lat_to_tile_y(lat, n)
|
||||||
|
lat_rad = lat * Math::PI / 180
|
||||||
|
(((1 - (Math.log(Math.tan(lat_rad) + (1 / Math.cos(lat_rad))) / Math::PI)) / 2) * n).floor.clamp(0, n - 1)
|
||||||
|
end
|
||||||
|
private_class_method :lat_to_tile_y
|
||||||
|
|
||||||
|
def self.fetch_terrarium_tile(z, x, y)
|
||||||
|
response = Net::HTTP.get_response(URI(format(TERRARIUM_URL_TEMPLATE, z: z, x: x, y: y)))
|
||||||
|
response.is_a?(Net::HTTPSuccess) ? response.body : nil
|
||||||
|
rescue StandardError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
private_class_method :fetch_terrarium_tile
|
||||||
|
|
||||||
|
# --- shared ---
|
||||||
|
|
||||||
|
# ENV read lazily (not a load-time constant) so tests can point this at
|
||||||
|
# a fake script without a require-order dance.
|
||||||
|
def self.pmtiles_bin
|
||||||
|
ENV.fetch("PMTILES_BIN", "pmtiles")
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.run_pmtiles!(*args)
|
||||||
|
_stdout, stderr, status = Open3.capture3(pmtiles_bin, *args)
|
||||||
|
raise BuildError, "pmtiles exited with status #{status.exitstatus}: #{stderr.strip}" unless status.success?
|
||||||
|
rescue Errno::ENOENT, Errno::EACCES => e
|
||||||
|
raise BuildError, "could not run '#{pmtiles_bin}' (#{e.message}) — is go-pmtiles installed and on PATH? See docs/development.adoc."
|
||||||
|
end
|
||||||
|
private_class_method :run_pmtiles!
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -397,6 +397,49 @@ html, body {
|
|||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.offline-data-panel__body {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-data-panel__row + .offline-data-panel__row {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-data-panel__row-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-data-panel__build {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-data-panel__build:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-data-panel__build:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-data-panel__status {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.gpx-panel {
|
.gpx-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -416,6 +416,16 @@ Object.assign(POI_ICONS, {
|
|||||||
shop_convenience: STORE_ICON,
|
shop_convenience: STORE_ICON,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hand-drawn, not NPS-sourced — no "download offline map data" pictogram
|
||||||
|
// in that set either. A tray with a downward arrow into it, same solid
|
||||||
|
// 22x22 currentColor convention as CONTOUR_ICON/TERRAIN_SHADOW_ICON above
|
||||||
|
// — used for the "Offline data" sidebar icon (offline_data.js).
|
||||||
|
export const OFFLINE_DATA_ICON = `
|
||||||
|
<svg viewBox="0 0 22 22" fill="currentColor">
|
||||||
|
<path d="M10,0h2v10.5l3-3l1.4,1.4L11,14.3L5.6,8.9L7,7.5l3,3V0z"/>
|
||||||
|
<path d="M2,15h18v2H2z M2,18h18v2H2z"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
// src/bookstore-22.svg (NPS symbol library, same source/license as
|
// src/bookstore-22.svg (NPS symbol library, same source/license as
|
||||||
// POI_ICONS above) — used as the fixed marker glyph for the "Wikipedia"
|
// POI_ICONS above) — used as the fixed marker glyph for the "Wikipedia"
|
||||||
// map layer (gpx.js), not part of POI_ICONS itself: that set is keyed by
|
// map layer (gpx.js), not part of POI_ICONS itself: that set is keyed by
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { NotesControl } from "/js/notes.js";
|
|||||||
import { PluginsControl } from "/js/plugins.js";
|
import { PluginsControl } from "/js/plugins.js";
|
||||||
import { LocateControl } from "/js/locate.js";
|
import { LocateControl } from "/js/locate.js";
|
||||||
import { SearchControl } from "/js/search.js";
|
import { SearchControl } from "/js/search.js";
|
||||||
|
import { OfflineDataControl } from "/js/offline_data.js";
|
||||||
import { LAYERS_ICON, WAYPOINT_ICON, SUNRISE_ICON, SUNSET_ICON, PENDING_ICON } from "/js/icons.js";
|
import { LAYERS_ICON, WAYPOINT_ICON, SUNRISE_ICON, SUNSET_ICON, PENDING_ICON } from "/js/icons.js";
|
||||||
import { formatCoords, formatSunInfo } from "/js/sun.js";
|
import { formatCoords, formatSunInfo } from "/js/sun.js";
|
||||||
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
||||||
@@ -515,6 +516,7 @@ if (isLoggedIn()) new PluginsControl(map).mount(sidebar);
|
|||||||
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Offline hub"></div>`);
|
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Offline hub"></div>`);
|
||||||
layerToggle.mount(sidebar);
|
layerToggle.mount(sidebar);
|
||||||
layerToggle.setActive("online");
|
layerToggle.setActive("online");
|
||||||
|
new OfflineDataControl(map).mount(sidebar);
|
||||||
|
|
||||||
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Navigation"></div>`);
|
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Navigation"></div>`);
|
||||||
gnssControl.mount(sidebar);
|
gnssControl.mount(sidebar);
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { OFFLINE_DATA_ICON } from "/js/icons.js";
|
||||||
|
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
||||||
|
import { isLoggedIn } from "/js/auth.js";
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
const mb = bytes / (1024 * 1024);
|
||||||
|
return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb.toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row of the Offline data panel — status text plus a "Regenerate for
|
||||||
|
// current view" button, for either offline.pmtiles or terrain.pmtiles.
|
||||||
|
// Both files behave identically from here (same status/build/build-status
|
||||||
|
// endpoint shape, see lib/maptool/pmtiles_builder.rb), just against
|
||||||
|
// different URLs/labels, so one class covers both rather than writing the
|
||||||
|
// same fetch/poll logic twice.
|
||||||
|
class DataFileRow {
|
||||||
|
constructor(map, { label, statusUrl, buildUrl, buildStatusUrl }) {
|
||||||
|
this._map = map;
|
||||||
|
this._label = label;
|
||||||
|
this._statusUrl = statusUrl;
|
||||||
|
this._buildUrl = buildUrl;
|
||||||
|
this._buildStatusUrl = buildStatusUrl;
|
||||||
|
this._exists = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(container) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "offline-data-panel__row";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="offline-data-panel__row-header">
|
||||||
|
<strong>${this._label}</strong>
|
||||||
|
<button type="button" class="offline-data-panel__build">Regenerate for current view</button>
|
||||||
|
</div>
|
||||||
|
<p class="offline-data-panel__status">Checking…</p>
|
||||||
|
`;
|
||||||
|
this._buildButton = row.querySelector(".offline-data-panel__build");
|
||||||
|
this._statusEl = row.querySelector(".offline-data-panel__status");
|
||||||
|
// Viewing status works for anyone; only triggering a build needs a
|
||||||
|
// login — same finer-grained gating measure.js/gpx.js use for their
|
||||||
|
// own write-only affordances, rather than hiding this whole control.
|
||||||
|
this._buildButton.hidden = !isLoggedIn();
|
||||||
|
this._buildButton.addEventListener("click", () => this._build());
|
||||||
|
|
||||||
|
container.appendChild(row);
|
||||||
|
this._refreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _refreshStatus() {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await (await fetch(this._statusUrl)).json();
|
||||||
|
} catch {
|
||||||
|
this._statusEl.textContent = "Could not check status.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._exists = data.available;
|
||||||
|
this._statusEl.textContent = data.available
|
||||||
|
? `${formatBytes(data.size)} · updated ${new Date(data.mtime).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}`
|
||||||
|
: "Not generated yet.";
|
||||||
|
}
|
||||||
|
|
||||||
|
async _build() {
|
||||||
|
if (this._exists && !window.confirm(`Replace the existing ${this._label.toLowerCase()} with a fresh build for the current view?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = this._map.getBounds();
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
south: bounds.getSouth(),
|
||||||
|
west: bounds.getWest(),
|
||||||
|
north: bounds.getNorth(),
|
||||||
|
east: bounds.getEast(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this._buildButton.disabled = true;
|
||||||
|
this._statusEl.textContent = "Starting…";
|
||||||
|
try {
|
||||||
|
const response = await fetch(this._buildUrl, { method: "POST", body });
|
||||||
|
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._buildButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._statusEl.textContent = "Building…";
|
||||||
|
this._poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-rescheduling setTimeout loop, same shape as livetrack.js's
|
||||||
|
// _scheduleRefresh — check status, only reschedule while still running.
|
||||||
|
_poll() {
|
||||||
|
setTimeout(async () => {
|
||||||
|
let status;
|
||||||
|
try {
|
||||||
|
status = await (await fetch(this._buildStatusUrl)).json();
|
||||||
|
} catch {
|
||||||
|
this._poll(); // transient failure — try again next tick
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.state === "running") {
|
||||||
|
this._poll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._buildButton.disabled = false;
|
||||||
|
if (status.state === "error") {
|
||||||
|
this._statusEl.textContent = `Build failed: ${status.message}`;
|
||||||
|
} else {
|
||||||
|
this._refreshStatus();
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sidebar panel wrapping the manual go-pmtiles workflow
|
||||||
|
// docs/development.adoc documents — "Regenerate for current view" on
|
||||||
|
// either file shells out to it server-side instead of an admin running it
|
||||||
|
// by hand (see lib/maptool/pmtiles_builder.rb). No area-picker: the
|
||||||
|
// current map viewport is the region, same convention the Overpass POI
|
||||||
|
// plugin already uses for "give me a bbox."
|
||||||
|
export class OfflineDataControl {
|
||||||
|
constructor(map) {
|
||||||
|
this._rows = [
|
||||||
|
new DataFileRow(map, {
|
||||||
|
label: "Offline map data",
|
||||||
|
statusUrl: "/api/offline-tiles",
|
||||||
|
buildUrl: "/api/offline-tiles/build",
|
||||||
|
buildStatusUrl: "/api/offline-tiles/build-status",
|
||||||
|
}),
|
||||||
|
new DataFileRow(map, {
|
||||||
|
label: "Offline terrain data",
|
||||||
|
statusUrl: "/api/terrain-tiles",
|
||||||
|
buildUrl: "/api/terrain-tiles/build",
|
||||||
|
buildStatusUrl: "/api/terrain-tiles/build-status",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(sidebar) {
|
||||||
|
this._container = document.createElement("div");
|
||||||
|
this._container.className = "sidebar-item";
|
||||||
|
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Offline data" aria-label="Offline data">${OFFLINE_DATA_ICON}</button>`;
|
||||||
|
this._icon = this._container.querySelector(".sidebar-icon");
|
||||||
|
this._icon.addEventListener("click", () => this._togglePanel());
|
||||||
|
|
||||||
|
this._panel = document.getElementById("offline-data-panel");
|
||||||
|
this._panel.querySelector(".basemap-panel__close").addEventListener("click", () => this._togglePanel(false));
|
||||||
|
|
||||||
|
const body = this._panel.querySelector(".offline-data-panel__body");
|
||||||
|
this._rows.forEach((row) => row.mount(body));
|
||||||
|
|
||||||
|
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
|
||||||
|
|
||||||
|
sidebar.appendChild(this._container);
|
||||||
|
}
|
||||||
|
|
||||||
|
_togglePanel(force) {
|
||||||
|
const open = force === undefined ? this._panel.hidden : force;
|
||||||
|
this._panel.hidden = !open;
|
||||||
|
this._icon.classList.toggle("active", open);
|
||||||
|
if (open) notifySidePanelOpen(this._panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,8 @@ class AppTest < Minitest::Test
|
|||||||
assert_equal 401, request.delete("/api/gpx/folder/x").status
|
assert_equal 401, request.delete("/api/gpx/folder/x").status
|
||||||
assert_equal 401, request.post("/api/gpx/move").status
|
assert_equal 401, request.post("/api/gpx/move").status
|
||||||
assert_equal 401, request.post("/api/plugins/test-plugin/run").status
|
assert_equal 401, request.post("/api/plugins/test-plugin/run").status
|
||||||
|
assert_equal 401, request.post("/api/offline-tiles/build").status
|
||||||
|
assert_equal 401, request.post("/api/terrain-tiles/build").status
|
||||||
end
|
end
|
||||||
|
|
||||||
# --- GPX/FIT upload and the tree ---
|
# --- GPX/FIT upload and the tree ---
|
||||||
@@ -347,6 +349,47 @@ class AppTest < Minitest::Test
|
|||||||
assert_equal 502, response.status
|
assert_equal 502, response.status
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# --- pmtiles regeneration ---
|
||||||
|
#
|
||||||
|
# The full build (network + go-pmtiles) is covered in isolation, with
|
||||||
|
# fakes injected, by test_pmtiles_builder.rb — same "keep this offline"
|
||||||
|
# reasoning as the rest of this file. What's route-specific and worth
|
||||||
|
# covering here is the bbox validation and the 409-while-running guard,
|
||||||
|
# neither of which needs a real build to actually run.
|
||||||
|
|
||||||
|
def test_offline_tiles_build_status_defaults_to_idle
|
||||||
|
assert_equal({ "state" => "idle" }, json_body(request.get("/api/offline-tiles/build-status")))
|
||||||
|
assert_equal({ "state" => "idle" }, json_body(request.get("/api/terrain-tiles/build-status")))
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_offline_tiles_build_requires_a_bbox
|
||||||
|
response = request.post("/api/offline-tiles/build", **logged_in_env)
|
||||||
|
assert_equal 400, response.status
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_terrain_tiles_build_requires_a_bbox
|
||||||
|
response = request.post("/api/terrain-tiles/build", **logged_in_env)
|
||||||
|
assert_equal 400, response.status
|
||||||
|
end
|
||||||
|
|
||||||
|
# fetch_build_url blocks on a Queue, called directly (not through the
|
||||||
|
# route, which has no way to inject a fake) so the build is
|
||||||
|
# deterministically still "running" when the route call below happens.
|
||||||
|
def test_offline_tiles_build_is_409_when_one_is_already_running
|
||||||
|
gate = Queue.new
|
||||||
|
thread = Maptool::PmtilesBuilder.build_offline([1, 2, 3, 4], data_dir: Maptool::App::DATA_DIR, fetch_build_url: -> { gate.pop })
|
||||||
|
|
||||||
|
response = request.post(
|
||||||
|
"/api/offline-tiles/build",
|
||||||
|
params: { south: "1", west: "2", north: "3", east: "4" },
|
||||||
|
**logged_in_env,
|
||||||
|
)
|
||||||
|
assert_equal 409, response.status
|
||||||
|
ensure
|
||||||
|
gate.push("unused")
|
||||||
|
thread&.join
|
||||||
|
end
|
||||||
|
|
||||||
# --- LiveTrack webhook ---
|
# --- LiveTrack webhook ---
|
||||||
|
|
||||||
def test_livetrack_webhook_requires_the_configured_secret
|
def test_livetrack_webhook_requires_the_configured_secret
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ module AppTestHelper
|
|||||||
def teardown
|
def teardown
|
||||||
FileUtils.rm_rf(Dir.glob(File.join(Maptool::App::DATA_DIR, "gpx", "*")))
|
FileUtils.rm_rf(Dir.glob(File.join(Maptool::App::DATA_DIR, "gpx", "*")))
|
||||||
File.write(File.join(Maptool::App::DATA_DIR, "livetrack.json"), "[]")
|
File.write(File.join(Maptool::App::DATA_DIR, "livetrack.json"), "[]")
|
||||||
|
Maptool::PmtilesBuilder.reset!
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
require_relative "test_helper"
|
||||||
|
require "maptool/pmtiles_builder"
|
||||||
|
require "tmpdir"
|
||||||
|
require "fileutils"
|
||||||
|
|
||||||
|
# Network calls (the protomaps build index, individual Terrarium tile
|
||||||
|
# fetches) are never exercised here — same "keep this offline/deterministic"
|
||||||
|
# reasoning test_helper.rb already gives for /api/plugins staying off the
|
||||||
|
# real Overpass API — build_offline/build_terrain take fetch_build_url/
|
||||||
|
# fetch_tile precisely so tests can swap those out. A fake `pmtiles` CLI
|
||||||
|
# (PMTILES_BIN) stands in for the real go-pmtiles binary.
|
||||||
|
class PmtilesBuilderTest < Minitest::Test
|
||||||
|
def setup
|
||||||
|
@data_dir = Dir.mktmpdir("maptool-pmtiles-test-data")
|
||||||
|
@bin_dir = Dir.mktmpdir("maptool-pmtiles-test-bin")
|
||||||
|
|
||||||
|
# $3 is the output path either way: `extract SOURCE OUTPUT --bbox=...
|
||||||
|
# --maxzoom=...` and `convert INPUT OUTPUT` both put it 3rd, even though
|
||||||
|
# it's not the *last* arg in the extract case (the --flags follow it).
|
||||||
|
@pmtiles_ok = File.join(@bin_dir, "pmtiles-ok")
|
||||||
|
File.write(@pmtiles_ok, "#!/bin/sh\necho fake > \"$3\"\n")
|
||||||
|
File.chmod(0o755, @pmtiles_ok)
|
||||||
|
|
||||||
|
@pmtiles_fail = File.join(@bin_dir, "pmtiles-fail")
|
||||||
|
File.write(@pmtiles_fail, "#!/bin/sh\necho 'boom' >&2\nexit 1\n")
|
||||||
|
File.chmod(0o755, @pmtiles_fail)
|
||||||
|
|
||||||
|
@original_pmtiles_bin = ENV["PMTILES_BIN"]
|
||||||
|
Maptool::PmtilesBuilder.reset!
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
FileUtils.remove_entry(@data_dir)
|
||||||
|
FileUtils.remove_entry(@bin_dir)
|
||||||
|
ENV["PMTILES_BIN"] = @original_pmtiles_bin
|
||||||
|
Maptool::PmtilesBuilder.reset!
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_offline_and_terrain_status_start_idle
|
||||||
|
assert_equal({ state: "idle" }, Maptool::PmtilesBuilder.offline_status)
|
||||||
|
assert_equal({ state: "idle" }, Maptool::PmtilesBuilder.terrain_status)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_build_offline_writes_the_file_and_reports_done
|
||||||
|
ENV["PMTILES_BIN"] = @pmtiles_ok
|
||||||
|
thread = Maptool::PmtilesBuilder.build_offline(
|
||||||
|
[1, 2, 3, 4],
|
||||||
|
data_dir: @data_dir,
|
||||||
|
fetch_build_url: -> { "https://example.com/fake.pmtiles" },
|
||||||
|
)
|
||||||
|
thread.join
|
||||||
|
|
||||||
|
assert_path_exists File.join(@data_dir, "offline.pmtiles")
|
||||||
|
refute_path_exists File.join(@data_dir, "offline.pmtiles.tmp")
|
||||||
|
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_build_offline_reports_an_error_status_on_non_zero_exit
|
||||||
|
ENV["PMTILES_BIN"] = @pmtiles_fail
|
||||||
|
thread = Maptool::PmtilesBuilder.build_offline(
|
||||||
|
[1, 2, 3, 4],
|
||||||
|
data_dir: @data_dir,
|
||||||
|
fetch_build_url: -> { "https://example.com/fake.pmtiles" },
|
||||||
|
)
|
||||||
|
thread.join
|
||||||
|
|
||||||
|
refute_path_exists File.join(@data_dir, "offline.pmtiles")
|
||||||
|
status = Maptool::PmtilesBuilder.offline_status
|
||||||
|
assert_equal "error", status[:state]
|
||||||
|
assert_match(/exited with status/, status[:message])
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_build_offline_reports_a_clear_error_when_the_binary_is_missing
|
||||||
|
ENV["PMTILES_BIN"] = File.join(@bin_dir, "does-not-exist")
|
||||||
|
thread = Maptool::PmtilesBuilder.build_offline(
|
||||||
|
[1, 2, 3, 4],
|
||||||
|
data_dir: @data_dir,
|
||||||
|
fetch_build_url: -> { "https://example.com/fake.pmtiles" },
|
||||||
|
)
|
||||||
|
thread.join
|
||||||
|
|
||||||
|
status = Maptool::PmtilesBuilder.offline_status
|
||||||
|
assert_equal "error", status[:state]
|
||||||
|
assert_match(/go-pmtiles installed/, status[:message])
|
||||||
|
end
|
||||||
|
|
||||||
|
# fetch_build_url blocks on a Queue so the build is deterministically
|
||||||
|
# still "running" when the second call happens, rather than racing a real
|
||||||
|
# one to see which state wins.
|
||||||
|
def test_build_offline_raises_when_one_is_already_running
|
||||||
|
ENV["PMTILES_BIN"] = @pmtiles_ok
|
||||||
|
gate = Queue.new
|
||||||
|
thread = Maptool::PmtilesBuilder.build_offline([1, 2, 3, 4], data_dir: @data_dir, fetch_build_url: -> { gate.pop })
|
||||||
|
|
||||||
|
assert_raises(Maptool::PmtilesBuilder::BuildError) do
|
||||||
|
Maptool::PmtilesBuilder.build_offline([1, 2, 3, 4], data_dir: @data_dir, fetch_build_url: -> { "unused" })
|
||||||
|
end
|
||||||
|
ensure
|
||||||
|
gate.push("https://example.com/fake.pmtiles")
|
||||||
|
thread&.join
|
||||||
|
end
|
||||||
|
|
||||||
|
# A tiny real-world bbox (a few blocks of central Bratislava) keeps the
|
||||||
|
# z0..15 tile_range small at every zoom level, so this stays fast even
|
||||||
|
# though it iterates all 16 levels.
|
||||||
|
def test_build_terrain_packs_fetched_tiles_and_converts
|
||||||
|
ENV["PMTILES_BIN"] = @pmtiles_ok
|
||||||
|
thread = Maptool::PmtilesBuilder.build_terrain(
|
||||||
|
[48.140, 17.100, 48.141, 17.101],
|
||||||
|
data_dir: @data_dir,
|
||||||
|
fetch_tile: ->(_z, _x, _y) { "not a real png, write_terrarium_mbtiles just stores opaque bytes" },
|
||||||
|
)
|
||||||
|
thread.join
|
||||||
|
|
||||||
|
assert_path_exists File.join(@data_dir, "terrain.pmtiles")
|
||||||
|
assert_equal "done", Maptool::PmtilesBuilder.terrain_status[:state]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_build_terrain_still_succeeds_when_some_tiles_are_missing
|
||||||
|
ENV["PMTILES_BIN"] = @pmtiles_ok
|
||||||
|
thread = Maptool::PmtilesBuilder.build_terrain(
|
||||||
|
[48.140, 17.100, 48.141, 17.101],
|
||||||
|
data_dir: @data_dir,
|
||||||
|
fetch_tile: ->(_z, _x, _y) { nil }, # every tile "missing" — should skip, not fail, the same way HillshadeControl's own source tolerates gaps
|
||||||
|
)
|
||||||
|
thread.join
|
||||||
|
|
||||||
|
assert_equal "done", Maptool::PmtilesBuilder.terrain_status[:state]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -88,3 +88,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul class="livetrack-panel__list"></ul>
|
<ul class="livetrack-panel__list"></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="offline-data-panel" class="basemap-panel" hidden>
|
||||||
|
<div class="basemap-panel__header">
|
||||||
|
<strong>Offline data</strong>
|
||||||
|
<button type="button" class="basemap-panel__close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<!-- Two rows (map data / terrain data), each its own status text +
|
||||||
|
"Regenerate for current view" button — built by
|
||||||
|
OfflineDataControl#mount (public/js/offline_data.js), same
|
||||||
|
"control owns its content" story as the Overlays panel. -->
|
||||||
|
<div class="offline-data-panel__body"></div>
|
||||||
|
</div>
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
"/js/locate.js": "/js/locate.js?v=<%= ASSET_VERSION %>",
|
"/js/locate.js": "/js/locate.js?v=<%= ASSET_VERSION %>",
|
||||||
"/js/measure.js": "/js/measure.js?v=<%= ASSET_VERSION %>",
|
"/js/measure.js": "/js/measure.js?v=<%= ASSET_VERSION %>",
|
||||||
"/js/notes.js": "/js/notes.js?v=<%= ASSET_VERSION %>",
|
"/js/notes.js": "/js/notes.js?v=<%= ASSET_VERSION %>",
|
||||||
|
"/js/offline_data.js": "/js/offline_data.js?v=<%= ASSET_VERSION %>",
|
||||||
"/js/overlays.js": "/js/overlays.js?v=<%= ASSET_VERSION %>",
|
"/js/overlays.js": "/js/overlays.js?v=<%= ASSET_VERSION %>",
|
||||||
"/js/panels.js": "/js/panels.js?v=<%= ASSET_VERSION %>",
|
"/js/panels.js": "/js/panels.js?v=<%= ASSET_VERSION %>",
|
||||||
"/js/plugins.js": "/js/plugins.js?v=<%= ASSET_VERSION %>",
|
"/js/plugins.js": "/js/plugins.js?v=<%= ASSET_VERSION %>",
|
||||||
|
|||||||
Reference in new issue
Block a user