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 = `

Regions

`; 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 = `
  • Could not load the region list.
  • `; return; } const selected = new Set(data.selected); this._list.innerHTML = data.available .map( (region) => `
  • `, ) .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 = ` ${this._label}

    Checking…

    `; 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 = ``; 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); } }