Add in-app regeneration of offline.pmtiles and terrain.pmtiles
CI / check (push) Successful in 20s
CI / deploy (push) Successful in 1m52s

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:
randy committed 2026-09-11 01:25:08 +02:00
1 parent fba8ca10a2
commit cd56ebe0a1
14 files changed
+740 -15

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ gem "puma", "~> 8.0"
gem "rexml", "~> 3.4"
gem "oauth2", "~> 2.0"
gem "rubyzip", "~> 2.4"
gem "sqlite3", "~> 2.0"
group :development do
gem "rerun"
+21
View File
@@ -95,6 +95,16 @@ GEM
snaky_hash (2.0.7)
hashie (>= 0.1.0, < 6)
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)
uri (1.1.1)
version_gem (1.1.15)
@@ -122,6 +132,7 @@ DEPENDENCIES
rubyzip (~> 2.4)
sinatra (~> 4.2)
sinatra-contrib (~> 4.2)
sqlite3 (~> 2.0)
CHECKSUMS
anonymous_loader (0.1.3) sha256=084a18e2439144d955447dc11dfc982f41fcd1583ad32d4d55151325dc44cb55
@@ -171,6 +182,16 @@ CHECKSUMS
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
sinatra-contrib (4.2.1) sha256=10d091c944d268aa910c618ea40a3c3ebe0533e6e32990d84af92235a3d26b4a
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
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
version_gem (1.1.15) sha256=a73241587b29252e3567e0c818ded80730eb754d43b508f6ce4db22ca9ba27d0
+48 -2
View File
@@ -12,6 +12,7 @@ require_relative "lib/maptool/livetrack_store"
require_relative "lib/maptool/livetrack_client"
require_relative "lib/maptool/kiwix"
require_relative "lib/maptool/plugins"
require_relative "lib/maptool/pmtiles_builder"
module Maptool
class App < Sinatra::Base
@@ -125,6 +126,12 @@ module Maptool
halt 401, json(error: "login required", login_url: "/login") unless logged_in?
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
"#{request.base_url}/auth/callback"
end
@@ -204,7 +211,7 @@ module Maptool
end
get "/api/offline-tiles" do
json available: File.exist?(OFFLINE_PMTILES_PATH)
json pmtiles_file_status(OFFLINE_PMTILES_PATH)
end
# 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"
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
# docs/development.adoc for how to generate one, until then hillshade
# just stays unavailable while the offline basemap is active.
get "/api/terrain-tiles" do
json available: File.exist?(TERRAIN_PMTILES_PATH)
json pmtiles_file_status(TERRAIN_PMTILES_PATH)
end
get "/tiles/terrain.pmtiles" do
@@ -229,6 +258,23 @@ module Maptool
send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream"
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
# 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
+43 -13
View File
@@ -27,18 +27,25 @@ reload (e.g. Ctrl/Cmd+Shift+R) if that happens.
To enable the offline style, place a PMTiles file at
`data/offline.pmtiles` (see `docs/architecture.adoc` for how it is
served and styled). It is not part of the repository — obtain or
build one locally:
served and styled). Logged-in users can generate this from the app
itself — see "In-app regeneration" below — or it can be built by hand:
* The simplest source is a pre-built extract from
https://app.protomaps.com[protomaps.com's extract builder] — pick a
region and download it as `.pmtiles`. Its schema is what
`protomaps-themes-base` (the styling library used for the offline
style) expects.
* Extract a region from Protomaps' own daily full-planet basemap build
(https://build-metadata.protomaps.dev/builds.json[build index],
files at `https://build.protomaps.com/<key>`, ~120GB, ODbL/OSM
attribution) with the
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)
into PMTiles, use the
https://github.com/protomaps/go-pmtiles[go-pmtiles] CLI:
`pmtiles convert in.mbtiles data/offline.pmtiles`.
into PMTiles instead, use the same CLI's `convert` subcommand:
`pmtiles convert in.mbtiles data/offline.pmtiles`. Its schema is
what `protomaps-themes-base` (the styling library used for the
offline style) expects.
Once the file is in place, `GET /api/offline-tiles` reports
`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
Terrarium-encoded `raster-dem` tiles at `data/terrain.pmtiles`
(`GET /api/terrain-tiles` reports `available: true` once it's there,
same pattern as offline map data above). It is not part of the
repository — build one locally from a DEM source (e.g. Copernicus
GLO-30 or SRTM) with a tool like
same pattern as offline map data above). Also generatable in-app (see
below) or by hand — build one locally from a DEM source (e.g.
Copernicus GLO-30 or SRTM) with a tool like
https://github.com/mapbox/rio-rgbify[rio-rgbify] (GeoTIFF ->
Terrarium-encoded PNG tiles in an `.mbtiles`), then
`pmtiles convert out.mbtiles data/terrain.pmtiles` with
https://github.com/protomaps/go-pmtiles[go-pmtiles], same as
`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)
`rake overpass:pois[south,west,north,east,out.geojson]` queries
+217
View File
@@ -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
+43
View File
@@ -397,6 +397,49 @@ html, body {
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 {
position: fixed;
top: 0;
+10
View File
@@ -416,6 +416,16 @@ Object.assign(POI_ICONS, {
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
// 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
+2
View File
@@ -15,6 +15,7 @@ import { NotesControl } from "/js/notes.js";
import { PluginsControl } from "/js/plugins.js";
import { LocateControl } from "/js/locate.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 { formatCoords, formatSunInfo } from "/js/sun.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>`);
layerToggle.mount(sidebar);
layerToggle.setActive("online");
new OfflineDataControl(map).mount(sidebar);
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Navigation"></div>`);
gnssControl.mount(sidebar);
+168
View File
@@ -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);
}
}
+43
View File
@@ -70,6 +70,8 @@ class AppTest < Minitest::Test
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/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
# --- GPX/FIT upload and the tree ---
@@ -347,6 +349,47 @@ class AppTest < Minitest::Test
assert_equal 502, response.status
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 ---
def test_livetrack_webhook_requires_the_configured_secret
+1
View File
@@ -66,6 +66,7 @@ module AppTestHelper
def teardown
FileUtils.rm_rf(Dir.glob(File.join(Maptool::App::DATA_DIR, "gpx", "*")))
File.write(File.join(Maptool::App::DATA_DIR, "livetrack.json"), "[]")
Maptool::PmtilesBuilder.reset!
super
end
+130
View File
@@ -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
+12
View File
@@ -88,3 +88,15 @@
</div>
<ul class="livetrack-panel__list"></ul>
</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">&times;</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>
+1
View File
@@ -41,6 +41,7 @@
"/js/locate.js": "/js/locate.js?v=<%= ASSET_VERSION %>",
"/js/measure.js": "/js/measure.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/panels.js": "/js/panels.js?v=<%= ASSET_VERSION %>",
"/js/plugins.js": "/js/plugins.js?v=<%= ASSET_VERSION %>",