terrain.pmtiles can take minutes (tiles fetched one at a time) with no way to tell a slow build from a stuck one. Now reports tiles_done/ tiles_total into the same status Hash the frontend already polls, and the panel shows elapsed time (both files) plus tile counts (terrain only) so the numbers visibly move every ~3s poll instead of sitting on one static string.
188 lines
6.8 KiB
JavaScript
188 lines
6.8 KiB
JavaScript
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`;
|
|
}
|
|
|
|
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 (terrain.pmtiles can
|
|
// take minutes — lib/maptool/pmtiles_builder.rb fetches tiles one at a
|
|
// time) from a stuck one. Elapsed time alone answers "is it alive" for
|
|
// both files; tile counts (only terrain.pmtiles reports these) give an
|
|
// actual sense of how much is left.
|
|
function formatRunning(status) {
|
|
const elapsed = status.started_at ? ` (${formatElapsed(Date.now() - new Date(status.started_at).getTime())})` : "";
|
|
const tiles = status.tiles_total ? ` · ${status.tiles_done}/${status.tiles_total} tiles` : "";
|
|
return `Building…${elapsed}${tiles}`;
|
|
}
|
|
|
|
// 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._statusEl.textContent = formatRunning(status);
|
|
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);
|
|
}
|
|
}
|