Track what was actually built, and flag when it's stale
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 16s

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:
randy committed 2026-09-11 13:18:32 +02:00
1 parent 53c9a82af2
commit 4a3862189f
7 files changed
+261 -15

No files matched your search

+65 -7
View File
@@ -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()));