Track what was actually built, and flag when it's stale
The .pmtiles files themselves carry no record of what upstream snapshot they came from — only a local mtime. Each successful build now writes a small sidecar (offline.pmtiles.meta.json: the Protomaps build date used; terrain.pmtiles.meta.json: the Mapterhorn source names/md5sums) recording that, surfaced in the panel next to the existing size/updated line. New GET .../check-update routes compare the recorded build against the live upstream index/manifest — true/false/null, null meaning "don't know" (no local build yet, or the check itself failed) rather than a false "no". Kept separate from the plain status endpoints (which map.js's own topbar dot hits on every page load) since this one reaches an external service; only called once when the Offline data panel is actually opened. Also: flag emoji next to each country in the picker (derived from the ISO code via Unicode regional indicators, no image assets needed).
This commit is contained in:
1 parent
53c9a82af2
commit
4a3862189f
7 files changed
+261
-15
No files matched your search
@@ -25,6 +25,30 @@ function formatRunning(status) {
|
||||
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
|
||||
@@ -66,7 +90,7 @@ class RegionPicker {
|
||||
<li>
|
||||
<label>
|
||||
<input type="checkbox" value="${escapeHtml(region.code)}" ${selected.has(region.code) ? "checked" : ""}>
|
||||
${escapeHtml(region.name)}
|
||||
<span aria-hidden="true">${flagEmoji(region.code)}</span> ${escapeHtml(region.name)}
|
||||
</label>
|
||||
</li>`,
|
||||
)
|
||||
@@ -97,16 +121,19 @@ class RegionPicker {
|
||||
// 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
|
||||
// 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 }) {
|
||||
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) {
|
||||
@@ -129,9 +156,36 @@ class DataFileRow {
|
||||
this._statusEl.textContent = "Could not check status.";
|
||||
return;
|
||||
}
|
||||
this._statusEl.textContent = data.available
|
||||
? `${formatBytes(data.size)} · updated ${new Date(data.mtime).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}`
|
||||
: "Not generated yet.";
|
||||
|
||||
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
|
||||
@@ -193,12 +247,16 @@ export class OfflineDataControl {
|
||||
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()));
|
||||
|
||||
Reference in new issue
Block a user