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

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
@@ -12337,6 +12600,285 @@ var StateGate = class {
12337
12600
  }
12338
12601
  };
12339
12602
  //#endregion
12603
+ //#region src/gpx.ts
12604
+ /**
12605
+ * GPX 1.1 serialisation and parsing for recorded tracks.
12606
+ *
12607
+ * Hand-written rather than through a library: the vocabulary is `gpx`,
12608
+ * `metadata`, `trk`, `name`, `extensions`, `signalk:context`, `trkseg`,
12609
+ * `trkpt` and `time` — nine element types — and a dependency for that would be
12610
+ * more surface than the code it replaces.
12611
+ * What matters is getting the details right, and those are documented below.
12612
+ */
12613
+ var LAT = 0;
12614
+ var LNG = 1;
12615
+ /**
12616
+ * Serialise tracks as a GPX 1.1 document.
12617
+ *
12618
+ * Several tracks go in one file, each as its own `<trk>`. GPX allows it, and
12619
+ * it is what makes "select three trips, export" produce one file rather than
12620
+ * three — TimeZero writes one file per vessel, which is a choice rather than a
12621
+ * constraint of the format.
12622
+ *
12623
+ * The Signal K context travels in `<extensions>`. TimeZero puts an opaque
12624
+ * GUID there and drops the MMSI entirely, so its exports cannot round-trip an
12625
+ * identity; carrying the context means ours can, while staying readable by
12626
+ * anything else, since `<extensions>` is ignorable by specification.
12627
+ */
12628
+ function toGpx(tracks, now = /* @__PURE__ */ new Date()) {
12629
+ const lines = [
12630
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
12631
+ "<gpx version=\"1.1\" creator=\"@signalk/tracks-plugin\" xmlns=\"http://www.topografix.com/GPX/1/1\">",
12632
+ " <metadata>",
12633
+ ` <time>${iso(now.getTime())}</time>`,
12634
+ " </metadata>"
12635
+ ];
12636
+ for (const track of tracks) {
12637
+ const segments = track.segments.map((points) => points.filter((p) => inRange(p.position[LAT], p.position[LNG]))).filter((points) => points.length > 0);
12638
+ if (segments.length === 0) continue;
12639
+ lines.push(" <trk>");
12640
+ lines.push(` <name>${escapeXml(track.name)}</name>`);
12641
+ if (track.context !== void 0) {
12642
+ lines.push(" <extensions>");
12643
+ lines.push(` <signalk:context xmlns:signalk="${NAMESPACE}">${escapeXml(track.context)}</signalk:context>`);
12644
+ lines.push(" </extensions>");
12645
+ }
12646
+ for (const points of segments) {
12647
+ lines.push(" <trkseg>");
12648
+ for (const { position, timestamp } of points) {
12649
+ lines.push(` <trkpt lat="${coordinate(position[LAT])}" lon="${coordinate(position[LNG])}">`);
12650
+ lines.push(` <time>${iso(timestamp)}</time>`);
12651
+ lines.push(" </trkpt>");
12652
+ }
12653
+ lines.push(" </trkseg>");
12654
+ }
12655
+ lines.push(" </trk>");
12656
+ }
12657
+ lines.push("</gpx>");
12658
+ return lines.join("\n") + "\n";
12659
+ }
12660
+ /** GPX's own namespace, which structural elements must be in. */
12661
+ var GPX_NAMESPACE = "http://www.topografix.com/GPX/1/1";
12662
+ /** Namespace for the one extension element this plugin writes. */
12663
+ var NAMESPACE = "https://signalk.org/specification/1.7.0/";
12664
+ /**
12665
+ * Parse a GPX document into tracks.
12666
+ *
12667
+ * Structure follows GPX's own hierarchy: the root is `<gpx>`, tracks are its
12668
+ * children, segments are a track's, points are a segment's. Anything else —
12669
+ * a `<trkseg>` inside an `<extensions>` payload, a `<trk>` below the root — is
12670
+ * not track data, and a namespace check alone cannot tell the difference,
12671
+ * since a payload declaring no namespace inherits GPX's own.
12672
+ *
12673
+ * Deliberately forgiving about structure and strict about values: elements it
12674
+ * does not recognise are ignored rather than rejected, but a coordinate that is
12675
+ * not an `xsd:decimal` in range, or a time that is not an `xsd:dateTime`, is
12676
+ * refused. One NaN reaching the store stretches every bounding box that track
12677
+ * appears in.
12678
+ *
12679
+ * **Points are sorted by time within each segment.** TimeZero paginates its
12680
+ * export in blocks of 150 and the block seams overlap: the last point of a
12681
+ * block can be a few milliseconds newer than the first point of the next. Out
12682
+ * of 5,550 points in a real export, 16 pairs are out of order, every one on a
12683
+ * 150-point boundary. Anything downstream that assumes ordering — time-window
12684
+ * queries, the history reconciliation — would quietly misbehave on such a file.
12685
+ *
12686
+ * Sorting stops at the segment boundary, deliberately: a segment is a stretch
12687
+ * of continuous recording, and reordering points across a gap would merge two
12688
+ * passages into one line. So `segments.flat()` is not globally sorted for a
12689
+ * file whose segments interleave in time, and a caller that needs that must
12690
+ * sort for itself.
12691
+ *
12692
+ * A document the parser rejects yields no tracks rather than throwing: this
12693
+ * reads files the plugin did not write, and an import that fails should say so
12694
+ * by returning nothing rather than by taking the caller down.
12695
+ */
12696
+ function fromGpx(xml) {
12697
+ let document;
12698
+ try {
12699
+ document = new DOMParser({ onError: (level) => {
12700
+ if (level !== "warning") throw new Error("malformed XML");
12701
+ } }).parseFromString(xml, "text/xml");
12702
+ } catch {
12703
+ return [];
12704
+ }
12705
+ const root = document.documentElement;
12706
+ if (root === null || root.localName !== "gpx" || !isGpxNamespace(root)) return [];
12707
+ const tracks = [];
12708
+ for (const trk of gpxChildren(root, "trk")) {
12709
+ const segments = [];
12710
+ for (const trkseg of gpxChildren(trk, "trkseg")) {
12711
+ const points = readPoints(trkseg);
12712
+ if (points.length > 0) segments.push(points);
12713
+ }
12714
+ if (segments.length === 0) continue;
12715
+ const name = childText(trk, "name").trim() || "Track";
12716
+ const context = signalKContext(trk);
12717
+ tracks.push({
12718
+ name,
12719
+ segments,
12720
+ ...context === void 0 ? {} : { context }
12721
+ });
12722
+ }
12723
+ return tracks;
12724
+ }
12725
+ /**
12726
+ * The text of a direct GPX child with this local name.
12727
+ *
12728
+ * Direct rather than `getElementsByTagName`, which would reach a `<name>`
12729
+ * inside an `<extensions>` payload and label the track with a vendor's string.
12730
+ */
12731
+ function childText(trk, local) {
12732
+ for (const child of Array.from(trk.childNodes)) if (isElement(child) && child.localName === local && isGpxNamespace(child)) return child.textContent ?? "";
12733
+ return "";
12734
+ }
12735
+ /**
12736
+ * The Signal K context declared in a track's own `<extensions>`.
12737
+ *
12738
+ * Matched on namespace URI, which is what the prefix actually means: GPX
12739
+ * extensions are an open field any vendor may write into, so a foreign
12740
+ * `<context>` — or one under a different prefix bound elsewhere — must not
12741
+ * claim the track's identity. Read only from the track's direct
12742
+ * `<extensions>` children, because a trackpoint carries its own and those
12743
+ * belong to that point.
12744
+ */
12745
+ function signalKContext(trk) {
12746
+ for (const child of Array.from(trk.childNodes)) {
12747
+ if (!isElement(child) || child.localName !== "extensions" || !isGpxNamespace(child)) continue;
12748
+ for (const candidate of Array.from(child.getElementsByTagNameNS(NAMESPACE, "context"))) {
12749
+ const value = (candidate.textContent ?? "").trim();
12750
+ if (value !== "") return value;
12751
+ }
12752
+ }
12753
+ }
12754
+ /** The points of one `<trkseg>`, oldest first. */
12755
+ function readPoints(trkseg) {
12756
+ const points = [];
12757
+ for (const trkpt of gpxChildren(trkseg, "trkpt")) {
12758
+ const latitude = decimal(trkpt.getAttribute("lat") ?? void 0);
12759
+ const longitude = decimal(trkpt.getAttribute("lon") ?? void 0);
12760
+ if (!inRange(latitude, longitude)) continue;
12761
+ const raw = childText(trkpt, "time").trim();
12762
+ const timestamp = raw === "" ? NaN : dateTime(raw);
12763
+ points.push({
12764
+ position: [latitude, longitude],
12765
+ timestamp: Number.isFinite(timestamp) ? timestamp : 0
12766
+ });
12767
+ }
12768
+ return points.sort((a, b) => a.timestamp - b.timestamp);
12769
+ }
12770
+ /**
12771
+ * Direct GPX children of `root` with this local name.
12772
+ *
12773
+ * Direct, because GPX fixes the hierarchy: a `<trkseg>` is a child of `<trk>`
12774
+ * and a `<trkpt>` a child of `<trkseg>`. Scanning descendants instead lets an
12775
+ * `<extensions>` payload contribute track data — and a payload that declares
12776
+ * no namespace of its own inherits GPX's, so a namespace check cannot catch
12777
+ * it. A vendor's `<trkseg>` inside `<extensions>` became a phantom segment.
12778
+ */
12779
+ function gpxChildren(root, local) {
12780
+ return Array.from(root.childNodes).filter((node) => isElement(node) && node.localName === local && isGpxNamespace(node));
12781
+ }
12782
+ /**
12783
+ * Whether an element is GPX's own.
12784
+ *
12785
+ * A null namespace counts: a great many files in the wild omit the xmlns
12786
+ * declaration entirely, and refusing those would reject most of what users
12787
+ * actually have.
12788
+ */
12789
+ function isGpxNamespace(element) {
12790
+ return element.namespaceURI === null || element.namespaceURI === GPX_NAMESPACE;
12791
+ }
12792
+ /** Narrow a DOM node to an element without relying on a global Node. */
12793
+ function isElement(node) {
12794
+ return node.nodeType === 1;
12795
+ }
12796
+ /**
12797
+ * Coordinates at seven decimal places: ~11 mm, finer than any GPS fix and
12798
+ * short enough that a long track does not carry pointless digits.
12799
+ */
12800
+ function coordinate(value) {
12801
+ const fixed = value.toFixed(7).replace(/\.?0+$/, "");
12802
+ return fixed === "-0" ? "0" : fixed;
12803
+ }
12804
+ /**
12805
+ * A GPX `<time>` as epoch milliseconds, or NaN when it is not one.
12806
+ *
12807
+ * GPX types the element as `xsd:dateTime`, and `Date.parse` is far looser: it
12808
+ * reads a bare `2024` and `Jan 1 2024` as instants, and silently rolls
12809
+ * `2024-02-30` forward to March. A date that never existed should reach the
12810
+ * caller's fallback rather than arrive as a real -- and wrong -- position in
12811
+ * time.
12812
+ */
12813
+ function dateTime(value) {
12814
+ const match = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.exec(value);
12815
+ if (!match) return NaN;
12816
+ const parsed = Date.parse(value);
12817
+ if (Number.isNaN(parsed)) return NaN;
12818
+ const year = Number(match[1]);
12819
+ const month = Number(match[2]);
12820
+ const day = Number(match[3]);
12821
+ const utc = new Date(Date.UTC(year, month - 1, day));
12822
+ return utc.getUTCFullYear() === year && utc.getUTCMonth() === month - 1 && utc.getUTCDate() === day ? parsed : NaN;
12823
+ }
12824
+ /**
12825
+ * ISO-8601 UTC, because a track outlives the timezone it was recorded in.
12826
+ *
12827
+ * A timestamp outside the Date range throws out of `toISOString`, and one bad
12828
+ * point would abort the export of every track in the file. It is dated to the
12829
+ * start of time instead, which is what `fromGpx` does with a time it cannot
12830
+ * read -- losing one point's time beats losing the document.
12831
+ */
12832
+ function iso(timestamp) {
12833
+ const at = new Date(timestamp);
12834
+ return Number.isNaN(at.getTime()) ? (/* @__PURE__ */ new Date(0)).toISOString() : at.toISOString();
12835
+ }
12836
+ /**
12837
+ * XML-escape a value for element content.
12838
+ *
12839
+ * Characters XML cannot express are dropped rather than escaped, because they
12840
+ * have no entity form: emitting one produces a document no conformant reader
12841
+ * will load, and losing the character beats losing the file.
12842
+ */
12843
+ function escapeXml(value) {
12844
+ 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;");
12845
+ }
12846
+ /**
12847
+ * Whether a code point is one XML 1.0 permits in content.
12848
+ *
12849
+ * The range has holes: NUL, most C0 controls and the surrogate block are all
12850
+ * excluded. Decoding `&#0;` or `&#xD800;` puts a character into a track name
12851
+ * that cannot be written back out as well-formed XML, so the reference is
12852
+ * kept verbatim instead -- the same treatment as one that names no character
12853
+ * at all.
12854
+ */
12855
+ function isXmlChar(value) {
12856
+ if (!Number.isInteger(value)) return false;
12857
+ return value === 9 || value === 10 || value === 13 || value >= 32 && value <= 55295 || value >= 57344 && value <= 65533 || value >= 65536 && value <= 1114111;
12858
+ }
12859
+ /**
12860
+ * An attribute value as an XSD decimal, or NaN when it is not one.
12861
+ *
12862
+ * GPX types lat and lon as `xsd:decimal`, which is digits with an optional
12863
+ * sign and point -- no exponent, no hexadecimal. `Number()` is far more
12864
+ * generous: it reads `0x10` as 16 and `1e1` as 10, so a file using either
12865
+ * imported a position it never expressed.
12866
+ */
12867
+ function decimal(value) {
12868
+ return value !== void 0 && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value) ? Number(value) : NaN;
12869
+ }
12870
+ /**
12871
+ * Whether a position is one GPX can express.
12872
+ *
12873
+ * Latitude beyond ±90 or longitude beyond ±180 is not a place. Such a value
12874
+ * reaching the store stretches every bounding box the track appears in, and
12875
+ * writing one produces a document a validating reader rejects — so it is
12876
+ * refused in both directions rather than trusted from a file or passed on.
12877
+ */
12878
+ function inRange(latitude, longitude) {
12879
+ return Number.isFinite(latitude) && Number.isFinite(longitude) && latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180;
12880
+ }
12881
+ //#endregion
12340
12882
  //#region src/tracks.ts
