@signalk/tracks-plugin 3.0.0-beta.2 → 3.0.0-beta.4

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.
package/dist/index.js CHANGED
@@ -3,11 +3,123 @@ import { join } from "node:path";
3
3
  import { Type } from "typebox";
4
4
  import { Value } from "typebox/value";
5
5
  import { DatabaseSync } from "node:sqlite";
6
+ import { DOMParser } from "@xmldom/xmldom";
6
7
  import { BehaviorSubject, ReplaySubject, Subject, combineLatest, connectable, firstValueFrom } from "rxjs";
7
8
  import { map, scan, startWith, throttleTime } from "rxjs/operators";
8
9
  //#region \0rolldown/runtime.js
9
10
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
10
11
  //#endregion
12
+ //#region src/simplify.ts
13
+ var LAT$2 = 0;
14
+ var LNG$2 = 1;
15
+ /**
16
+ * Metres per degree of latitude. Good to ~0.5% over the WGS84 ellipsoid.
17
+ *
18
+ * Exported because the provider derives its automatic tolerance from the same
19
+ * figure: "one part in a thousand of the diagonal" only holds while both use
20
+ * one metres-per-degree value.
21
+ */
22
+ var M_PER_DEG = 111320;
23
+ /**
24
+ * Span, in scaled degrees, beyond which the flat projection is not trusted.
25
+ *
26
+ * Ten degrees is ~1,100 km, where the projection still agrees with a spherical
27
+ * cross-track calculation to 0.1%. No leg between two recorded positions comes
28
+ * close; a segment that does is a data artefact rather than a passage.
29
+ */
30
+ var SPAN_LIMIT = 10;
31
+ /**
32
+ * Douglas-Peucker simplification of a recorded track.
33
+ *
34
+ * Distinct from `thin()`, and the difference is the whole point: thinning
35
+ * decimates by *time*, so it keeps a point every N seconds whether the boat was
36
+ * turning or holding a course, and a corner falling between two samples is
37
+ * simply lost. Simplification decimates by *shape* — it keeps the points that
38
+ * carry the geometry and drops the ones a straight line already describes.
39
+ *
40
+ * That is what makes this the piece a route conversion needs. 5,550 points at
41
+ * 1.2 s spacing is a recording, not a route; thinning it to 30 points gives 30
42
+ * evenly spaced positions that miss every turn, while simplifying it to 30
43
+ * gives the turns and nothing else.
44
+ *
45
+ * `epsilon` is a tolerance in **metres**: no point of the original track is
46
+ * further than this from the simplified line.
47
+ */
48
+ function simplify(points, epsilon) {
49
+ if (!(epsilon > 0) || points.length <= 2) return points;
50
+ const keep = new Uint8Array(points.length);
51
+ keep[0] = 1;
52
+ keep[points.length - 1] = 1;
53
+ const stack = [[0, points.length - 1]];
54
+ while (stack.length > 0) {
55
+ const [first, last] = stack.pop();
56
+ if (last <= first + 1) continue;
57
+ let worst = 0;
58
+ let worstAt = -1;
59
+ for (let i = first + 1; i < last; i++) {
60
+ const d = perpendicularDistance(points[i], points[first], points[last]);
61
+ if (d > worst) {
62
+ worst = d;
63
+ worstAt = i;
64
+ }
65
+ }
66
+ if (worstAt >= 0 && worst > epsilon) {
67
+ keep[worstAt] = 1;
68
+ stack.push([first, worstAt], [worstAt, last]);
69
+ }
70
+ }
71
+ return points.filter((_, i) => keep[i] === 1);
72
+ }
73
+ /**
74
+ * Distance in metres from `p` to the segment `a`-`b`.
75
+ *
76
+ * Positions are projected to a local plane with longitude scaled by
77
+ * cos(latitude) at the segment's midpoint. Measured against a spherical
78
+ * cross-track reference the error is 0.1% for segments spanning up to ten
79
+ * degrees of latitude — some 1,100 km, longer than any leg between two
80
+ * recorded fixes — which is far below the metre-scale tolerances this runs
81
+ * with, and it avoids trigonometry per point per recursion level (2.9 ms for
82
+ * 5,550 points, and `simplifyToBudget` calls this ~40 times).
83
+ *
84
+ * One flat scale cannot hold across a segment spanning most of a hemisphere:
85
+ * an 80N-to-equator segment reads 85 km where the truth is 111 km. Such a
86
+ * segment means two consecutive fixes an ocean and a continent apart, which
87
+ * recorded data does not contain — but the tolerance is documented in metres,
88
+ * so `SPAN_LIMIT` refuses to answer rather than answer wrongly, in either
89
+ * axis.
90
+ */
91
+ function perpendicularDistance(p, a, b) {
92
+ const cos = Math.cos((a.position[LAT$2] + b.position[LAT$2]) / 2 * Math.PI / 180);
93
+ const originLng = a.position[LNG$2];
94
+ if (Math.abs(a.position[LAT$2] - b.position[LAT$2]) > SPAN_LIMIT || Math.abs(nearestLongitude(b.position[LNG$2], originLng) - originLng) * cos > SPAN_LIMIT) return Infinity;
95
+ const ax = originLng * cos;
96
+ const ay = a.position[LAT$2];
97
+ const bx = nearestLongitude(b.position[LNG$2], originLng) * cos;
98
+ const by = b.position[LAT$2];
99
+ const px = nearestLongitude(p.position[LNG$2], originLng) * cos;
100
+ const py = p.position[LAT$2];
101
+ const dx = bx - ax;
102
+ const dy = by - ay;
103
+ let t = 0;
104
+ const lenSq = dx * dx + dy * dy;
105
+ if (lenSq > 0) t = Math.min(1, Math.max(0, ((px - ax) * dx + (py - ay) * dy) / lenSq));
106
+ const cx = ax + t * dx;
107
+ const cy = ay + t * dy;
108
+ return Math.hypot(px - cx, py - cy) * M_PER_DEG;
109
+ }
110
+ /**
111
+ * `lng` shifted by whole turns to sit within 180 degrees of `origin`.
112
+ *
113
+ * A track crossing the antimeridian is stored as 179.9 then -179.9, a tenth of
114
+ * a degree apart on the ground but 359.8 apart numerically. Without this, a
115
+ * straight line across it reads as a segment spanning almost the whole globe,
116
+ * and points that lie exactly on that line measure kilometres away from it —
117
+ * so simplification keeps every one of them.
118
+ */
119
+ function nearestLongitude(lng, origin) {
120
+ return lng - 360 * Math.round((lng - origin) / 360);
121
+ }
122
+ //#endregion
11
123
  //#region src/timeWindow.ts
