Add a country-based region picker for offline data
Replaces "regenerate for the current viewport" with what Locus/OsmAnd- style apps give you: tick which countries you want offline (a Natural Earth-derived checklist, public domain, vendored), Apply rebuilds offline.pmtiles/terrain.pmtiles to cover the whole selection at once. Multiple regions can be offline simultaneously (e.g. Slovakia + Austria for a trip that crosses the border) — the selection itself is the source of truth (data/offline_regions.json), not a set of per-country files, so "remove a country" and "refresh a country" are both just "change the selection, then Apply." Confirmed against go-pmtiles' own source (not just its docs) before committing to this shape: `extract --region=<geojson>` treats a FeatureCollection's features as their union already (no dissolve/union math needed on our side — pmtiles/region.go's UnmarshalRegion just concatenates every feature's rings), and `merge` hard-errors on any overlapping tile id between inputs (merge.go's prepareInputs). That second fact rules out "extract each country separately, merge them all" — adjacent countries' region-extracts would share border tiles, since extraction copies whole tiles rather than clipping vector content to the polygon. The actual fix: extract per *source archive* (the Protomaps build, each intersecting Mapterhorn chunk), each call covering the whole current selection via one --region= file — the terrain pipeline already extracted-then-merged per source this way, it just needed a multi-country region instead of a single bbox.
This commit is contained in:
1 parent
f89dea5805
commit
53c9a82af2
10 files changed
+442
-170
No files matched your search
+38
-12
@@ -401,39 +401,65 @@ html, body {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.offline-data-panel__row + .offline-data-panel__row {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #eee;
|
||||
.offline-data-panel__regions-label {
|
||||
margin: 0 0 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.offline-data-panel__row-header {
|
||||
.offline-data-panel__region-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.offline-data-panel__region-list label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.offline-data-panel__build {
|
||||
flex-shrink: 0;
|
||||
.offline-data-panel__region-list label:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.offline-data-panel__region-status {
|
||||
padding: 6px 8px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.offline-data-panel__apply {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
padding: 4px 8px;
|
||||
padding: 6px 8px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.offline-data-panel__build:hover {
|
||||
.offline-data-panel__apply:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.offline-data-panel__build:disabled {
|
||||
.offline-data-panel__apply:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.offline-data-panel__row {
|
||||
margin-top: 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.offline-data-panel__status {
|
||||
margin: 6px 0 0;
|
||||
color: #666;
|
||||
|
||||
+1
-1
@@ -516,7 +516,7 @@ if (isLoggedIn()) new PluginsControl(map).mount(sidebar);
|
||||
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Offline hub"></div>`);
|
||||
layerToggle.mount(sidebar);
|
||||
layerToggle.setActive("online");
|
||||
new OfflineDataControl(map).mount(sidebar);
|
||||
new OfflineDataControl().mount(sidebar);
|
||||
|
||||
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Navigation"></div>`);
|
||||
gnssControl.mount(sidebar);
|
||||
|
||||
+98
-53
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -16,51 +17,106 @@ function formatElapsed(ms) {
|
||||
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.
|
||||
// "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())})` : "";
|
||||
const tiles = status.tiles_total ? ` · ${status.tiles_done}/${status.tiles_total} tiles` : "";
|
||||
return `Building…${elapsed}${tiles}`;
|
||||
return `Building…${elapsed}`;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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" : ""}>
|
||||
${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 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;
|
||||
constructor({ label, statusUrl, buildUrl, buildStatusUrl }) {
|
||||
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>
|
||||
<strong>${this._label}</strong>
|
||||
<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();
|
||||
}
|
||||
@@ -73,34 +129,21 @@ class DataFileRow {
|
||||
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;
|
||||
// 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", body });
|
||||
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;
|
||||
this._buildButton.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,7 +169,6 @@ class DataFileRow {
|
||||
return;
|
||||
}
|
||||
|
||||
this._buildButton.disabled = false;
|
||||
if (status.state === "error") {
|
||||
this._statusEl.textContent = `Build failed: ${status.message}`;
|
||||
} else {
|
||||
@@ -137,27 +179,29 @@ class DataFileRow {
|
||||
}
|
||||
|
||||
// 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."
|
||||
// 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(map) {
|
||||
constructor() {
|
||||
this._rows = [
|
||||
new DataFileRow(map, {
|
||||
new DataFileRow({
|
||||
label: "Offline map data",
|
||||
statusUrl: "/api/offline-tiles",
|
||||
buildUrl: "/api/offline-tiles/build",
|
||||
buildStatusUrl: "/api/offline-tiles/build-status",
|
||||
}),
|
||||
new DataFileRow(map, {
|
||||
new DataFileRow({
|
||||
label: "Offline terrain data",
|
||||
statusUrl: "/api/terrain-tiles",
|
||||
buildUrl: "/api/terrain-tiles/build",
|
||||
buildStatusUrl: "/api/terrain-tiles/build-status",
|
||||
}),
|
||||
];
|
||||
this._regionPicker = new RegionPicker(() => this._rows.forEach((row) => row.triggerBuild()));
|
||||
}
|
||||
|
||||
mount(sidebar) {
|
||||
@@ -171,6 +215,7 @@ export class OfflineDataControl {
|
||||
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"));
|
||||
|
||||
Reference in new issue
Block a user