12341
12883
  /**
12342
12884
  * The in-memory store: a bounded sliding window of positions per context, held
@@ -12503,6 +13045,45 @@ var HISTORY_QUERY_TIMEOUT_MS = 5e3;
12503
13045
  */
12504
13046
  var WINDOWLESS_HISTORY_SPAN_MS = 864e5;
12505
13047
  /**
13048
+ * The earliest instant the existence probe asks about.
13049
+ *
13050
+ * The probe has to name a bound: the History API's time range has no
13051
+ * unbounded form, every branch of `TimeRangeParams` carries one. A provider
13052
+ * filters its context list by the range it is given — questdb builds a SQL
13053
+ * `WHERE` from it — so any bound later than the provider's oldest row can
13054
+ * still miss a vessel and 404 it, which is the case the probe exists to catch.
13055
+ *
13056
+ * The Unix epoch is the bound rather than some span of years: it predates
13057
+ * satellite navigation, so no position fix can lie before it, and unlike a
13058
+ * fixed "wide enough" window it cannot be outlived. The cost lands only on the
13059
+ * branch that was already about to 404, and the answer is cached.
13060
+ */
13061
+ var EXISTENCE_PROBE_FROM_MS = 0;
13062
+ /**
13063
+ * How long a provider's context list is reused before asking again.
13064
+ *
13065
+ * The probe runs only for a vessel about to 404, which is precisely the
13066
+ * request a client can repeat without limit — the routes are open, so
13067
+ * enumerating vessel ids would otherwise drive one epoch-wide `getContexts`
13068
+ * per request, each able to block for the timeout.
13069
+ *
13070
+ * The whole list is cached rather than a per-context answer, or enumeration
13071
+ * would simply fill the cache with distinct keys and query just as often. The
13072
+ * cost is that a vessel a provider learns about becomes visible up to this
13073
+ * long after the fact, which is not a delay anyone can perceive in a track
13074
+ * that is already minutes old.
13075
+ */
13076
+ var KNOWN_CONTEXTS_TTL_MS = 3e4;
13077
+ /**
13078
+ * How long a *failed* context query is remembered.
13079
+ *
13080
+ * Short, so a provider that recovers is noticed quickly, but not zero: with
13081
+ * the entry simply dropped, a provider that hangs is asked again by every
13082
+ * subsequent miss while the earlier calls are still pending — reinstating per
13083
+ * request exactly the cost the cache exists to prevent.
13084
+ */
13085
+ var KNOWN_CONTEXTS_FAILURE_TTL_MS = 1e3;
13086
+ /**
12506
13087
  * Config values arrive from the plugin UI as numbers, but a hand-edited
12507
13088
  * settings file can supply strings. Accept both, reject anything non-finite so
12508
13089
  * a bad value falls back to the default instead of poisoning arithmetic with NaN.
@@ -12518,6 +13099,15 @@ var notAvailable = (res) => {
12518
13099
  res.status(404);
12519
13100
  res.json({ message: "Tracks API not available because tracks plugin is not enabled" });
12520
13101
  };
13102
+ /**
13103
+ * The history provider could not answer, and nothing else could either.
13104
+ *
13105
+ * Distinct from a missing track: a 404 asserts that no source knows the
13106
+ * vessel, which a failed provider read cannot support. Reporting the failure
13107
+ * as an empty track would present an outage as "this vessel has no positions
13108
+ * here" — the one answer that is certainly wrong.
13109
+ */
13110
+ var HistoryUnavailableError = class extends Error {};
12521
13111
  var errorDetail = (err) => err instanceof Error && err.stack ? err.stack : String(err);
