Files
maptool/public/js/offline_data.js
T
randy 4a3862189f
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 16s
Track what was actually built, and flag when it's stale
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).
2026-09-11 13:18:32 +02:00

291 lines
11 KiB
JavaScript

import { OFFLINE_DATA_ICON } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
import { isLoggedIn } from "/js/auth.js";
import { escapeHtml } from "/js/gpx.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`;
}
function formatElapsed(ms) {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
}
// "Building…" alone gave no way to tell a slow build from a stuck one —
// elapsed time answers "is it alive" regardless of how long a given
// selection takes.
function formatRunning(status) {
const elapsed = status.started_at ? ` (${formatElapsed(Date.now() - new Date(status.started_at).getTime())})` : "";
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
// does that across both files once the selection is saved.
class RegionPicker {
constructor(onApply) {
this._onApply = onApply;
}
async mount(container) {
const section = document.createElement("div");
section.className = "offline-data-panel__regions";
section.innerHTML = `
<p class="offline-data-panel__regions-label">Regions</p>
<ul class="offline-data-panel__region-list"><li class="offline-data-panel__region-status">Loading…</li></ul>
<button type="button" class="offline-data-panel__apply" hidden>Apply changes</button>
`;
this._list = section.querySelector(".offline-data-panel__region-list");
this._applyButton = section.querySelector(".offline-data-panel__apply");
// Viewing/ticking works for anyone; only saving 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._applyButton.hidden = !isLoggedIn();
this._applyButton.addEventListener("click", () => this._apply());
container.appendChild(section);
let data;
try {
data = await (await fetch("/api/offline-regions")).json();
} catch {
this._list.innerHTML = `<li class="offline-data-panel__region-status">Could not load the region list.</li>`;
return;
}
const selected = new Set(data.selected);
this._list.innerHTML = data.available
.map(
(region) => `
<li>
<label>
<input type="checkbox" value="${escapeHtml(region.code)}" ${selected.has(region.code) ? "checked" : ""}>
<span aria-hidden="true">${flagEmoji(region.code)}</span> ${escapeHtml(region.name)}
</label>
</li>`,
)
.join("");
}
async _apply() {
const codes = [...this._list.querySelectorAll("input:checked")].map((el) => el.value);
const body = new URLSearchParams();
codes.forEach((code) => body.append("codes[]", code));
this._applyButton.disabled = true;
try {
const response = await fetch("/api/offline-regions", { method: "POST", body });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `could not save the selection: ${response.status}`);
} catch (err) {
window.alert(err.message);
return;
} finally {
this._applyButton.disabled = false;
}
this._onApply();
}
}
// 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/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, checkUpdateUrl, formatMeta }) {
this._label = label;
this._statusUrl = statusUrl;
this._buildUrl = buildUrl;
this._buildStatusUrl = buildStatusUrl;
this._checkUpdateUrl = checkUpdateUrl;
this._formatMeta = formatMeta;
}
mount(container) {
const row = document.createElement("div");
row.className = "offline-data-panel__row";
row.innerHTML = `
<strong>${this._label}</strong>
<p class="offline-data-panel__status">Checking…</p>
`;
this._statusEl = row.querySelector(".offline-data-panel__status");
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;
}
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
// new selection — not tied to a button of this row's own.
async triggerBuild() {
this._statusEl.textContent = "Starting…";
try {
const response = await fetch(this._buildUrl, { method: "POST" });
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;
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._statusEl.textContent = formatRunning(status);
this._poll();
return;
}
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 — a region picker (which countries
// should be available offline) plus, on "Apply changes", a rebuild of
// offline.pmtiles/terrain.pmtiles covering the whole current selection
// (see lib/maptool/pmtiles_builder.rb — there's no standalone per-country
// file to individually delete; "remove a country" and "refresh a
// country" are both just "change the selection, then Apply").
export class OfflineDataControl {
constructor() {
this._rows = [
new DataFileRow({
label: "Offline map data",
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()));
}
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._regionPicker.mount(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);
}
}