diff --git a/docs/architecture.adoc b/docs/architecture.adoc index 92ae475..3a198eb 100644 --- a/docs/architecture.adoc +++ b/docs/architecture.adoc @@ -189,7 +189,13 @@ equivalent, so it never produces `Point` features. MapLibre's `geometry-type` expression treats `MultiLineString` the same as `LineString`, so a single GeoJSON source per file with a `line` layer and a `circle` layer (each filtered by geometry type) is enough to -render both. +render both. Checking a file fetches its GeoJSON itself +(`public/js/gpx.js`'s `_addLayers`) and hands the parsed result to +`addSource` — rather than passing MapLibre a URL and letting it fetch +internally — specifically so a `422` (a file REXML or `Fit` can't +parse) surfaces as an alert and un-checks the box instead of the layer +just silently never appearing, which is what a `data: url` source does +on a failed fetch. FIT parsing:: No gem was added for this — the one Ruby option (`fit-parser`, tjwallace/fit) hasn't been touched since 2022 and pulls diff --git a/public/js/gpx.js b/public/js/gpx.js index a7d4d4b..0c1cb7d 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -206,7 +206,7 @@ export class GpxPanelControl { const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = this._visible.has(file.path); - checkbox.addEventListener("change", () => this._toggleFile(file.path, checkbox.checked)); + checkbox.addEventListener("change", () => this._toggleFile(file.path, checkbox.checked, checkbox)); const name = document.createElement("span"); name.className = "gpx-tree__file-name"; name.textContent = file.name; @@ -269,21 +269,48 @@ export class GpxPanelControl { }); } - _toggleFile(path, visible) { + async _toggleFile(path, visible, checkbox) { if (visible) { this._visible.add(path); - this._addLayers(path); + const ok = await this._addLayers(path); + if (!ok) { + // Loading failed (see _addLayers) — don't leave the box checked + // for a file that isn't actually showing anything. + this._visible.delete(path); + if (checkbox) checkbox.checked = false; + } } else { this._visible.delete(path); this._removeLayers(path); } } - _addLayers(path) { + // Fetches the file's GeoJSON itself (rather than handing maplibre a URL + // to fetch internally) so a parse failure — a real GPX a device/app + // wrote in some way REXML doesn't like, say — surfaces as an alert + // instead of the layer just silently never appearing. Returns whether it + // succeeded. + async _addLayers(path) { const id = sourceId(path); - if (this._map.getSource(id)) return; + if (this._map.getSource(id)) return true; - this._map.addSource(id, { type: "geojson", data: gpxFileUrl(path) }); + let geojson; + try { + const response = await fetch(gpxFileUrl(path)); + if (!response.ok) { + const { error } = await response.json().catch(() => ({})); + window.alert(error || `Could not load "${path}".`); + return false; + } + geojson = await response.json(); + } catch (err) { + window.alert(`Could not load "${path}": ${err.message}`); + return false; + } + + if (this._map.getSource(id)) return true; // added concurrently (e.g. a style.load race) while this was fetching + + this._map.addSource(id, { type: "geojson", data: geojson }); this._map.addLayer({ id: `${id}-line`, type: "line", @@ -307,6 +334,7 @@ export class GpxPanelControl { this._registerPointInteractivity(`${id}-points`); this._registerLineInteractivity(`${id}-line`); + return true; } // Registered once per layer id, ever — map.on(type, layerId, fn) is a