12
124
  /**
13
125
  * Time-window and resolution query parameters.
@@ -232,8 +344,8 @@ function segment(points, gap) {
232
344
  }
233
345
  //#endregion
234
346
  //#region src/utils.ts
235
- var LAT = 0;
236
- var LNG = 1;
347
+ var LAT$1 = 0;
348
+ var LNG$1 = 1;
237
349
  /**
238
350
  * The recording times of a segment, as ISO-8601 UTC, positionally aligned with
239
351
  * the segment's coordinates.
@@ -247,13 +359,13 @@ function toIsoTimes(segment) {
247
359
  return segment.map(({ timestamp }) => new Date(timestamp).toISOString());
248
360
  }
249
361
  function createInBounds(bounds) {
250
- const minLat = bounds.sw[LAT];
251
- const maxLat = bounds.ne[LAT];
362
+ const minLat = bounds.sw[LAT$1];
363
+ const maxLat = bounds.ne[LAT$1];
252
364
  if (minLat > maxLat) throw new Error(`Bounding box south must be <= north, got ${JSON.stringify(bounds)}`);
253
- const minLng = bounds.sw[LNG];
254
- const maxLng = (bounds.sw[LNG] > bounds.ne[LNG] ? 360 : 0) + bounds.ne[LNG];
365
+ const minLng = bounds.sw[LNG$1];
366
+ const maxLng = (bounds.sw[LNG$1] > bounds.ne[LNG$1] ? 360 : 0) + bounds.ne[LNG$1];
255
367
  return (p) => {
256
- return p !== null && p[LAT] >= minLat && p[LAT] <= maxLat && (p[LNG] >= minLng && p[LNG] <= maxLng || p[LNG] + 360 >= minLng && p[LNG] + 360 <= maxLng);
368
+ return p !== null && p[LAT$1] >= minLat && p[LAT$1] <= maxLat && (p[LNG$1] >= minLng && p[LNG$1] <= maxLng || p[LNG$1] + 360 >= minLng && p[LNG$1] + 360 <= maxLng);
257
369
  };
258
370
  }
259
371
  /**
@@ -271,11 +383,11 @@ function createInBounds(bounds) {
271
383
  * iterate the result.
272
384
  */