12522
13112
  /**
12523
13113
  * A window covering everything the store holds, up to now.
@@ -12548,9 +13138,15 @@ function windowSpanning(stored, fallbackMs) {
12548
13138
  * promise wins. A race leaves it armed, holding the event loop open for the
12549
13139
  * remainder of the timeout on every call that succeeds.
12550
13140
  */
13141
+ /**
13142
+ * A bound expired. Distinct from the error a provider itself raises, because
13143
+ * "did not answer in time" and "is not installed" are different answers and
13144
+ * only one of them is an outage.
13145
+ */
13146
+ var HistoryTimeoutError = class extends Error {};
12551
13147
  function withTimeout(promise, ms) {
12552
13148
  return new Promise((resolve, reject) => {
12553
- const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`timed out after ${ms}ms`)), ms);
13149
+ const timer = setTimeout(() => reject(new HistoryTimeoutError(`timed out after ${ms}ms`)), ms);
12554
13150
  promise.then((value) => {
12555
13151
  clearTimeout(timer);
12556
13152
  resolve(value);
@@ -12561,6 +13157,48 @@ function withTimeout(promise, ms) {
12561
13157
  });
12562
13158
  }
12563
13159
  /**
13160
+ * Whether a history provider holds anything at all for a context.
13161
+ *
13162
+ * Asked only when a track is about to 404: `getValues` narrowed to a window
13163
+ * cannot tell "this vessel is unknown" from "this vessel has nothing *here*",
13164
+ * and 404ing on the second is wrong — the contract is that a 404 means neither
13165
+ * source knows the vessel. A vessel whose history lies wholly outside the
13166
+ * asked window is known, and deserves an empty track.
13167
+ *
13168
+ * Best-effort, like every other provider call here: a provider that lacks the
13169
+ * method, errors, or hangs leaves the 404 standing rather than holding the
13170
+ * request open.
13171
+ */
13172
+ async function historyKnowsContext(app, context, debug, cache) {
13173
+ const getHistoryApi = app.getHistoryApi;
13174
+ if (!getHistoryApi) return false;
13175
+ try {
13176
+ const now = Date.now();
13177
+ const cached = cache.current;
13178
+ if (cached === void 0 || now - cached.at >= KNOWN_CONTEXTS_TTL_MS) {
13179
+ const contexts = withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider).then((historyApi) => historyApi.getContexts ? historyApi.getContexts({
13180
+ from: Temporal.Instant.from((/* @__PURE__ */ new Date(EXISTENCE_PROBE_FROM_MS)).toISOString()),
13181
+ to: Temporal.Instant.from(new Date(now).toISOString())
13182
+ }) : []).then((listed) => new Set((listed ?? []).filter((c) => typeof c === "string").map((c) => c === "vessels.self" ? app.selfContext : c))), HISTORY_QUERY_TIMEOUT_MS);
13183
+ const entry = {
13184
+ at: now,
13185
+ contexts
13186
+ };
13187
+ contexts.catch(() => {
13188
+ if (cache.current === entry) cache.current = {
13189
+ at: now - KNOWN_CONTEXTS_TTL_MS + KNOWN_CONTEXTS_FAILURE_TTL_MS,
13190
+ contexts
13191
+ };
13192
+ });
13193
+ cache.current = entry;
13194
+ }
13195
+ return (await cache.current.contexts).has(context);
13196
+ } catch (err) {
13197
+ debug(`History contexts unavailable for ${context}: ${errorDetail(err)}`);
13198
+ return false;
13199
+ }
13200
+ }
13201
+ /**
12564
13202
  * Positions a history provider holds for a window, or none.
12565
13203
  *
12566
13204
  * Best-effort by design: a provider that is absent, slow, or failing must not
@@ -12573,10 +13211,23 @@ async function historyPositions(app, context, window, resolutionMs, debug) {
12573
13211
  const getHistoryApi = app.getHistoryApi;
12574
13212
  if (!getHistoryApi) return {
12575
13213
  points: [],
12576
- resolutionMs: applied
13214
+ resolutionMs: applied,
13215
+ failed: false
12577
13216
  };
13217
+ let historyApi;
13218
+ try {
13219
+ historyApi = await withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider), HISTORY_QUERY_TIMEOUT_MS);
13220
+ } catch (err) {
13221
+ const timedOut = err instanceof HistoryTimeoutError;
13222
+ if (debug.enabled) debug(`${timedOut ? "History provider timed out" : "No history provider"} for ${context}: ${errorDetail(err)}`);
13223
+ return {
13224
+ points: [],
13225
+ resolutionMs: applied,
13226
+ failed: timedOut
13227
+ };
13228
+ }
12578
13229
  try {
12579
- const response = await withTimeout((await withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider), HISTORY_QUERY_TIMEOUT_MS)).getValues({
13230
+ const response = await withTimeout(historyApi.getValues({
12580
13231
  context,
12581
13232
  from: Temporal.Instant.from(new Date(window.from).toISOString()),
12582
13233
  to: Temporal.Instant.from(new Date(window.to).toISOString()),
@@ -12601,13 +13252,15 @@ async function historyPositions(app, context, window, resolutionMs, debug) {
12601
13252
  if (debug.enabled) debug(`History supplied ${points.length} position(s) for ${context}`);
12602
13253
  return {
12603
13254
  points,
12604
- resolutionMs: applied
13255
+ resolutionMs: applied,
13256
+ failed: false
12605
13257
  };
12606
13258
  } catch (err) {
12607
13259
  if (debug.enabled) debug(`History unavailable for ${context}: ${errorDetail(err)}`);
12608
13260
  return {
12609
13261
  points: [],
12610
- resolutionMs: applied
13262
+ resolutionMs: applied,
13263
+ failed: true
12611
13264
  };
12612
13265
  }
12613
13266
  }
@@ -12633,6 +13286,13 @@ function ThePlugin(app) {
12633
13286
  const sourceWatch = new SourceWatch();
12634
13287
  let glitchFilter = new GlitchFilter({ maxSpeedKnots: 100 });
12635
13288
  let stateGate = new StateGate(app.selfContext, DEFAULT_PAUSE_STATES);
13289
+ /**
13290
+ * The history provider's context list, cached between existence probes.
13291
+ *
13292
+ * Per plugin instance rather than module-global: two instances would
13293
+ * otherwise answer from each other's provider.
13294
+ */
13295
+ const knownContexts = { current: void 0 };
12636
13296
  /** The own vessel's navigation.state, or undefined when it reports none. */
