The only visibility into downloaded data was the whole offline.pmtiles row, and the only delete was "everything" (whole file + whole cache). GET /api/offline-tiles/cache lists what's really in data/geofabrik_cache/ right now — distinct from offline.pmtiles' own meta.sources, which only reflects the last successful build and can disagree with both the current selection and the cache (ticked-but- not-Applied, or built-from-but-since-unticked). The region picker now shows each cached country's size next to its checkbox, with its own delete button (DELETE /api/offline-tiles/cache/:code, already added but unused by the UI until now) — removing just that entry rather than clearing the whole cache.
376 lines
14 KiB
JavaScript
376 lines
14 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, or
|
|
// which of the current selection's sources it's actually working on right
|
|
// now — elapsed time answers "is it alive", status.step (see
|
|
// PmtilesBuilder.run_job) answers "doing what, and what's left".
|
|
function formatRunning(status) {
|
|
const elapsed = status.started_at ? ` (${formatElapsed(Date.now() - new Date(status.started_at).getTime())})` : "";
|
|
const step = status.step ? `: ${status.step}` : "";
|
|
return `Building…${elapsed}${step}`;
|
|
}
|
|
|
|
// Vendored square-cropped SVGs (public/vendor/flag-icons/, MIT, see
|
|
// docs/third-party-notices.adoc) rather than Unicode flag emoji — emoji
|
|
// are font glyphs, not shapes, so they can't be resized/bordered/muted to
|
|
// fit this app's own controlled, hand-drawn-icon look the way a real SVG
|
|
// can (same reasoning the topbar readout's icons already settled on
|
|
// earlier — see icons.js). Framed in a small rounded-square badge in the
|
|
// markup below, the same convention icons.js's MAP_MARKER_ICON already
|
|
// uses for framing a category glyph, rather than a new visual language.
|
|
function flagIconUrl(code) {
|
|
return `/vendor/flag-icons/1x1/${code.toLowerCase()}.svg`;
|
|
}
|
|
|
|
// meta.sources is one entry per country the current build covers (see
|
|
// lib/maptool/pmtiles_builder.rb) — listing the codes is more useful here
|
|
// than a count, since they're exactly what RegionPicker's checklist above
|
|
// already shows.
|
|
function formatOfflineMeta(meta) {
|
|
if (!meta) return "";
|
|
return ` · ${meta.sources.map((s) => s.code).join(", ")}`;
|
|
}
|
|
|
|
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;
|
|
let cache;
|
|
try {
|
|
[data, cache] = await Promise.all([
|
|
fetch("/api/offline-regions").then((r) => r.json()),
|
|
fetch("/api/offline-tiles/cache").then((r) => r.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);
|
|
const cached = new Map(cache.extracts.map((e) => [e.code, e]));
|
|
this._list.innerHTML = data.available
|
|
.map((region) => {
|
|
const extract = cached.get(region.code);
|
|
// Downloaded-but-not-(yet)-built is a real state (ticked, not
|
|
// Applied yet) as is built-but-no-longer-selected (unticked since)
|
|
// — this badge reflects the cache on disk, independent of either.
|
|
const cacheInfo = extract
|
|
? `
|
|
<span class="offline-data-panel__cache-info">
|
|
${formatBytes(extract.size)}
|
|
${
|
|
isLoggedIn()
|
|
? `<button type="button" class="offline-data-panel__cache-delete" data-code="${escapeHtml(region.code)}" data-name="${escapeHtml(region.name)}" title="Delete downloaded data for ${escapeHtml(region.name)}">✕</button>`
|
|
: ""
|
|
}
|
|
</span>`
|
|
: "";
|
|
return `
|
|
<li>
|
|
<label>
|
|
<input type="checkbox" value="${escapeHtml(region.code)}" ${selected.has(region.code) ? "checked" : ""}>
|
|
<img class="offline-data-panel__flag" src="${flagIconUrl(region.code)}" alt="" width="18" height="18">
|
|
${escapeHtml(region.name)}
|
|
</label>
|
|
${cacheInfo}
|
|
</li>`;
|
|
})
|
|
.join("");
|
|
|
|
this._list.querySelectorAll(".offline-data-panel__cache-delete").forEach((button) => {
|
|
button.addEventListener("click", () => this._deleteCache(button));
|
|
});
|
|
}
|
|
|
|
// Removes just this row's cache badge on success, rather than re-fetching
|
|
// and re-rendering the whole list — that would also overwrite whatever
|
|
// unticked/ticked-but-not-yet-Applied state the user has pending.
|
|
async _deleteCache(button) {
|
|
const code = button.dataset.code;
|
|
if (!window.confirm(`Delete downloaded data for ${button.dataset.name}? This can't be undone.`)) return;
|
|
|
|
button.disabled = true;
|
|
try {
|
|
const response = await fetch(`/api/offline-tiles/cache/${code}`, { method: "DELETE" });
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(data.error || `could not delete: ${response.status}`);
|
|
} catch (err) {
|
|
window.alert(err.message);
|
|
button.disabled = false;
|
|
return;
|
|
}
|
|
|
|
button.closest(".offline-data-panel__cache-info").remove();
|
|
}
|
|
|
|
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, deleteUrl, buildUrl, buildStatusUrl, checkUpdateUrl, formatMeta }) {
|
|
this._label = label;
|
|
this._statusUrl = statusUrl;
|
|
this._deleteUrl = deleteUrl;
|
|
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 = `
|
|
<div class="offline-data-panel__row-head">
|
|
<strong>${this._label}</strong>
|
|
<button type="button" class="offline-data-panel__delete" hidden>Delete</button>
|
|
</div>
|
|
<p class="offline-data-panel__status">Checking…</p>
|
|
`;
|
|
this._statusEl = row.querySelector(".offline-data-panel__status");
|
|
this._deleteButton = row.querySelector(".offline-data-panel__delete");
|
|
this._deleteButton.addEventListener("click", () => this._delete());
|
|
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.";
|
|
this._deleteButton.hidden = true;
|
|
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._deleteButton.hidden = !isLoggedIn();
|
|
this._checkUpdate();
|
|
}
|
|
|
|
// Deletes the built file (and, for offline.pmtiles, its whole per-country
|
|
// download cache — see PmtilesBuilder.delete_offline_data) so the row
|
|
// goes back to "Not generated yet." — a manual way to reclaim space or
|
|
// force a completely fresh rebuild, since Apply alone never removes
|
|
// anything already on disk.
|
|
async _delete() {
|
|
if (!window.confirm(`Delete ${this._label.toLowerCase()}? This can't be undone.`)) return;
|
|
|
|
this._deleteButton.disabled = true;
|
|
try {
|
|
const response = await fetch(this._deleteUrl, { method: "DELETE" });
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(data.error || `could not delete: ${response.status}`);
|
|
} catch (err) {
|
|
window.alert(err.message);
|
|
return;
|
|
} finally {
|
|
this._deleteButton.disabled = false;
|
|
}
|
|
|
|
this._refreshStatus();
|
|
}
|
|
|
|
// Separate from _refreshStatus's own fetch (and only called once, right
|
|
// after — not polled): this one reaches an external service (Geofabrik
|
|
// 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",
|
|
deleteUrl: "/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",
|
|
deleteUrl: "/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);
|
|
}
|
|
}
|