273
385
  function splitAtAntimeridian(bounds) {
274
- const west = bounds.sw[LNG];
275
- const east = bounds.ne[LNG];
386
+ const west = bounds.sw[LNG$1];
387
+ const east = bounds.ne[LNG$1];
276
388
  if (west <= east) return [bounds];
277
- const south = bounds.sw[LAT];
278
- const north = bounds.ne[LAT];
389
+ const south = bounds.sw[LAT$1];
390
+ const north = bounds.ne[LAT$1];
279
391
  return [{
280
392
  sw: [south, west],
281
393
  ne: [north, 180]
@@ -378,6 +490,91 @@ var historyRowPosition = (row) => {
378
490
  if (Number.isFinite(latitude) && Number.isFinite(longitude)) return [latitude, longitude];
379
491
  }
380
492
  };
493
+ /**
494
+ * The name of the vessel (or aircraft, or aton) a context refers to.
495
+ *
496
+ * Deliberately bare — `Ariadne`, not `AIS Ariadne` and not `Own Ship`. This is
497
+ * the v2 Track API's `contextName`, whose spec says "Name of the vessel,
498
+ * aircraft or other context, where known. Not a name for the track itself."
499
+ * Decoration belongs to whoever is drawing a list; see `trackLabel`.
500
+ *
501
+ * `lookup` reads the data model (`app.getPath`). Note the shape: a vessel's
502
+ * `name` is a bare string in the full model, not the `{value}` wrapper that
503
+ * `navigation.position` and friends carry, so both are accepted rather than
504
+ * guessed at.
505
+ *
506
+ * Resolves `name` and nothing else, matching the server's own `findContextName`
507
+ * (`packages/server-admin-ui/src/utils/pathKeys.ts`), which returns undefined
508
+ * rather than substituting an identifier. An MMSI is how a vessel is addressed,
509
+ * not what it is called, so it belongs in `trackLabel` where something has to
510
+ * render — putting it here would make "where known" untrue.
511
+ */
512
+ function contextName(context, lookup) {
513
+ return unwrapString(lookup(`${context}.name`));
514
+ }
515
+ /**
516
+ * A display label for a track, as a chart plotter would show it.
517
+ *
518
+ * TimeZero's GPX exports label tracks `Own Ship` and `AIS <shipname>` — never a
519
+ * serial number — and that is what makes a list of tracks readable. A UI
520
+ * listing `vessels.urn:mrn:imo:mmsi:211234567` is unusable.
521
+ *
522
+ * Distinct from `contextName`: this one *is* a name for the track, so it says
523
+ * whose track it is and where the identity came from. Falls back to the raw
524
+ * context, because a label has to render something.
525
+ */
526
+ function trackLabel(context, selfContext, lookup) {
527
+ if (context === selfContext) return "Own Ship";
528
+ const identity = contextName(context, lookup) ?? unwrapString(lookup(`${context}.mmsi`)) ?? mmsiFromContext(context);
529
+ return identity === void 0 ? context : `AIS ${identity}`;
530
+ }
531
+ /** Accepts both a bare string and the `{value}` wrapper deltas arrive in. */
532
+ function unwrapString(raw) {
533
+ const value = raw && typeof raw === "object" && "value" in raw ? raw.value : raw;
534
+ if (typeof value !== "string") return;
535
+ const trimmed = value.trim();
536
+ return trimmed === "" ? void 0 : trimmed;
537
+ }
538
+ /** `vessels.urn:mrn:imo:mmsi:211234567` -> `211234567`. */
539
+ function mmsiFromContext(context) {
540
+ return /urn:mrn:imo:mmsi:(\d+)$/.exec(context)?.[1];
541
+ }
542
+ /**
543
+ * A track label as a filename a browser can save.
544
+ *
545
+ * A vessel name arrives from an AIS transmission and is not ours to trust in a
546
+ * path, so the characters that could break out of one — separators, quotes,
547
+ * controls — are replaced. Everything else is kept, including non-ASCII: a
548
+ * boat called `Ärger` keeps its name.
549
+ */
550
+ function gpxFilename(label) {
551
+ const safe = label.replace(/[\p{Cc}\p{Cf}"'\\/:*?<>|]+/gu, "-").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
552
+ return `${safe === "" ? "track" : safe}.gpx`;
553
+ }
554
+ /**
555
+ * The ASCII-only form of a filename, for the quoted `Content-Disposition`
556
+ * parameter.
557
+ *
558
+ * An HTTP header carries bytes: anything outside Latin-1 makes `setHeader`
559
+ * throw, and anything outside ASCII is reinterpreted byte-wise by the client,
560
+ * so `Ärger.gpx` arrives as mojibake. The real name travels in the `filename*`
561
+ * parameter; this is the fallback for readers that do not implement it.
562
+ */
563
+ function asciiFilename(filename) {
564
+ const trimmed = [...filename].map((character) => character < "€" ? character : "-").join("").replace(/-{2,}/g, "-").replace(/^-+/, "");
565
+ return trimmed === ".gpx" || trimmed === "" ? "track.gpx" : trimmed;
566
+ }
567
+ /**
568
+ * A filename as an RFC 8187 extended value, for `filename*`.
569
+ *
570
+ * `encodeURIComponent` is close but not exact: it leaves `!'()*` unescaped,
571
+ * and RFC 8187's `attr-char` set excludes them. A vessel called
572
+ * `Boat (Test)` would otherwise put raw parentheses into the header, which is
573
+ * not a valid extended value.
574
+ */
575
+ function rfc8187(filename) {
576
+ return encodeURIComponent(filename).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
577
+ }
381
578
  //#endregion
382
579
  //#region src/trackProvider.ts
383
580
  /** v2 sends `[west, south, east, north]`; a bounds here is `[lat, lng]` corners. */
@@ -537,8 +734,11 @@ function createTrackProvider(deps) {
537
734
  resolution: requested
538
735
  } : thinToBudget(all, query.maxPoints, requested);
539
736
  if (points.length === 0) continue;
540
- const segments = segment(points, gap);
541
- const bbox = boundsOf(points);
737
+ const chosenEpsilon = chooseEpsilon(points, query);
738
+ const segments = segment(points, gap).map((s) => chosenEpsilon === void 0 ? s : simplify(s, chosenEpsilon));
739
+ const shaped = segments.flat();
740
+ const bbox = boundsOf(shaped);
741
+ const name = deps.contextName(context);
542
742
  features.push({
543
743
  type: "Feature",
544
744
  geometry: query.geometry === false ? null : {
@@ -548,11 +748,13 @@ function createTrackProvider(deps) {
548
748
  properties: {
549
749
  context,
550
750
  isSelf: context === selfContext,
551
- from: new Date(points[0].timestamp).toISOString(),
552
- to: new Date(points[points.length - 1].timestamp).toISOString(),
751
+ ...name === void 0 ? {} : { contextName: name },
752
+ from: new Date(shaped[0].timestamp).toISOString(),
753
+ to: new Date(shaped[shaped.length - 1].timestamp).toISOString(),
553
754
  ...bbox ? { bbox } : {},
554
- pointCount: points.length,
755
+ pointCount: shaped.length,
555
756
  ...appliedMs === void 0 ? {} : { resolution: msToDuration(appliedMs).toString() },
757
+ ...chosenEpsilon === void 0 ? {} : { epsilon: chosenEpsilon },
556
758
  ...query.times ? { coordTimes: segments.map(toIsoTimes) } : {}
557
759
  }
558
760
  });
@@ -567,6 +769,67 @@ function createTrackProvider(deps) {
567
769
  }
568
770
  };
569
771
  }
772
+ /**
773
+ * The tolerance to simplify with, or undefined to leave the geometry alone.
774
+ *
775
+ * `epsilon` implies `simplify=true`, so an explicit tolerance is honoured
776
+ * whether or not the flag came with it. `simplify` alone leaves the tolerance
777
+ * to the provider: the spec says "a tolerance suited to the size of the box",
778
+ * and the extent of the track *as thinning left it* is the honest basis for
779
+ * that — a query bbox may be absent, and when present it is what the client
780
+ * searched in rather than what came back. It cannot be the simplified extent,
781
+ * since that is the thing being computed.
782
+ *
783
+ * Chosen from the whole track rather than per segment, so every leg of one
784
+ * track is simplified to the same tolerance and the single reported `epsilon`
785
+ * describes all of them.
786
+ *
787
+ * A non-positive `epsilon` is not reachable through the API — the schema
788
+ * declares `exclusiveMinimum: 0`, so the server rejects it — and is treated
789
+ * here as no tolerance given, falling through to `simplify` if that was asked
790
+ * for. Simplifying by zero would be a no-op reported as if it had done
791
+ * something.
792
+ */
793
+ function chooseEpsilon(points, query) {
794
+ const explicit = query.epsilon;
795
+ if (explicit !== void 0 && explicit > 0) return explicit;
796
+ return query.simplify === true ? autoEpsilon(points) : void 0;
797
+ }
798
+ /**
799
+ * How much of a track's own extent the automatic tolerance may deviate by.
800
+ *
801
+ * One part in a thousand. Provisional while the v2 API is designed in
802
+ * SignalK/signalk-server#2504: it is only a provider-chosen default for
803
+ * `simplify` without an explicit epsilon, and a client that calibrates against
804
+ * the exact ratio rather than reading the reported `epsilon` back would make
805
+ * it expensive to change.
806
+ */
807
+ var AUTO_EPSILON_DIVISOR = 1e3;
808
+ /**
809
+ * Floor, in metres, under the automatic tolerance.
810
+ *
811
+ * Below the noise of any GPS fix, so it collapses the thousands of near
812
+ * identical points a vessel records at anchor without altering a track that
813
+ * actually moved.
814
+ */
815
+ var MIN_AUTO_EPSILON = 1;
816
+ /**
817
+ * A tolerance scaled to how much ground a track covers.
818
+ *
819
+ * One part in a thousand of the track's diagonal: enough to drop the jitter of
820
+ * a boat holding station while keeping every turn a passage is made of. A
821
+ * fixed metre value cannot do both — 10 m erases nothing on an ocean crossing
822
+ * and erases a marina approach entirely.
823
+ */
824
+ function autoEpsilon(points) {
825
+ const bounds = boundsOf(points);
826
+ if (!bounds) return;
827
+ const [west, south, east, north] = bounds;
828
+ const midLat = (south + north) / 2;
829
+ const height = (north - south) * M_PER_DEG;
830
+ const width = (east >= west ? east - west : east + 360 - west) * M_PER_DEG * Math.cos(midLat * Math.PI / 180);
831
+ return Math.max(Math.hypot(width, height) / AUTO_EPSILON_DIVISOR, MIN_AUTO_EPSILON);
832
+ }
570
833
  /** v2 accepts the `self` alias; the store keys on the qualified context. */
571
834
  var resolveSelf = (selfContext) => (context) => context === "self" || context === "vessels.self" ? selfContext : context;
572
835
  //#endregion
@@ -12220,26 +12483,108 @@ function reconcileByExtent(history, stored) {
12220
12483
  * Detection is therefore empirical rather than reading the priority config:
12221
12484
  * seeing several sources here *is* the evidence that no rule is matching,
12222
12485
  * which a config that merely exists would not tell us.
12486
+ *
12487
+ * With one qualification. A priority rule has a failover window — when the
12488
+ * top-ranked source goes quiet the engine promotes the next one — so over a
12489
+ * long run several sources legitimately reach this bus even with a rule in
12490
+ * place, one at a time. Counting every source ever seen would therefore
12491
+ * report a conflict on a correctly configured boat whose GPS drops out
12492
+ * occasionally. What distinguishes competition is *alternation*: unfiltered
12493
+ * sources hand back and forth repeatedly within seconds, while a failover
12494
+ * switches once and stays.
12495
+ */
12496
+ /**
12497
+ * How quickly the source must change again for it to count as alternation.
12498
+ *
12499
+ * Shorter than the server's 15s default failover, so a promotion — one
12500
+ * switch after a silence — never chains into a sequence. Positions are a
12501
+ * rapid update, often 10Hz, so genuinely competing sources alternate far
12502
+ * faster than this.
12503
+ */
12504
+ var ALTERNATION_WINDOW_MS = 1e4;
12505
+ /**
12506
+ * How long a conflict is remembered after it was last observed.
12507
+ *
12508
+ * Longer than the 30s status tick, so a conflict seen just after one tick is
12509
+ * still reported at the next and does not flicker. Finite, so fixing the
12510
+ * priorities clears the warning on its own rather than needing a restart —
12511
+ * which is the whole complaint about a watcher that only ever accumulates.
12512
+ */
12513
+ var CONFLICT_RETENTION_MS = 9e4;
12514
+ /**
12515
+ * How many source changes in a row before it counts as competition.
12516
+ *
12517
+ * One change is a failover, two is a failback — the preferred source
12518
+ * returning and winning immediately, which the server does on purpose. Three
12519
+ * means the sources are trading the path back and forth, which no priority
12520
+ * rule does.
12223
12521
  */
12522
+ var CHANGES_BEFORE_WARNING = 3;
12224
12523
  var SourceWatch = class {
12524
+ /** Last time each source produced a position, per context. */
12225
12525
  seen = /* @__PURE__ */ new Map();
12526
+ /** Sources found competing, per context, with when each was last seen so. */
12527
+ competing = /* @__PURE__ */ new Map();
12528
+ /** The current alternation per context: who is in it, and how long it is. */
12529
+ lastSeen = /* @__PURE__ */ new Map();
12226
12530
  /** Record that `context` produced a position from `$source`. */
12227
- add(context, source) {
12531
+ add(context, source, now = Date.now()) {
12228
12532
  if (!source) return;
12229
12533
  let sources = this.seen.get(context);
12230
12534
  if (!sources) {
12231
- sources = /* @__PURE__ */ new Set();
12535
+ sources = /* @__PURE__ */ new Map();
12232
12536
  this.seen.set(context, sources);
12233
12537
  }
12234
- sources.add(source);
12538
+ const previous = this.lastSeen.get(context);
12539
+ sources.set(source, now);
12540
+ const stale = !previous || now - previous.changedAt > ALTERNATION_WINDOW_MS;
12541
+ if (stale || previous.source === source) this.lastSeen.set(context, {
12542
+ source,
12543
+ at: now,
12544
+ changedAt: stale ? now : previous.changedAt,
12545
+ changes: stale ? 0 : previous.changes,
12546
+ participants: stale ? /* @__PURE__ */ new Set([source]) : previous.participants
12547
+ });
12548
+ else {
12549
+ const changes = previous.changes + 1;
12550
+ const participants = previous.participants.add(source);
12551
+ this.lastSeen.set(context, {
12552
+ source,
12553
+ at: now,
12554
+ changedAt: now,
12555
+ changes,
12556
+ participants
12557
+ });
12558
+ if (changes >= CHANGES_BEFORE_WARNING) {
12559
+ const found = this.competing.get(context) ?? /* @__PURE__ */ new Map();
12560
+ for (const participant of participants) found.set(participant, sources.get(participant) ?? now);
12561
+ this.competing.set(context, found);
12562
+ }
12563
+ }
12235
12564
  }
12236
- /** Sources seen for a context, in the order they first appeared. */
12565
+ /** Every source seen for a context, in the order they first appeared. */
12237
12566
  sourcesFor(context) {
12238
- return [...this.seen.get(context) ?? []];
12567
+ return [...(this.seen.get(context) ?? /* @__PURE__ */ new Map()).keys()];
12568
+ }
12569
+ /**
12570
+ * The sources found live at the same time, which is what a warning names.
12571
+ *
12572
+ * Expired entries are dropped rather than merely hidden, so a conflict that
12573
+ * has stopped recurring leaves nothing behind.
12574
+ */
12575
+ competingFor(context, now = Date.now()) {
12576
+ const found = this.competing.get(context);
12577
+ if (!found) return [];
12578
+ for (const [source, at] of found) if (now - at > CONFLICT_RETENTION_MS) found.delete(source);
12579
+ if (found.size < 2) {
12580
+ this.competing.delete(context);
12581
+ return [];
12582
+ }
12583
+ return [...found.keys()];
12239
12584
  }
12240
- /** Contexts receiving positions from more than one source. */
12241
- conflicted() {
12242
- return [...this.seen.entries()].filter(([, sources]) => sources.size > 1).map(([context]) => context);
12585
+ /** Contexts that have received positions from two sources at once. */
12586
+ conflicted(now = Date.now()) {
12587
+ return [...this.competing.keys()].filter((context) => this.competingFor(context, now).length > 1);
12243
12588
  }
12244
12589
  /**
12245
12590
  * A status line naming the problem, or undefined when nothing is wrong.
@@ -12249,21 +12594,24 @@ var SourceWatch = class {
12249
12594
  * vessels are usually AIS, where multiple receivers are expected and less
12250
12595
  * worth nagging about — they are counted, not named.
12251
12596
  */
12252
- warning(selfContext) {
12253
- const conflicted = this.conflicted();
12597
+ warning(selfContext, now = Date.now()) {
12598
+ const conflicted = this.conflicted(now);
12254
12599
  if (conflicted.length === 0) return;
12255
12600
  const selfConflicted = selfContext !== void 0 && conflicted.includes(selfContext);
12256
12601
  const others = conflicted.length - (selfConflicted ? 1 : 0);
12257
12602
  if (selfConflicted) {
12258
- const sources = this.sourcesFor(selfContext).join(", ");
12603
+ const recent = this.competingFor(selfContext, now);
12604
+ const sources = recent.join(", ");
12259
12605
  const tail = others > 0 ? ` (and ${others} other ${others === 1 ? "vessel" : "vessels"})` : "";
12260
- return `navigation.position for the own vessel is arriving from ${this.sourcesFor(selfContext).length} sources (${sources})${tail}. Set source priority for navigation.position, or the track will zigzag between them.`;
12606
+ return `navigation.position for the own vessel is arriving from ${recent.length} sources (${sources})${tail}. Set source priority for navigation.position, or the track will zigzag between them.`;
12261
12607
  }
12262
12608
  return `navigation.position is arriving from multiple sources for ${others} ${others === 1 ? "vessel" : "vessels"}.`;
12263
12609
  }
12264
12610
  /** Forget everything seen; used when the plugin restarts. */
12265
12611
  clear() {
12266
12612
  this.seen.clear();
12613
+ this.competing.clear();
12614
+ this.lastSeen.clear();
12267
12615
  }
12268
12616
  };
12269
12617
  //#endregion
@@ -12337,6 +12685,285 @@ var StateGate = class {
12337
12685
  }
12338
12686
  };
12339
12687
  //#endregion
12688
+ //#region src/gpx.ts
12689
+ /**
12690
+ * GPX 1.1 serialisation and parsing for recorded tracks.
12691
+ *
12692
+ * Hand-written rather than through a library: the vocabulary is `gpx`,
12693
+ * `metadata`, `trk`, `name`, `extensions`, `signalk:context`, `trkseg`,
12694
+ * `trkpt` and `time` — nine element types — and a dependency for that would be
12695
+ * more surface than the code it replaces.
12696
+ * What matters is getting the details right, and those are documented below.
12697
+ */
12698
+ var LAT = 0;
12699
+ var LNG = 1;
12700
+ /**
12701
+ * Serialise tracks as a GPX 1.1 document.
12702
+ *
12703
+ * Several tracks go in one file, each as its own `<trk>`. GPX allows it, and
12704
+ * it is what makes "select three trips, export" produce one file rather than
12705
+ * three — TimeZero writes one file per vessel, which is a choice rather than a
12706
+ * constraint of the format.
12707
+ *
12708
+ * The Signal K context travels in `<extensions>`. TimeZero puts an opaque
12709
+ * GUID there and drops the MMSI entirely, so its exports cannot round-trip an
12710
+ * identity; carrying the context means ours can, while staying readable by
12711
+ * anything else, since `<extensions>` is ignorable by specification.
12712
+ */
12713
+ function toGpx(tracks, now = /* @__PURE__ */ new Date()) {
12714
+ const lines = [
12715
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
12716
+ "<gpx version=\"1.1\" creator=\"@signalk/tracks-plugin\" xmlns=\"http://www.topografix.com/GPX/1/1\">",
12717
+ " <metadata>",
12718
+ ` <time>${iso(now.getTime())}</time>`,
12719
+ " </metadata>"
12720
+ ];
12721
+ for (const track of tracks) {
12722
+ const segments = track.segments.map((points) => points.filter((p) => inRange(p.position[LAT], p.position[LNG]))).filter((points) => points.length > 0);
12723
+ if (segments.length === 0) continue;
12724
+ lines.push(" <trk>");
12725
+ lines.push(` <name>${escapeXml(track.name)}</name>`);
12726
+ if (track.context !== void 0) {
12727
+ lines.push(" <extensions>");
12728
+ lines.push(` <signalk:context xmlns:signalk="${NAMESPACE}">${escapeXml(track.context)}</signalk:context>`);
12729
+ lines.push(" </extensions>");
12730
+ }
12731
+ for (const points of segments) {
12732
+ lines.push(" <trkseg>");
12733
+ for (const { position, timestamp } of points) {
12734
+ lines.push(` <trkpt lat="${coordinate(position[LAT])}" lon="${coordinate(position[LNG])}">`);
12735
+ lines.push(` <time>${iso(timestamp)}</time>`);
12736
+ lines.push(" </trkpt>");
12737
+ }
12738
+ lines.push(" </trkseg>");
12739
+ }
12740
+ lines.push(" </trk>");
12741
+ }
12742
+ lines.push("</gpx>");
12743
+ return lines.join("\n") + "\n";
12744
+ }
12745
+ /** GPX's own namespace, which structural elements must be in. */
12746
+ var GPX_NAMESPACE = "http://www.topografix.com/GPX/1/1";
12747
+ /** Namespace for the one extension element this plugin writes. */
12748
+ var NAMESPACE = "https://signalk.org/specification/1.7.0/";
12749
+ /**
12750
+ * Parse a GPX document into tracks.
12751
+ *
12752
+ * Structure follows GPX's own hierarchy: the root is `<gpx>`, tracks are its
12753
+ * children, segments are a track's, points are a segment's. Anything else —
12754
+ * a `<trkseg>` inside an `<extensions>` payload, a `<trk>` below the root — is
12755
+ * not track data, and a namespace check alone cannot tell the difference,
12756
+ * since a payload declaring no namespace inherits GPX's own.
12757
+ *
12758
+ * Deliberately forgiving about structure and strict about values: elements it
12759
+ * does not recognise are ignored rather than rejected, but a coordinate that is
12760
+ * not an `xsd:decimal` in range, or a time that is not an `xsd:dateTime`, is
12761
+ * refused. One NaN reaching the store stretches every bounding box that track
12762
+ * appears in.
12763
+ *
12764
+ * **Points are sorted by time within each segment.** TimeZero paginates its
12765
+ * export in blocks of 150 and the block seams overlap: the last point of a
12766
+ * block can be a few milliseconds newer than the first point of the next. Out
12767
+ * of 5,550 points in a real export, 16 pairs are out of order, every one on a
12768
+ * 150-point boundary. Anything downstream that assumes ordering — time-window
12769
+ * queries, the history reconciliation — would quietly misbehave on such a file.
12770
+ *
12771
+ * Sorting stops at the segment boundary, deliberately: a segment is a stretch
12772
+ * of continuous recording, and reordering points across a gap would merge two
12773
+ * passages into one line. So `segments.flat()` is not globally sorted for a
12774
+ * file whose segments interleave in time, and a caller that needs that must
12775
+ * sort for itself.
12776
+ *
12777
+ * A document the parser rejects yields no tracks rather than throwing: this
12778
+ * reads files the plugin did not write, and an import that fails should say so
12779
+ * by returning nothing rather than by taking the caller down.
12780
+ */
12781
+ function fromGpx(xml) {
12782
+ let document;
12783
+ try {
12784
+ document = new DOMParser({ onError: (level) => {
12785
+ if (level !== "warning") throw new Error("malformed XML");
12786
+ } }).parseFromString(xml, "text/xml");
12787
+ } catch {
12788
+ return [];
12789
+ }
12790
+ const root = document.documentElement;
12791
+ if (root === null || root.localName !== "gpx" || !isGpxNamespace(root)) return [];
12792
+ const tracks = [];
12793
+ for (const trk of gpxChildren(root, "trk")) {
12794
+ const segments = [];
12795
+ for (const trkseg of gpxChildren(trk, "trkseg")) {
12796
+ const points = readPoints(trkseg);
12797
+ if (points.length > 0) segments.push(points);
12798
+ }
12799
+ if (segments.length === 0) continue;
12800
+ const name = childText(trk, "name").trim() || "Track";
12801
+ const context = signalKContext(trk);
12802
+ tracks.push({
12803
+ name,
12804
+ segments,
12805
+ ...context === void 0 ? {} : { context }
12806
+ });
12807
+ }
12808
+ return tracks;
12809
+ }
12810
+ /**
12811
+ * The text of a direct GPX child with this local name.
12812
+ *
12813
+ * Direct rather than `getElementsByTagName`, which would reach a `<name>`
12814
+ * inside an `<extensions>` payload and label the track with a vendor's string.
12815
+ */
12816
+ function childText(trk, local) {
12817
+ for (const child of Array.from(trk.childNodes)) if (isElement(child) && child.localName === local && isGpxNamespace(child)) return child.textContent ?? "";
12818
+ return "";
12819
+ }
12820
+ /**
12821
+ * The Signal K context declared in a track's own `<extensions>`.
12822
+ *
12823
+ * Matched on namespace URI, which is what the prefix actually means: GPX
12824
+ * extensions are an open field any vendor may write into, so a foreign
12825
+ * `<context>` — or one under a different prefix bound elsewhere — must not
12826
+ * claim the track's identity. Read only from the track's direct
12827
+ * `<extensions>` children, because a trackpoint carries its own and those
12828
+ * belong to that point.
12829
+ */
12830
+ function signalKContext(trk) {
12831
+ for (const child of Array.from(trk.childNodes)) {
12832
+ if (!isElement(child) || child.localName !== "extensions" || !isGpxNamespace(child)) continue;
12833
+ for (const candidate of Array.from(child.getElementsByTagNameNS(NAMESPACE, "context"))) {
12834
+ const value = (candidate.textContent ?? "").trim();
12835
+ if (value !== "") return value;
12836
+ }
12837
+ }
12838
+ }
12839
+ /** The points of one `<trkseg>`, oldest first. */
12840
+ function readPoints(trkseg) {
12841
+ const points = [];
12842
+ for (const trkpt of gpxChildren(trkseg, "trkpt")) {
12843
+ const latitude = decimal(trkpt.getAttribute("lat") ?? void 0);
12844
+ const longitude = decimal(trkpt.getAttribute("lon") ?? void 0);
12845
+ if (!inRange(latitude, longitude)) continue;
12846
+ const raw = childText(trkpt, "time").trim();
12847
+ const timestamp = raw === "" ? NaN : dateTime(raw);
12848
+ points.push({
12849
+ position: [latitude, longitude],
12850
+ timestamp: Number.isFinite(timestamp) ? timestamp : 0
12851
+ });
12852
+ }
12853
+ return points.sort((a, b) => a.timestamp - b.timestamp);
12854
+ }
12855
+ /**
12856
+ * Direct GPX children of `root` with this local name.
12857
+ *
12858
+ * Direct, because GPX fixes the hierarchy: a `<trkseg>` is a child of `<trk>`
12859
+ * and a `<trkpt>` a child of `<trkseg>`. Scanning descendants instead lets an
12860
+ * `<extensions>` payload contribute track data — and a payload that declares
12861
+ * no namespace of its own inherits GPX's, so a namespace check cannot catch
12862
+ * it. A vendor's `<trkseg>` inside `<extensions>` became a phantom segment.
12863
+ */
12864
+ function gpxChildren(root, local) {
12865
+ return Array.from(root.childNodes).filter((node) => isElement(node) && node.localName === local && isGpxNamespace(node));
12866
+ }
12867
+ /**
12868
+ * Whether an element is GPX's own.
12869
+ *
12870
+ * A null namespace counts: a great many files in the wild omit the xmlns
12871
+ * declaration entirely, and refusing those would reject most of what users
12872
+ * actually have.
12873
+ */
12874
+ function isGpxNamespace(element) {
12875
+ return element.namespaceURI === null || element.namespaceURI === GPX_NAMESPACE;
12876
+ }
12877
+ /** Narrow a DOM node to an element without relying on a global Node. */
12878
+ function isElement(node) {
12879
+ return node.nodeType === 1;
12880
+ }
12881
+ /**
12882
+ * Coordinates at seven decimal places: ~11 mm, finer than any GPS fix and
12883
+ * short enough that a long track does not carry pointless digits.
12884
+ */
12885
+ function coordinate(value) {
12886
+ const fixed = value.toFixed(7).replace(/\.?0+$/, "");
12887
+ return fixed === "-0" ? "0" : fixed;
12888
+ }
12889
+ /**
12890
+ * A GPX `<time>` as epoch milliseconds, or NaN when it is not one.
12891
+ *
12892
+ * GPX types the element as `xsd:dateTime`, and `Date.parse` is far looser: it
12893
+ * reads a bare `2024` and `Jan 1 2024` as instants, and silently rolls
12894
+ * `2024-02-30` forward to March. A date that never existed should reach the
12895
+ * caller's fallback rather than arrive as a real -- and wrong -- position in
12896
+ * time.
12897
+ */
12898
+ function dateTime(value) {
12899
+ const match = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.exec(value);
12900
+ if (!match) return NaN;
12901
+ const parsed = Date.parse(value);
12902
+ if (Number.isNaN(parsed)) return NaN;
12903
+ const year = Number(match[1]);
12904
+ const month = Number(match[2]);
12905
+ const day = Number(match[3]);
12906
+ const utc = new Date(Date.UTC(year, month - 1, day));
12907
+ return utc.getUTCFullYear() === year && utc.getUTCMonth() === month - 1 && utc.getUTCDate() === day ? parsed : NaN;
12908
+ }
12909
+ /**
12910
+ * ISO-8601 UTC, because a track outlives the timezone it was recorded in.
12911
+ *
12912
+ * A timestamp outside the Date range throws out of `toISOString`, and one bad
12913
+ * point would abort the export of every track in the file. It is dated to the
12914
+ * start of time instead, which is what `fromGpx` does with a time it cannot
12915
+ * read -- losing one point's time beats losing the document.
12916
+ */
12917
+ function iso(timestamp) {
12918
+ const at = new Date(timestamp);
12919
+ return Number.isNaN(at.getTime()) ? (/* @__PURE__ */ new Date(0)).toISOString() : at.toISOString();
12920
+ }
12921
+ /**
12922
+ * XML-escape a value for element content.
12923
+ *
12924
+ * Characters XML cannot express are dropped rather than escaped, because they
12925
+ * have no entity form: emitting one produces a document no conformant reader
12926
+ * will load, and losing the character beats losing the file.
12927
+ */
12928
+ function escapeXml(value) {
12929
+ return [...value].filter((character) => isXmlChar(character.codePointAt(0) ?? -1)).join("").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
12930
+ }
12931
+ /**
12932
+ * Whether a code point is one XML 1.0 permits in content.
12933
+ *
12934
+ * The range has holes: NUL, most C0 controls and the surrogate block are all
12935
+ * excluded. Decoding `&#0;` or `&#xD800;` puts a character into a track name
12936
+ * that cannot be written back out as well-formed XML, so the reference is
12937
+ * kept verbatim instead -- the same treatment as one that names no character
12938
+ * at all.
12939
+ */
12940
+ function isXmlChar(value) {
12941
+ if (!Number.isInteger(value)) return false;
12942
+ return value === 9 || value === 10 || value === 13 || value >= 32 && value <= 55295 || value >= 57344 && value <= 65533 || value >= 65536 && value <= 1114111;
12943
+ }
12944
+ /**
12945
+ * An attribute value as an XSD decimal, or NaN when it is not one.
12946
+ *
12947
+ * GPX types lat and lon as `xsd:decimal`, which is digits with an optional
12948
+ * sign and point -- no exponent, no hexadecimal. `Number()` is far more
12949
+ * generous: it reads `0x10` as 16 and `1e1` as 10, so a file using either
12950
+ * imported a position it never expressed.
12951
+ */
12952
+ function decimal(value) {
12953
+ return value !== void 0 && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value) ? Number(value) : NaN;
12954
+ }
12955
+ /**
12956
+ * Whether a position is one GPX can express.
12957
+ *
12958
+ * Latitude beyond ±90 or longitude beyond ±180 is not a place. Such a value
12959
+ * reaching the store stretches every bounding box the track appears in, and
12960
+ * writing one produces a document a validating reader rejects — so it is
12961
+ * refused in both directions rather than trusted from a file or passed on.
12962
+ */
12963
+ function inRange(latitude, longitude) {
12964
+ return Number.isFinite(latitude) && Number.isFinite(longitude) && latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180;
12965
+ }
12966
+ //#endregion
12340
12967
  //#region src/tracks.ts
12341
12968
  /**
12342
12969
  * The in-memory store: a bounded sliding window of positions per context, held
@@ -12503,6 +13130,45 @@ var HISTORY_QUERY_TIMEOUT_MS = 5e3;
12503
13130
  */
12504
13131
  var WINDOWLESS_HISTORY_SPAN_MS = 864e5;
12505
13132
  /**
13133
+ * The earliest instant the existence probe asks about.
13134
+ *
13135
+ * The probe has to name a bound: the History API's time range has no
13136
+ * unbounded form, every branch of `TimeRangeParams` carries one. A provider
13137
+ * filters its context list by the range it is given — questdb builds a SQL
13138
+ * `WHERE` from it — so any bound later than the provider's oldest row can
13139
+ * still miss a vessel and 404 it, which is the case the probe exists to catch.
13140
+ *
13141
+ * The Unix epoch is the bound rather than some span of years: it predates
13142
+ * satellite navigation, so no position fix can lie before it, and unlike a
13143
+ * fixed "wide enough" window it cannot be outlived. The cost lands only on the
13144
+ * branch that was already about to 404, and the answer is cached.
13145
+ */
13146
+ var EXISTENCE_PROBE_FROM_MS = 0;
13147
+ /**
13148
+ * How long a provider's context list is reused before asking again.
13149
+ *
13150
+ * The probe runs only for a vessel about to 404, which is precisely the
13151
+ * request a client can repeat without limit — the routes are open, so
13152
+ * enumerating vessel ids would otherwise drive one epoch-wide `getContexts`
13153
+ * per request, each able to block for the timeout.
13154
+ *
13155
+ * The whole list is cached rather than a per-context answer, or enumeration
13156
+ * would simply fill the cache with distinct keys and query just as often. The
13157
+ * cost is that a vessel a provider learns about becomes visible up to this
13158
+ * long after the fact, which is not a delay anyone can perceive in a track
13159
+ * that is already minutes old.
13160
+ */
13161
+ var KNOWN_CONTEXTS_TTL_MS = 3e4;
13162
+ /**
13163
+ * How long a *failed* context query is remembered.
13164
+ *
13165
+ * Short, so a provider that recovers is noticed quickly, but not zero: with
13166
+ * the entry simply dropped, a provider that hangs is asked again by every
13167
+ * subsequent miss while the earlier calls are still pending — reinstating per
13168
+ * request exactly the cost the cache exists to prevent.
13169
+ */
13170
+ var KNOWN_CONTEXTS_FAILURE_TTL_MS = 1e3;
13171
+ /**
12506
13172
  * Config values arrive from the plugin UI as numbers, but a hand-edited
12507
13173
  * settings file can supply strings. Accept both, reject anything non-finite so
12508
13174
  * a bad value falls back to the default instead of poisoning arithmetic with NaN.
@@ -12518,6 +13184,15 @@ var notAvailable = (res) => {
12518
13184
  res.status(404);
12519
13185
  res.json({ message: "Tracks API not available because tracks plugin is not enabled" });
12520
13186
  };
13187
+ /**
13188
+ * The history provider could not answer, and nothing else could either.
13189
+ *
13190
+ * Distinct from a missing track: a 404 asserts that no source knows the
13191
+ * vessel, which a failed provider read cannot support. Reporting the failure
13192
+ * as an empty track would present an outage as "this vessel has no positions
13193
+ * here" — the one answer that is certainly wrong.
13194
+ */
13195
+ var HistoryUnavailableError = class extends Error {};
12521
13196
  var errorDetail = (err) => err instanceof Error && err.stack ? err.stack : String(err);
12522
13197
  /**
12523
13198
  * A window covering everything the store holds, up to now.
@@ -12548,9 +13223,15 @@ function windowSpanning(stored, fallbackMs) {
12548
13223
  * promise wins. A race leaves it armed, holding the event loop open for the
12549
13224
  * remainder of the timeout on every call that succeeds.
12550
13225
  */
13226
+ /**
13227
+ * A bound expired. Distinct from the error a provider itself raises, because
13228
+ * "did not answer in time" and "is not installed" are different answers and
13229
+ * only one of them is an outage.
13230
+ */
13231
+ var HistoryTimeoutError = class extends Error {};
12551
13232
  function withTimeout(promise, ms) {
12552
13233
  return new Promise((resolve, reject) => {
12553
- const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`timed out after ${ms}ms`)), ms);
13234
+ const timer = setTimeout(() => reject(new HistoryTimeoutError(`timed out after ${ms}ms`)), ms);
12554
13235
  promise.then((value) => {
12555
13236
  clearTimeout(timer);
12556
13237
  resolve(value);
@@ -12561,6 +13242,48 @@ function withTimeout(promise, ms) {
12561
13242
  });
12562
13243
  }
12563
13244
  /**
13245
+ * Whether a history provider holds anything at all for a context.
13246
+ *
13247
+ * Asked only when a track is about to 404: `getValues` narrowed to a window
13248
+ * cannot tell "this vessel is unknown" from "this vessel has nothing *here*",
13249
+ * and 404ing on the second is wrong — the contract is that a 404 means neither
13250
+ * source knows the vessel. A vessel whose history lies wholly outside the
13251
+ * asked window is known, and deserves an empty track.
13252
+ *
13253
+ * Best-effort, like every other provider call here: a provider that lacks the
13254
+ * method, errors, or hangs leaves the 404 standing rather than holding the
13255
+ * request open.
13256
+ */
13257
+ async function historyKnowsContext(app, context, debug, cache) {
13258
+ const getHistoryApi = app.getHistoryApi;
13259
+ if (!getHistoryApi) return false;
13260
+ try {
13261
+ const now = Date.now();
13262
+ const cached = cache.current;
13263
+ if (cached === void 0 || now - cached.at >= KNOWN_CONTEXTS_TTL_MS) {
13264
+ const contexts = withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider).then((historyApi) => historyApi.getContexts ? historyApi.getContexts({
13265
+ from: Temporal.Instant.from((/* @__PURE__ */ new Date(EXISTENCE_PROBE_FROM_MS)).toISOString()),
13266
+ to: Temporal.Instant.from(new Date(now).toISOString())
13267
+ }) : []).then((listed) => new Set((listed ?? []).filter((c) => typeof c === "string").map((c) => c === "vessels.self" ? app.selfContext : c))), HISTORY_QUERY_TIMEOUT_MS);
13268
+ const entry = {
13269
+ at: now,
13270
+ contexts
13271
+ };
13272
+ contexts.catch(() => {
13273
+ if (cache.current === entry) cache.current = {
13274
+ at: now - KNOWN_CONTEXTS_TTL_MS + KNOWN_CONTEXTS_FAILURE_TTL_MS,
13275
+ contexts
13276
+ };
13277
+ });
13278
+ cache.current = entry;
13279
+ }
13280
+ return (await cache.current.contexts).has(context);
13281
+ } catch (err) {
13282
+ debug(`History contexts unavailable for ${context}: ${errorDetail(err)}`);
13283
+ return false;
13284
+ }
13285
+ }
13286
+ /**
12564
13287
  * Positions a history provider holds for a window, or none.
12565
13288
  *
12566
13289
  * Best-effort by design: a provider that is absent, slow, or failing must not
@@ -12573,10 +13296,23 @@ async function historyPositions(app, context, window, resolutionMs, debug) {
12573
13296
  const getHistoryApi = app.getHistoryApi;
12574
13297
  if (!getHistoryApi) return {
12575
13298
  points: [],
12576
- resolutionMs: applied
13299
+ resolutionMs: applied,
13300
+ failed: false
12577
13301
  };
13302
+ let historyApi;
13303
+ try {
13304
+ historyApi = await withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider), HISTORY_QUERY_TIMEOUT_MS);
13305
+ } catch (err) {
13306
+ const timedOut = err instanceof HistoryTimeoutError;
13307
+ if (debug.enabled) debug(`${timedOut ? "History provider timed out" : "No history provider"} for ${context}: ${errorDetail(err)}`);
13308
+ return {
13309
+ points: [],
13310
+ resolutionMs: applied,
13311
+ failed: timedOut
13312
+ };
13313
+ }
12578
13314
  try {
12579
- const response = await withTimeout((await withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider), HISTORY_QUERY_TIMEOUT_MS)).getValues({
13315
+ const response = await withTimeout(historyApi.getValues({
12580
13316
  context,
12581
13317
  from: Temporal.Instant.from(new Date(window.from).toISOString()),
12582
13318
  to: Temporal.Instant.from(new Date(window.to).toISOString()),
@@ -12601,13 +13337,15 @@ async function historyPositions(app, context, window, resolutionMs, debug) {
12601
13337
  if (debug.enabled) debug(`History supplied ${points.length} position(s) for ${context}`);
12602
13338
  return {
12603
13339
  points,
12604
- resolutionMs: applied
13340
+ resolutionMs: applied,
13341
+ failed: false
12605
13342
  };
12606
13343
  } catch (err) {
12607
13344
  if (debug.enabled) debug(`History unavailable for ${context}: ${errorDetail(err)}`);
12608
13345
  return {
12609
13346
  points: [],
12610
- resolutionMs: applied
13347
+ resolutionMs: applied,
13348
+ failed: true
12611
13349
  };
12612
13350
  }
12613
13351
  }
@@ -12633,6 +13371,13 @@ function ThePlugin(app) {
12633
13371
  const sourceWatch = new SourceWatch();
12634
13372
  let glitchFilter = new GlitchFilter({ maxSpeedKnots: 100 });
12635
13373
  let stateGate = new StateGate(app.selfContext, DEFAULT_PAUSE_STATES);
13374
+ /**
13375
+ * The history provider's context list, cached between existence probes.
13376
+ *
13377
+ * Per plugin instance rather than module-global: two instances would
13378
+ * otherwise answer from each other's provider.
13379
+ */
13380
+ const knownContexts = { current: void 0 };
12636
13381
  /** The own vessel's navigation.state, or undefined when it reports none. */
12637
13382
  function getVesselState() {
12638
13383
  const s = app.getSelfPath("navigation.state");
@@ -12704,6 +13449,7 @@ function ThePlugin(app) {
12704
13449
  store: () => tracks,
12705
13450
  selfContext: () => app.selfContext,
12706
13451
  segmentGap: () => segmentGap,
13452
+ contextName: (context) => contextName(context, (path) => app.getPath?.(path)),
12707
13453
  reconcileWithHistory: async (context, stored, window, resolutionMs) => {
12708
13454
  const effective = resolutionMs ?? storeResolution;
12709
13455
  const history = await historyPositions(app, context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective, app.debug);
@@ -12723,6 +13469,7 @@ function ThePlugin(app) {
12723
13469
  sourceWatch.clear();
12724
13470
  glitchFilter.clear();
12725
13471
  stateGate.clear();
13472
+ knownContexts.current = void 0;
12726
13473
  try {
12727
13474
  tracks?.close?.();
12728
13475
  } catch (err) {
@@ -12732,6 +13479,34 @@ function ThePlugin(app) {
12732
13479
  }
12733
13480
  },
12734
13481
  signalKApiRoutes: function(router) {
13482
+ const nameOf = (context) => trackLabel(context, app.selfContext, (path) => app.getPath?.(path));
13483
+ /**
13484
+ * The points of one context, reconciled with history and segmented.
13485
+ *
13486
+ * Shared by the JSON and GPX routes so the two cannot answer the same
13487
+ * question differently: the history reconciliation, the windowless
13488
+ * fallback and the 404-only-for-an-unknown-vessel rule are subtle enough
13489
+ * that a second copy would drift.
13490
+ *
13491
+ * Rejects with `undefined` when neither source knows the vessel at all.
13492
+ */
13493
+ const readSegments = async (context, query) => {
13494
+ const effectiveResolution = query.resolution ?? storeResolution;
13495
+ const { points: stored, known } = await tracks.getTimed(context, query.window).then((points) => ({
13496
+ points,
13497
+ known: true
13498
+ })).catch(() => ({
13499
+ points: [],
13500
+ known: false
13501
+ }));
13502
+ const history = await historyPositions(app, context, query.window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effectiveResolution, app.debug);
13503
+ const points = history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
13504
+ if (points.length === 0) {
13505
+ if (!(known || await historyKnowsContext(app, context, app.debug, knownContexts))) return;
13506
+ if (history.failed) throw new HistoryUnavailableError(`History provider could not be read for ${context}`);
13507
+ }
13508
+ return segment(thin(points, query.resolution), segmentGap);
13509
+ };
12735
13510
  const singleTrackHandler = (contextOf) => (req, res) => {
12736
13511
  if (!tracks) {
12737
13512
  notAvailable(res);
@@ -12746,37 +13521,89 @@ function ThePlugin(app) {
12746
13521
  res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
12747
13522
  return;
12748
13523
  }
12749
- const effectiveResolution = query.resolution ?? storeResolution;
12750
- tracks.getTimed(context, query.window).then((points) => ({
12751
- points,
12752
- known: true
12753
- })).catch(() => ({
12754
- points: [],
12755
- known: false
12756
- })).then(async ({ points: stored, known }) => {
12757
- const window = query.window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS);
12758
- const history = await historyPositions(app, context, window, effectiveResolution, app.debug);
12759
- const points = history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
12760
- if (points.length === 0 && !known) {
13524
+ readSegments(context, query).then((segments) => {
13525
+ if (segments === void 0) {
12761
13526
  res.status(404);
12762
13527
  res.json({ message: `No track available for ${context}` });
12763
13528
  return;
12764
13529
  }
12765
- const segments = segment(thin(points, query.resolution), segmentGap);
12766
13530
  res.json({
12767
13531
  type: "MultiLineString",
12768
13532
  coordinates: segments.map((points) => points.map(({ position }) => toLngLat(position))),
12769
13533
  ...query.times ? { times: segments.map(toIsoTimes) } : {},
12770
13534
  context,
12771
- isSelf: context === app.selfContext
13535
+ isSelf: context === app.selfContext,
13536
+ name: nameOf(context)
12772
13537
  });
12773
- }).catch(() => {
13538
+ }).catch((err) => {
13539
+ if (err instanceof HistoryUnavailableError) {
13540
+ app.error(`${err.message}`);
13541
+ res.status(503);
13542
+ res.json({ message: `Track history is temporarily unavailable for ${context}` });
13543
+ return;
13544
+ }
12774
13545
  res.status(404);
12775
13546
  res.json({ message: `No track available for ${context}` });
12776
13547
  });
12777
13548
  };
13549
+ /**
13550
+ * One vessel's track as a GPX file.
13551
+ *
13552
+ * Serialised by `toGpx` rather than assembled here, so the export a user
13553
+ * downloads is the same document the module produces everywhere else.
13554
+ * The webapp cannot reach that module — it ships as static files with no
13555
+ * bundler — which is why the conversion lives behind a route instead of
13556
+ * being duplicated in the page.
13557
+ */
13558
+ const gpxHandler = (contextOf) => (req, res) => {
13559
+ if (!tracks) {
13560
+ notAvailable(res);
13561
+ return;
13562
+ }
13563
+ const context = contextOf(req);
13564
+ let query;
13565
+ try {
13566
+ query = parseTrackQuery(req.query);
13567
+ } catch (err) {
13568
+ res.status(400);
13569
+ res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
13570
+ return;
13571
+ }
13572
+ readSegments(context, query).then((segments) => {
13573
+ if (segments === void 0) {
13574
+ res.status(404);
13575
+ res.json({ message: `No track available for ${context}` });
13576
+ return;
13577
+ }
13578
+ const label = nameOf(context);
13579
+ const filename = gpxFilename(label);
13580
+ res.type("application/gpx+xml");
13581
+ res.setHeader("Content-Disposition", `attachment; filename="${asciiFilename(filename)}"; filename*=UTF-8''${rfc8187(filename)}`);
13582
+ res.send(toGpx([{
13583
+ name: label,
13584
+ context,
13585
+ segments
13586
+ }]));
13587
+ }).catch((err) => {
13588
+ if (err instanceof HistoryUnavailableError) {
13589
+ app.error(`${err.message}`);
13590
+ if (!res.headersSent) {
13591
+ res.status(503);
13592
+ res.json({ message: `Track history is temporarily unavailable for ${context}` });
13593
+ }
13594
+ return;
13595
+ }
13596
+ app.error(`Could not export GPX for ${context}: ${errorDetail(err)}`);
13597
+ if (!res.headersSent) {
13598
+ res.status(500);
13599
+ res.json({ message: `Could not export the track for ${context}` });
13600
+ }
13601
+ });
13602
+ };
12778
13603
  const trackHandler = singleTrackHandler((req) => resolveContext(String(req.params.vesselId), app.selfContext));
12779
13604
  router.get("/vessels/:vesselId/track", trackHandler);
13605
+ router.get("/vessels/:vesselId/track.gpx", gpxHandler((req) => resolveContext(String(req.params.vesselId), app.selfContext)));
13606
+ router.get("/self/track.gpx", gpxHandler(() => app.selfContext));
12780
13607
  router.get("/self/track", singleTrackHandler(() => app.selfContext));
12781
13608
  const allTracksHandler = (req, res) => {
12782
13609
  app.debug(req.query);
@@ -12800,14 +13627,16 @@ function ThePlugin(app) {
12800
13627
  type: "MultiLineString",
12801
13628
  coordinates: segments.map((s) => s.map(({ position }) => toLngLat(position))),
12802
13629
  times: segments.map(toIsoTimes),
12803
- isSelf: context === app.selfContext
13630
+ isSelf: context === app.selfContext,
13631
+ name: nameOf(context)
12804
13632
  };
12805
13633
  return acc;
12806
13634
  }, {})) : tracks.getFilteredTracks(params, selfPosition, app.debug, query).then((tc) => Object.entries(tc).reduce((acc, [context, track]) => {
12807
13635
  acc[context] = {
12808
13636
  type: "MultiLineString",
12809
13637
  coordinates: [track.map(toLngLat)],
12810
- isSelf: context === app.selfContext
13638
+ isSelf: context === app.selfContext,
13639
+ name: nameOf(context)
12811
13640
  };
12812
13641
  return acc;
12813
13642
  }, {}))).then((trks) => {
@@ -12877,6 +13706,6 @@ function ThePlugin(app) {
12877
13706
  };
12878
13707
  }
12879
13708
  //#endregion
12880
- export { TrackAccumulator, Tracks, ThePlugin as default };
13709
+ export { TrackAccumulator, Tracks, ThePlugin as default, fromGpx, toGpx };
12881
13710
 
12882
13711
  //# sourceMappingURL=index.js.map