diff --git a/app.rb b/app.rb index 6a77111..2a2b678 100644 --- a/app.rb +++ b/app.rb @@ -126,10 +126,11 @@ module Maptool halt 401, json(error: "login required", login_url: "/login") unless logged_in? end - def pmtiles_file_status(path) + def pmtiles_file_status(path, meta) return { available: false } unless File.exist?(path) - { available: true, size: File.size(path), mtime: File.mtime(path).utc.iso8601 } + status = { available: true, size: File.size(path), mtime: File.mtime(path).utc.iso8601 } + meta ? status.merge(meta: meta) : status end def callback_url @@ -211,7 +212,7 @@ module Maptool end get "/api/offline-tiles" do - json pmtiles_file_status(OFFLINE_PMTILES_PATH) + json pmtiles_file_status(OFFLINE_PMTILES_PATH, PmtilesBuilder.offline_meta(DATA_DIR)) end # Served as a plain range-request-capable file, not decoded server-side: @@ -223,6 +224,18 @@ module Maptool send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream" end + # Compares the recorded build (offline.pmtiles.meta.json) against the + # current latest Protomaps build without rebuilding anything — + # update_available is null (not true/false) if there's no local build + # yet, or the live index couldn't be reached, so the frontend can tell + # "don't know" apart from "no, up to date". Not folded into GET + # /api/offline-tiles above: that route is on the hot path (map.js's own + # topbar-dot check on every page load), this one only gets called when + # the Offline data panel is actually opened. + get "/api/offline-tiles/check-update" do + json update_available: PmtilesBuilder.offline_update_available?(DATA_DIR) + end + # Lists the selectable countries and which of them are currently # selected (data/offline_regions.json) — read-only, no login needed, # same tier as the plain availability checks above. @@ -269,7 +282,7 @@ module Maptool # 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 pmtiles_file_status(TERRAIN_PMTILES_PATH) + json pmtiles_file_status(TERRAIN_PMTILES_PATH, PmtilesBuilder.terrain_meta(DATA_DIR)) end get "/tiles/terrain.pmtiles" do @@ -278,6 +291,11 @@ module Maptool send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream" end + # Same reasoning as GET /api/offline-tiles/check-update above. + get "/api/terrain-tiles/check-update" do + json update_available: PmtilesBuilder.terrain_update_available?(DATA_DIR) + end + post "/api/terrain-tiles/build" do require_login! begin diff --git a/docs/development.adoc b/docs/development.adoc index 730d9ae..596f28f 100644 --- a/docs/development.adoc +++ b/docs/development.adoc @@ -127,6 +127,20 @@ matching `*/build-status` route) — only one of each kind at a time; starting a second while one's running gets a 409, as does starting either with nothing selected yet. +Each successful build also writes a small sidecar (`offline.pmtiles. +meta.json` / `terrain.pmtiles.meta.json` — the Protomaps build date used, +or the Mapterhorn source names/md5sums) recording what was actually +built, since the `.pmtiles` file itself carries no such record. `GET +/api/offline-tiles`/`/api/terrain-tiles` include it as `meta`, shown in +the panel next to the existing size/updated line. `GET .../check-update` +compares that recorded build against the live upstream index/manifest +(without rebuilding anything) and reports `update_available: true/false/ +null` — `null` means "don't know" (no local build yet, or the upstream +check itself failed), kept as its own route rather than folded into the +plain status endpoints above since those are on the page-load hot path +(the topbar's offline-status dot) and this one reaches an external +service, only worth doing when the Offline data panel is actually open. + == Camper-relevant POIs (Overpass) `rake overpass:pois[south,west,north,east,out.geojson]` queries diff --git a/lib/maptool/pmtiles_builder.rb b/lib/maptool/pmtiles_builder.rb index 1ce87b5..6b6cc17 100644 --- a/lib/maptool/pmtiles_builder.rb +++ b/lib/maptool/pmtiles_builder.rb @@ -189,6 +189,7 @@ module Maptool run_pmtiles!("extract", source, tmp_path, "--region=#{region_path}", "--maxzoom=15") File.rename(tmp_path, File.join(data_dir, "offline.pmtiles")) + File.write(offline_meta_path(data_dir), JSON.generate(source: File.basename(URI(source).path))) ensure FileUtils.remove_entry(scratch_dir) if scratch_dir && Dir.exist?(scratch_dir) File.delete(tmp_path) if tmp_path && File.exist?(tmp_path) @@ -208,6 +209,33 @@ module Maptool end private_class_method :latest_protomaps_build_url + # Sidecar recording which Protomaps build (a date-stamped filename, e.g. + # "20260910.pmtiles") the on-disk offline.pmtiles was actually extracted + # from — the file itself carries no such record. nil if never built (or + # built before this existed). + def self.offline_meta(data_dir) + read_json(offline_meta_path(data_dir)) + end + + # Compares the persisted build against the current latest without + # rebuilding anything — nil (not true/false) if there's nothing to + # compare yet (no local build) or the upstream index couldn't be + # reached, so the caller can tell "don't know" apart from "no". + def self.offline_update_available?(data_dir, fetch_build_url: method(:latest_protomaps_build_url)) + meta = offline_meta(data_dir) + return nil unless meta + + latest = File.basename(URI(fetch_build_url.call).path) + latest != meta["source"] + rescue BuildError + nil + end + + def self.offline_meta_path(data_dir) + File.join(data_dir, "offline.pmtiles.meta.json") + end + private_class_method :offline_meta_path + # --- terrain.pmtiles --- # planet.pmtiles (z0-12, always) plus whichever regional chunks @@ -248,6 +276,7 @@ module Maptool run_pmtiles!("merge", *extracts, tmp_path) end File.rename(tmp_path, File.join(data_dir, "terrain.pmtiles")) + File.write(terrain_meta_path(data_dir), JSON.generate(sources: [planet, *chunks].map { |i| { name: i["name"], md5sum: i["md5sum"] } })) ensure FileUtils.remove_entry(scratch_dir) if scratch_dir && Dir.exist?(scratch_dir) File.delete(tmp_path) if tmp_path && File.exist?(tmp_path) @@ -269,8 +298,55 @@ module Maptool end private_class_method :fetch_mapterhorn_manifest + # Sidecar recording which Mapterhorn sources (name + md5sum) the on-disk + # terrain.pmtiles was actually extracted+merged from. nil if never built. + def self.terrain_meta(data_dir) + read_json(terrain_meta_path(data_dir)) + end + + # Same "don't know" vs. "no" distinction as offline_update_available? + # above. Recomputes which sources *would* be selected for the current + # selection (same manifest + bbox_intersects? this build itself uses) + # and compares their md5sums against what's recorded — so this also + # correctly says "yes" if the selection changed since the last build + # (a chunk now relevant that wasn't recorded), not just if Mapterhorn's + # own data moved on. + def self.terrain_update_available?(data_dir, fetch_manifest: method(:fetch_mapterhorn_manifest)) + meta = terrain_meta(data_dir) + return nil unless meta + + codes = selected_regions(data_dir) + return nil if codes.empty? + + items = fetch_manifest.call + planet = items.find { |item| item["name"] == MAPTERHORN_PLANET_NAME } + return nil unless planet + + Dir.mktmpdir do |dir| + _region_path, bbox = region_geojson_and_bbox(codes, dir) + south, west, north, east = bbox + chunks = items.select { |item| item["name"] != MAPTERHORN_PLANET_NAME && bbox_intersects?(item, south, west, north, east) } + current = [planet, *chunks].map { |i| { "name" => i["name"], "md5sum" => i["md5sum"] } }.sort_by { |s| s["name"] } + current != (meta["sources"] || []).sort_by { |s| s["name"] } + end + rescue BuildError + nil + end + + def self.terrain_meta_path(data_dir) + File.join(data_dir, "terrain.pmtiles.meta.json") + end + private_class_method :terrain_meta_path + # --- shared --- + def self.read_json(path) + File.exist?(path) ? JSON.parse(File.read(path)) : nil + rescue JSON::ParserError + nil + end + private_class_method :read_json + # 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 diff --git a/public/css/style.css b/public/css/style.css index 65c4e7d..f4f6ba4 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -466,6 +466,10 @@ html, body { font-size: 12px; } +.offline-data-panel__update-badge { + color: #b8860b; +} + .gpx-panel { position: fixed; top: 0; diff --git a/public/js/offline_data.js b/public/js/offline_data.js index e9928ac..9b17cc8 100644 --- a/public/js/offline_data.js +++ b/public/js/offline_data.js @@ -25,6 +25,30 @@ function formatRunning(status) { return `Building…${elapsed}`; } +// Two Unicode regional indicator symbols, offset from the country's own +// ISO A2 letters — the standard zero-asset way to get a flag glyph (most +// platforms render the pair as one flag; a code with no assigned flag, +// e.g. Kosovo's "XK", just falls back to showing the two letters, not +// broken, just plainer). +function flagEmoji(code) { + return [...code.toUpperCase()].map((c) => String.fromCodePoint(127397 + c.charCodeAt(0))).join(""); +} + +// meta.source is a Protomaps build filename like "20260910.pmtiles" — the +// leading 8 digits are its build date. +function formatOfflineMeta(meta) { + if (!meta) return ""; + const match = meta.source.match(/^(\d{4})(\d{2})(\d{2})/); + const date = match ? `${match[1]}-${match[2]}-${match[3]}` : meta.source; + return ` · Protomaps ${date}`; +} + +function formatTerrainMeta(meta) { + if (!meta) return ""; + const count = meta.sources.length; + return ` · ${count} source${count === 1 ? "" : "s"}`; +} + // The checklist of selectable countries (GET /api/offline-regions) plus // the "Apply changes" button that saves the ticked set (POST, same URL) // and hands it off — doesn't trigger a build itself, OfflineDataControl @@ -66,7 +90,7 @@ class RegionPicker {