signalk-race-control 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.js +60 -0
  2. package/package.json +1 -1
  3. package/public/app.js +34 -21
package/index.js CHANGED
@@ -2172,6 +2172,66 @@ module.exports = function (app) {
2172
2172
  res.json({ currentRaceId: state.currentRaceId, races });
2173
2173
  });
2174
2174
 
2175
+ // Proxies an Overpass API query (used by the course editor's OpenSeaMap
2176
+ // nav-mark search/snap) through this server rather than the browser
2177
+ // calling a public Overpass mirror directly. Two problems forced this:
2178
+ // overpass-api.de — the one mirror actually verified to carry real,
2179
+ // complete seamark data — sends no Access-Control-Allow-Origin header,
2180
+ // so a browser fetch to it is blocked outright regardless of whether
2181
+ // the request itself would succeed; and of the two mirrors that do
2182
+ // support CORS, one has been unreachable and the other has been
2183
+ // observed returning a confident HTTP 200 with zero elements for real,
2184
+ // densely-tagged areas. A server-to-server request isn't subject to
2185
+ // CORS at all, so this can freely use the mirror that actually works.
2186
+ const OVERPASS_ENDPOINTS = [
2187
+ 'https://overpass-api.de/api/interpreter',
2188
+ 'https://overpass.kumi.systems/api/interpreter',
2189
+ 'https://overpass.osm.ch/api/interpreter'
2190
+ ];
2191
+ router.get('/overpass', async (req, res) => {
2192
+ const query = req.query.data;
2193
+ if (!query) return res.status(400).json({ error: 'Missing data query parameter' });
2194
+ // overpass-api.de has the best data but is also the most rate-
2195
+ // limited/overloaded public instance — it can 504 one moment and
2196
+ // work fine a couple of seconds later. A valid-but-empty response
2197
+ // from a worse mirror is accepted only as a last resort (remembered,
2198
+ // not returned immediately), and the whole mirror list gets a second
2199
+ // pass after a short pause before actually giving up — otherwise a
2200
+ // single transient overload on the good mirror would get cached by
2201
+ // the client as "nothing charted here" for the rest of the session.
2202
+ let emptyResult = null;
2203
+ for (let pass = 0; pass < 2; pass++) {
2204
+ for (const base of OVERPASS_ENDPOINTS) {
2205
+ try {
2206
+ const controller = new AbortController();
2207
+ const timer = setTimeout(() => controller.abort(), 12000);
2208
+ let r;
2209
+ try {
2210
+ // overpass-api.de specifically 406s a generic/default
2211
+ // User-Agent (confirmed: it rejects Node's own default one)
2212
+ // — identify the app explicitly, as any well-behaved
2213
+ // Overpass client should.
2214
+ r = await fetch(`${base}?data=${encodeURIComponent(query)}`, {
2215
+ signal: controller.signal,
2216
+ headers: { 'User-Agent': 'signalk-race-control (+https://github.com/joabakk/signalk-race-control)' }
2217
+ });
2218
+ } finally {
2219
+ clearTimeout(timer);
2220
+ }
2221
+ if (!r.ok) continue;
2222
+ const data = await r.json();
2223
+ if (!data || !Array.isArray(data.elements)) continue;
2224
+ if (data.elements.length) return res.json(data);
2225
+ emptyResult = data;
2226
+ } catch (e) {
2227
+ // try the next mirror
2228
+ }
2229
+ }
2230
+ if (pass === 0) await new Promise((resolve) => setTimeout(resolve, 2000));
2231
+ }
2232
+ res.json(emptyResult || { elements: [] });
2233
+ });
2234
+
2175
2235
  router.post('/races', (req, res) => {
2176
2236
  const name = ((req.body && req.body.name) || '').trim();
2177
2237
  if (!name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-race-control",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "SignalK plugin and webapp for tracking elapsed and handicap-corrected (Time-on-Time) race time, with a per-boat editable TCF and boat names pulled from AIS when available.",
5
5
  "main": "index.js",
6
6
  "author": "Joachim Bakke <github@heiamoss.com>",
package/public/app.js CHANGED
@@ -1412,32 +1412,29 @@
1412
1412
  // autocomplete and the pick-on-map snap. Silently returns nothing on
1413
1413
  // any failure or timeout, same spirit as the reverse-geocode name
1414
1414
  // suggestion.
1415
- // Public Overpass mirrors are individually flaky (rate limits, regional-
1416
- // only data, outages) — every configured one is queried and their
1417
- // results merged, rather than stopping at the first success. A single
1418
- // mirror can return a confident, well-formed, but empty result for an
1419
- // area its own extract just doesn't cover, which would otherwise poison
1420
- // the cache for that whole area for the rest of the session; querying
1421
- // both is only paid once per newly-explored area (not per keystroke or
1422
- // click the way this used to run), so the extra latency is worth it.
1423
- const OVERPASS_ENDPOINTS = ['https://overpass.kumi.systems/api/interpreter', 'https://overpass.osm.ch/api/interpreter'];
1415
+ // Goes through this plugin's own server (see the /overpass route)
1416
+ // rather than calling a public Overpass mirror directly from here: the
1417
+ // mirror with real, complete seamark data (overpass-api.de) sends no
1418
+ // CORS headers, so a browser fetch to it is blocked outright — a
1419
+ // server-to-server request isn't subject to CORS at all. The server
1420
+ // also handles falling back across mirrors, so this stays a single
1421
+ // same-origin call.
1424
1422
  async function queryOverpass(overpassQuery) {
1425
- const elements = [];
1426
- for (const base of OVERPASS_ENDPOINTS) {
1423
+ try {
1427
1424
  const controller = new AbortController();
1428
- const timer = setTimeout(() => controller.abort(), 7000);
1425
+ const timer = setTimeout(() => controller.abort(), 90000);
1426
+ let res;
1429
1427
  try {
1430
- const res = await fetch(`${base}?data=${encodeURIComponent(overpassQuery)}`, { signal: controller.signal });
1431
- if (!res.ok) continue;
1432
- const data = await res.json();
1433
- if (data && Array.isArray(data.elements)) elements.push(...data.elements);
1434
- } catch (e) {
1435
- // try the next endpoint
1428
+ res = await fetch(`${API}/overpass?data=${encodeURIComponent(overpassQuery)}`, { signal: controller.signal });
1436
1429
  } finally {
1437
1430
  clearTimeout(timer);
1438
1431
  }
1432
+ if (!res.ok) return [];
1433
+ const data = await res.json();
1434
+ return data && Array.isArray(data.elements) ? data.elements : [];
1435
+ } catch (e) {
1436
+ return [];
1439
1437
  }
1440
- return elements;
1441
1438
  }
1442
1439
 
1443
1440
  function seamarkElementsToResults(elements) {
@@ -1465,8 +1462,24 @@
1465
1462
  if (seamarkCacheBounds && seamarkCacheBounds.contains(bounds)) return;
1466
1463
  }
1467
1464
  const padded = bounds.pad(0.5);
1468
- const bbox = `${padded.getSouth()},${padded.getWest()},${padded.getNorth()},${padded.getEast()}`;
1469
- const overpassQuery = `[out:json][timeout:15];node["seamark:type"](${bbox});out body 200;`;
1465
+ // The actual Overpass query is clamped to a sane max span around the
1466
+ // padded area's center — a chart zoomed out to show a whole region
1467
+ // (easy to end up at after an auto-fit spanning far-apart boat tracks)
1468
+ // would otherwise turn this into a practically unbounded query, whose
1469
+ // out-body cap then gets consumed by whatever Overpass returns first
1470
+ // rather than the marks actually near what's being searched/picked.
1471
+ // seamarkCacheBounds still tracks the full (unclamped) padded area
1472
+ // below so a wide view isn't re-queried on every subsequent call.
1473
+ const MAX_SPAN_DEG = 1; // ~110km of latitude — generous for any single race course
1474
+ const center = padded.getCenter();
1475
+ const latSpan = Math.min(padded.getNorth() - padded.getSouth(), MAX_SPAN_DEG);
1476
+ const lonSpan = Math.min(padded.getEast() - padded.getWest(), MAX_SPAN_DEG);
1477
+ const queryBounds = L.latLngBounds(
1478
+ [center.lat - latSpan / 2, center.lng - lonSpan / 2],
1479
+ [center.lat + latSpan / 2, center.lng + lonSpan / 2]
1480
+ );
1481
+ const bbox = `${queryBounds.getSouth()},${queryBounds.getWest()},${queryBounds.getNorth()},${queryBounds.getEast()}`;
1482
+ const overpassQuery = `[out:json][timeout:15];node["seamark:type"](${bbox});out body 1000;`;
1470
1483
  seamarkCacheFetch = queryOverpass(overpassQuery);
1471
1484
  let elements;
1472
1485
  try {