12637
13297
  function getVesselState() {
12638
13298
  const s = app.getSelfPath("navigation.state");
@@ -12704,6 +13364,7 @@ function ThePlugin(app) {
12704
13364
  store: () => tracks,
12705
13365
  selfContext: () => app.selfContext,
12706
13366
  segmentGap: () => segmentGap,
13367
+ contextName: (context) => contextName(context, (path) => app.getPath?.(path)),
12707
13368
  reconcileWithHistory: async (context, stored, window, resolutionMs) => {
12708
13369
  const effective = resolutionMs ?? storeResolution;
12709
13370
  const history = await historyPositions(app, context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective, app.debug);
@@ -12723,6 +13384,7 @@ function ThePlugin(app) {
12723
13384
  sourceWatch.clear();
12724
13385
  glitchFilter.clear();
12725
13386
  stateGate.clear();
13387
+ knownContexts.current = void 0;
12726
13388
  try {
12727
13389
  tracks?.close?.();
12728
13390
  } catch (err) {
@@ -12732,6 +13394,34 @@ function ThePlugin(app) {
12732
13394
  }
12733
13395
  },
12734
13396
  signalKApiRoutes: function(router) {
13397
+ const nameOf = (context) => trackLabel(context, app.selfContext, (path) => app.getPath?.(path));
13398
+ /**
13399
+ * The points of one context, reconciled with history and segmented.
13400
+ *
13401
+ * Shared by the JSON and GPX routes so the two cannot answer the same
13402
+ * question differently: the history reconciliation, the windowless
13403
+ * fallback and the 404-only-for-an-unknown-vessel rule are subtle enough
13404
+ * that a second copy would drift.
13405
+ *
13406
+ * Rejects with `undefined` when neither source knows the vessel at all.
13407
+ */
13408
+ const readSegments = async (context, query) => {
13409
+ const effectiveResolution = query.resolution ?? storeResolution;
13410
+ const { points: stored, known } = await tracks.getTimed(context, query.window).then((points) => ({
13411
+ points,
13412
+ known: true
13413
+ })).catch(() => ({
13414
+ points: [],
13415
+ known: false
13416
+ }));
13417
+ const history = await historyPositions(app, context, query.window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effectiveResolution, app.debug);
13418
+ const points = history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
13419
+ if (points.length === 0) {
13420
+ if (!(known || await historyKnowsContext(app, context, app.debug, knownContexts))) return;
13421
+ if (history.failed) throw new HistoryUnavailableError(`History provider could not be read for ${context}`);
13422
+ }
13423
+ return segment(thin(points, query.resolution), segmentGap);
13424
+ };
12735
13425
  const singleTrackHandler = (contextOf) => (req, res) => {
12736
13426
  if (!tracks) {
12737
13427
  notAvailable(res);
@@ -12746,37 +13436,89 @@ function ThePlugin(app) {
12746
13436
  res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
12747
13437
  return;
12748
13438
  }
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) {
13439
+ readSegments(context, query).then((segments) => {
13440
+ if (segments === void 0) {
12761
13441
  res.status(404);
12762
13442
  res.json({ message: `No track available for ${context}` });
12763
13443
  return;
12764
13444
  }
12765
- const segments = segment(thin(points, query.resolution), segmentGap);
12766
13445
  res.json({
12767
13446
  type: "MultiLineString",
12768
13447
  coordinates: segments.map((points) => points.map(({ position }) => toLngLat(position))),
12769
13448
  ...query.times ? { times: segments.map(toIsoTimes) } : {},
12770
13449
  context,
12771
- isSelf: context === app.selfContext
13450
+ isSelf: context === app.selfContext,
13451
+ name: nameOf(context)
12772
13452
  });
12773
- }).catch(() => {
13453
+ }).catch((err) => {
13454
+ if (err instanceof HistoryUnavailableError) {
13455
+ app.error(`${err.message}`);
13456
+ res.status(503);
13457
+ res.json({ message: `Track history is temporarily unavailable for ${context}` });
13458
+ return;
13459
+ }
12774
13460
  res.status(404);
12775
13461
  res.json({ message: `No track available for ${context}` });
12776
13462
  });
12777
13463
  };
13464
+ /**
13465
+ * One vessel's track as a GPX file.
13466
+ *
13467
+ * Serialised by `toGpx` rather than assembled here, so the export a user
13468
+ * downloads is the same document the module produces everywhere else.
13469
+ * The webapp cannot reach that module — it ships as static files with no
13470
+ * bundler — which is why the conversion lives behind a route instead of
13471
+ * being duplicated in the page.
13472
+ */
13473
+ const gpxHandler = (contextOf) => (req, res) => {
13474
+ if (!tracks) {
13475
+ notAvailable(res);
13476
+ return;
13477
+ }
13478
+ const context = contextOf(req);
13479
+ let query;
13480
+ try {
13481
+ query = parseTrackQuery(req.query);
13482
+ } catch (err) {
13483
+ res.status(400);
13484
+ res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
13485
+ return;
13486
+ }
13487
+ readSegments(context, query).then((segments) => {
13488
+ if (segments === void 0) {
13489
+ res.status(404);
13490
+ res.json({ message: `No track available for ${context}` });
13491
+ return;
13492
+ }
13493
+ const label = nameOf(context);
13494
+ const filename = gpxFilename(label);
13495
+ res.type("application/gpx+xml");
13496
+ res.setHeader("Content-Disposition", `attachment; filename="${asciiFilename(filename)}"; filename*=UTF-8''${rfc8187(filename)}`);
13497
+ res.send(toGpx([{
13498
+ name: label,
13499
+ context,
13500
+ segments
13501
+ }]));
13502
+ }).catch((err) => {
13503
+ if (err instanceof HistoryUnavailableError) {
13504
+ app.error(`${err.message}`);
13505
+ if (!res.headersSent) {
13506
+ res.status(503);
13507
+ res.json({ message: `Track history is temporarily unavailable for ${context}` });
13508
+ }
13509
+ return;
13510
+ }
13511
+ app.error(`Could not export GPX for ${context}: ${errorDetail(err)}`);
13512
+ if (!res.headersSent) {
13513
+ res.status(500);
13514
+ res.json({ message: `Could not export the track for ${context}` });
13515
+ }
13516
+ });
13517
+ };
12778
13518
  const trackHandler = singleTrackHandler((req) => resolveContext(String(req.params.vesselId), app.selfContext));
12779
13519
  router.get("/vessels/:vesselId/track", trackHandler);
13520
+ router.get("/vessels/:vesselId/track.gpx", gpxHandler((req) => resolveContext(String(req.params.vesselId), app.selfContext)));
13521
+ router.get("/self/track.gpx", gpxHandler(() => app.selfContext));
12780
13522
  router.get("/self/track", singleTrackHandler(() => app.selfContext));
12781
13523
  const allTracksHandler = (req, res) => {
12782
13524
  app.debug(req.query);
@@ -12800,14 +13542,16 @@ function ThePlugin(app) {
12800
13542
  type: "MultiLineString",
12801
13543
  coordinates: segments.map((s) => s.map(({ position }) => toLngLat(position))),
12802
13544
  times: segments.map(toIsoTimes),
12803
- isSelf: context === app.selfContext
13545
+ isSelf: context === app.selfContext,
13546
+ name: nameOf(context)
12804
13547
  };
12805
13548
  return acc;
12806
13549
  }, {})) : tracks.getFilteredTracks(params, selfPosition, app.debug, query).then((tc) => Object.entries(tc).reduce((acc, [context, track]) => {
12807
13550
  acc[context] = {
12808
13551
  type: "MultiLineString",
12809
13552
  coordinates: [track.map(toLngLat)],
12810
- isSelf: context === app.selfContext
13553
+ isSelf: context === app.selfContext,
13554
+ name: nameOf(context)
12811
13555
  };
12812
13556
  return acc;
12813
13557
  }, {}))).then((trks) => {
@@ -12877,6 +13621,6 @@ function ThePlugin(app) {
12877
13621
  };
12878
13622
  }
12879
13623
  //#endregion
12880
- export { TrackAccumulator, Tracks, ThePlugin as default };
13624
+ export { TrackAccumulator, Tracks, ThePlugin as default, fromGpx, toGpx };
12881
13625
 
12882
13626
  //# sourceMappingURL=index.js.map