diff --git a/public/js/hiking_trails.js b/public/js/hiking_trails.js index 86940e8..8ad9916 100644 --- a/public/js/hiking_trails.js +++ b/public/js/hiking_trails.js @@ -13,7 +13,18 @@ const LAYER_ID = "hiking-trails-line"; // 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 // 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; // 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), @@ -128,14 +139,15 @@ export class HikingTrailsControl { // waymarkedtrails.org's own site. `out geom` inlines each member way's // full coordinate list right on the relation, so no separate stitching // 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) { 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, { - method: "POST", - body: `data=${encodeURIComponent(query)}`, - }); - if (!response.ok) throw new Error(`overpass ${response.status}`); - const data = await response.json(); + const data = await Promise.any(OVERPASS_APIS.map((url) => queryOverpass(url, query))); return (data.elements || []).filter((element) => element.type === "relation"); } @@ -151,16 +163,26 @@ function routeDistance(relation) { function routeHtml(relation) { 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 meta = [formatDistance(routeDistance(relation))]; + if (tags.ref) meta.push(`#${tags.ref}`); if (tags.network) meta.push(tags.network); if (tags.operator) meta.push(escapeHtml(tags.operator)); - return [ + const parts = [ `
${escapeHtml(name)}${swatch}
`, `

${meta.filter(Boolean).join(" · ")}

`, - ].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(`

${escapeHtml(tags.description)}

`); + parts.push( + ``, + ); + return parts.join(""); }