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

+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);
}
}