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).
291 lines
11 KiB
JavaScript
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);
|
|
}
|
|
}
|