Track what was actually built, and flag when it's stale
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 16s

The .pmtiles files themselves carry no record of what upstream
snapshot they came from — only a local mtime. Each successful build
now writes a small sidecar (offline.pmtiles.meta.json: the Protomaps
build date used; terrain.pmtiles.meta.json: the Mapterhorn source
names/md5sums) recording that, surfaced in the panel next to the
existing size/updated line.

New GET .../check-update routes compare the recorded build against
the live upstream index/manifest — true/false/null, null meaning
"don't know" (no local build yet, or the check itself failed) rather
than a false "no". Kept separate from the plain status endpoints
(which map.js's own topbar dot hits on every page load) since this
one reaches an external service; only called once when the Offline
data panel is actually opened.

Also: flag emoji next to each country in the picker (derived from the
ISO code via Unicode regional indicators, no image assets needed).
This commit is contained in:
randy committed 2026-09-11 13:18:32 +02:00
1 parent 53c9a82af2
commit 4a3862189f
7 files changed
+261 -15

No files matched your search

+22 -4
View File
@@ -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
+14
View File
@@ -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
+76
View File
@@ -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
+4
View File
@@ -466,6 +466,10 @@ html, body {
font-size: 12px;
}
.offline-data-panel__update-badge {
color: #b8860b;
}
.gpx-panel {
position: fixed;
top: 0;
+65 -7
View File
@@ -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 {
<li>
<label>
<input type="checkbox" value="${escapeHtml(region.code)}" ${selected.has(region.code) ? "checked" : ""}>
${escapeHtml(region.name)}
<span aria-hidden="true">${flagEmoji(region.code)}</span> ${escapeHtml(region.name)}
</label>
</li>`,
)
@@ -97,16 +121,19 @@ class RegionPicker {
// One row of the Offline data panel — just a status readout for either
// offline.pmtiles or terrain.pmtiles, both driven by the same selection
// (RegionPicker above). 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
// status/build/build-status/check-update endpoint shape, see
// lib/maptool/pmtiles_builder.rb), just against different URLs/labels and
// a different way of describing what was actually built (formatMeta), so
// one class covers both rather than writing the same fetch/poll logic
// twice.
class DataFileRow {
constructor({ label, statusUrl, buildUrl, buildStatusUrl }) {
constructor({ label, statusUrl, buildUrl, buildStatusUrl, checkUpdateUrl, formatMeta }) {
this._label = label;
this._statusUrl = statusUrl;
this._buildUrl = buildUrl;
this._buildStatusUrl = buildStatusUrl;
this._checkUpdateUrl = checkUpdateUrl;
this._formatMeta = formatMeta;
}
mount(container) {
@@ -129,9 +156,36 @@ class DataFileRow {
this._statusEl.textContent = "Could not check status.";
return;
}
this._statusEl.textContent = data.available
? `${formatBytes(data.size)} · updated ${new Date(data.mtime).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}`
: "Not generated yet.";
if (!data.available) {
this._statusEl.textContent = "Not generated yet.";
return;
}
const updated = new Date(data.mtime).toLocaleString([], { dateStyle: "short", timeStyle: "short" });
this._statusEl.textContent = `${formatBytes(data.size)} · updated ${updated}${this._formatMeta(data.meta)}`;
this._checkUpdate();
}
// Separate from _refreshStatus's own fetch (and only called once, right
// after — not polled): this one reaches an external service (Protomaps'
// build index or Mapterhorn's manifest, see
// lib/maptool/pmtiles_builder.rb), so it's kept off the hot status path
// and just silently skipped if it fails, same as everywhere else this
// app degrades gracefully when an optional network check doesn't answer.
async _checkUpdate() {
let data;
try {
data = await (await fetch(this._checkUpdateUrl)).json();
} catch {
return;
}
if (!data.update_available) return;
const badge = document.createElement("span");
badge.className = "offline-data-panel__update-badge";
badge.textContent = " · a newer version is available";
this._statusEl.appendChild(badge);
}
// Called by OfflineDataControl once RegionPicker's Apply has saved a
@@ -193,12 +247,16 @@ export class OfflineDataControl {
statusUrl: "/api/offline-tiles",
buildUrl: "/api/offline-tiles/build",
buildStatusUrl: "/api/offline-tiles/build-status",
checkUpdateUrl: "/api/offline-tiles/check-update",
formatMeta: formatOfflineMeta,
}),
new DataFileRow({
label: "Offline terrain data",
statusUrl: "/api/terrain-tiles",
buildUrl: "/api/terrain-tiles/build",
buildStatusUrl: "/api/terrain-tiles/build-status",
checkUpdateUrl: "/api/terrain-tiles/check-update",
formatMeta: formatTerrainMeta,
}),
];
this._regionPicker = new RegionPicker(() => this._rows.forEach((row) => row.triggerBuild()));
+5
View File
@@ -387,6 +387,11 @@ class AppTest < Minitest::Test
assert_equal({ "state" => "idle" }, json_body(request.get("/api/terrain-tiles/build-status")))
end
def test_check_update_is_null_before_any_build
assert_nil json_body(request.get("/api/offline-tiles/check-update"))["update_available"]
assert_nil json_body(request.get("/api/terrain-tiles/check-update"))["update_available"]
end
def test_offline_tiles_build_is_409_with_no_selection
response = request.post("/api/offline-tiles/build", **logged_in_env)
assert_equal 409, response.status
+75 -4
View File
@@ -69,11 +69,11 @@ class PmtilesBuilderTest < Minitest::Test
# chunk-a covers Slovakia's real extent, chunk-b covers Iceland's —
# Spain falls in neither, for the "no chunk intersects" case.
def fake_manifest
def fake_manifest(chunk_a_md5: "md5-a")
[
{ "name" => "planet.pmtiles", "url" => "https://example.com/planet.pmtiles", "min_lon" => -180.0, "min_lat" => -85.0, "max_lon" => 180.0, "max_lat" => 85.0, "min_zoom" => 0, "max_zoom" => 12 },
{ "name" => "chunk-a.pmtiles", "url" => "https://example.com/chunk-a.pmtiles", "min_lon" => 16.0, "min_lat" => 47.0, "max_lon" => 23.0, "max_lat" => 50.0, "min_zoom" => 13, "max_zoom" => 15 },
{ "name" => "chunk-b.pmtiles", "url" => "https://example.com/chunk-b.pmtiles", "min_lon" => -25.0, "min_lat" => 63.0, "max_lon" => -13.0, "max_lat" => 67.0, "min_zoom" => 13, "max_zoom" => 15 },
{ "name" => "planet.pmtiles", "url" => "https://example.com/planet.pmtiles", "md5sum" => "md5-planet", "min_lon" => -180.0, "min_lat" => -85.0, "max_lon" => 180.0, "max_lat" => 85.0, "min_zoom" => 0, "max_zoom" => 12 },
{ "name" => "chunk-a.pmtiles", "url" => "https://example.com/chunk-a.pmtiles", "md5sum" => chunk_a_md5, "min_lon" => 16.0, "min_lat" => 47.0, "max_lon" => 23.0, "max_lat" => 50.0, "min_zoom" => 13, "max_zoom" => 15 },
{ "name" => "chunk-b.pmtiles", "url" => "https://example.com/chunk-b.pmtiles", "md5sum" => "md5-b", "min_lon" => -25.0, "min_lat" => 63.0, "max_lon" => -13.0, "max_lat" => 67.0, "min_zoom" => 13, "max_zoom" => 15 },
]
end
@@ -121,6 +121,40 @@ class PmtilesBuilderTest < Minitest::Test
refute_path_exists File.join(@data_dir, "offline.pmtiles.tmp")
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
assert_equal ["extract https://example.com/fake.pmtiles"], log_lines
assert_equal({ "source" => "fake.pmtiles" }, Maptool::PmtilesBuilder.offline_meta(@data_dir))
end
def test_offline_meta_is_nil_before_any_build
assert_nil Maptool::PmtilesBuilder.offline_meta(@data_dir)
end
def test_offline_update_available_is_nil_before_any_build
assert_nil Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" })
end
def test_offline_update_available_is_false_when_unchanged
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" }).join
refute Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" })
end
def test_offline_update_available_is_true_when_a_newer_build_exists
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/old.pmtiles" }).join
assert Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_build_url: -> { "https://example.com/new.pmtiles" })
end
def test_offline_update_available_is_nil_when_the_index_is_unreachable
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" }).join
fetch_build_url = -> { raise Maptool::PmtilesBuilder::BuildError, "could not reach index" }
assert_nil Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_build_url: fetch_build_url)
end
def test_build_offline_reports_an_error_status_on_non_zero_exit
@@ -198,6 +232,43 @@ class PmtilesBuilderTest < Minitest::Test
merged = log_lines.select { |line| line.start_with?("merge ") }
assert_equal ["extract https://example.com/planet.pmtiles", "extract https://example.com/chunk-a.pmtiles"], extracted
assert_equal 1, merged.size
assert_equal(
{ "sources" => [{ "name" => "planet.pmtiles", "md5sum" => "md5-planet" }, { "name" => "chunk-a.pmtiles", "md5sum" => "md5-a" }] },
Maptool::PmtilesBuilder.terrain_meta(@data_dir),
)
end
def test_terrain_meta_is_nil_before_any_build
assert_nil Maptool::PmtilesBuilder.terrain_meta(@data_dir)
end
def test_terrain_update_available_is_false_when_unchanged
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest }).join
refute Maptool::PmtilesBuilder.terrain_update_available?(@data_dir, fetch_manifest: -> { fake_manifest })
end
# Same selection, but chunk-a's md5sum has since changed upstream.
def test_terrain_update_available_is_true_when_a_source_changed
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest }).join
assert Maptool::PmtilesBuilder.terrain_update_available?(@data_dir, fetch_manifest: -> { fake_manifest(chunk_a_md5: "md5-a-v2") })
end
# Same recorded sources, but the selection has since grown to also cover
# chunk-b — recorded sources no longer match what the current selection
# would need.
def test_terrain_update_available_is_true_when_the_selection_grew
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest }).join
select_regions("SK", "IS")
assert Maptool::PmtilesBuilder.terrain_update_available?(@data_dir, fetch_manifest: -> { fake_manifest })
end
# Slovakia + Iceland together intersect both fake chunks — planet + both