Race 3 Overpass mirrors for speed, show more per-route detail
CI / check (push) Successful in 2m18s
CI / deploy (push) Successful in 11s

The single shared overpass-api.de instance varies wildly in load —
Promise.any against three independent public mirrors (also CORS-open,
verified) means only the slowest one determines wait time, not
whichever happens to be hit.

Also show the description tag (falls back to it for the title when a
route has no name, e.g. many KST route relations), the ref number, and
a "View on OpenStreetMap" link — the popup was skipping data that was
already in the response for free.
This commit is contained in:
randy committed 2026-09-13 08:53:48 +02:00
1 parent 6f1577f957
commit cc3d8ca5f3
1 file changed
+32 -10
+32 -10
View File
@@ -13,7 +13,18 @@ const LAYER_ID = "hiking-trails-line";
// hits from here. One query per click (not per pan/move like the // hits from here. One query per click (not per pan/move like the
// Wikipedia layer's live refetch), so this stays well within Overpass's // Wikipedia layer's live refetch), so this stays well within Overpass's
// shared rate limits. // shared rate limits.
const OVERPASS_API = "https://overpass-api.de/api/interpreter"; //
// Three independent public mirrors (also CORS-open, verified), raced with
// Promise.any rather than called one at a time — the shared free instances
// vary a lot in load from one moment to the next, and a multi-second wait
// on a single one was the main complaint. Whichever answers first wins;
// the others are simply left to finish and get GC'd, not worth the extra
// AbortController plumbing for a single one-off per-click request.
const OVERPASS_APIS = [
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
"https://overpass.private.coffee/api/interpreter",
];
const CLICK_RADIUS_M = 40; const CLICK_RADIUS_M = 40;
// OSM colour tag values are free text (untrusted) — only used as a CSS // OSM colour tag values are free text (untrusted) — only used as a CSS
// background if they look like an actual color (hex or a plain word), // background if they look like an actual color (hex or a plain word),
@@ -128,14 +139,15 @@ export class HikingTrailsControl {
// waymarkedtrails.org's own site. `out geom` inlines each member way's // waymarkedtrails.org's own site. `out geom` inlines each member way's
// full coordinate list right on the relation, so no separate stitching // full coordinate list right on the relation, so no separate stitching
// step is needed to get real geometry back. // step is needed to get real geometry back.
async function queryOverpass(url, query) {
const response = await fetch(url, { method: "POST", body: `data=${encodeURIComponent(query)}` });
if (!response.ok) throw new Error(`overpass ${response.status}`);
return response.json();
}
async function fetchNearbyRoutes(lng, lat) { async function fetchNearbyRoutes(lng, lat) {
const query = `[out:json][timeout:15];way(around:${CLICK_RADIUS_M},${lat},${lng})->.w;rel(bw.w)["route"="hiking"];out geom;`; const query = `[out:json][timeout:15];way(around:${CLICK_RADIUS_M},${lat},${lng})->.w;rel(bw.w)["route"="hiking"];out geom;`;
const response = await fetch(OVERPASS_API, { const data = await Promise.any(OVERPASS_APIS.map((url) => queryOverpass(url, query)));
method: "POST",
body: `data=${encodeURIComponent(query)}`,
});
if (!response.ok) throw new Error(`overpass ${response.status}`);
const data = await response.json();
return (data.elements || []).filter((element) => element.type === "relation"); return (data.elements || []).filter((element) => element.type === "relation");
} }
@@ -151,16 +163,26 @@ function routeDistance(relation) {
function routeHtml(relation) { function routeHtml(relation) {
const tags = relation.tags || {}; const tags = relation.tags || {};
const name = tags.name || tags.ref || "Hiking trail"; const name = tags.name || tags.description || "Hiking trail";
const swatch = SAFE_COLOR.test(tags.colour || "") const swatch = SAFE_COLOR.test(tags.colour || "")
? `<span class="hiking-trail-popup__swatch" style="background:${tags.colour}"></span>` ? `<span class="hiking-trail-popup__swatch" style="background:${tags.colour}"></span>`
: ""; : "";
const meta = [formatDistance(routeDistance(relation))]; const meta = [formatDistance(routeDistance(relation))];
if (tags.ref) meta.push(`#${tags.ref}`);
if (tags.network) meta.push(tags.network); if (tags.network) meta.push(tags.network);
if (tags.operator) meta.push(escapeHtml(tags.operator)); if (tags.operator) meta.push(escapeHtml(tags.operator));
return [ const parts = [
`<div class="gpx-popup__header"><strong>${escapeHtml(name)}</strong>${swatch}</div>`, `<div class="gpx-popup__header"><strong>${escapeHtml(name)}</strong>${swatch}</div>`,
`<p class="gpx-popup__category">${meta.filter(Boolean).join(" · ")}</p>`, `<p class="gpx-popup__category">${meta.filter(Boolean).join(" · ")}</p>`,
].join(""); ];
// description already used as the title above when there's no name — only
// shown again here as its own line if there both is a name *and* a
// description to add, so it's never rendered twice for the common
// no-name case.
if (tags.name && tags.description) parts.push(`<p class="gpx-popup__description">${escapeHtml(tags.description)}</p>`);
parts.push(
`<p class="gpx-popup__link"><a href="https://www.openstreetmap.org/relation/${relation.id}" target="_blank" rel="noopener noreferrer">View on OpenStreetMap →</a></p>`,
);
return parts.join("");
} }