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