@signalk/tracks-plugin 3.0.0-beta.1 → 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
@@ -1,13 +1,125 @@
1
1
  import { Temporal } from "@js-temporal/polyfill";
2
2
  import { join } from "node:path";
3
- import { BehaviorSubject, ReplaySubject, Subject, combineLatest, connectable, firstValueFrom } from "rxjs";
4
- import { map, scan, startWith, throttleTime } from "rxjs/operators";
5
3
  import { Type } from "typebox";
6
4
  import { Value } from "typebox/value";
7
5
  import { DatabaseSync } from "node:sqlite";
6
+ import { DOMParser } from "@xmldom/xmldom";
7
+ import { BehaviorSubject, ReplaySubject, Subject, combineLatest, connectable, firstValueFrom } from "rxjs";
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]
@@ -358,142 +470,111 @@ function createMatcher(params, selfPosition, debug) {
358
470
  }
359
471
  return () => true;
360
472
  }
361
- //#endregion
362
- //#region src/tracks.ts
363
473
  /**
364
- * The in-memory store: a bounded sliding window of positions per context,
365
- * held in RxJS accumulators and lost on restart. `bootstrapSelfTrack()` in
366
- * index.ts is what re-hydrates it from a History provider at startup.
474
+ * A history row is `[timestamp, position]`. Both encodings are legal: the
475
+ * History API types a value as `unknown` and its own comment names "objects
476
+ * (like navigation.position)" alongside primitives, so signalk-questdb returns
477
+ * `{latitude, longitude}` and signalk-to-influxdb2 returns `[lon, lat]`.
478
+ * Accept both — neither is an error, so rejecting one silently yields an empty
479
+ * track rather than a failure anybody can see.
367
480
  */
368
- var Tracks = class {
369
- tracks = {};
370
- debug;
371
- config;
372
- constructor(config, debug) {
373
- if (debug.enabled) debug(JSON.stringify(config));
374
- this.config = config;
375
- this.debug = debug;
376
- }
377
- newPosition(context, position, timestamp) {
378
- this.getAccumulator(context)?.nextLatLngTuple(position, timestamp);
379
- }
380
- initialTrack(context, track, timestamps) {
381
- this.getAccumulator(context)?.setInitialTrack(track, timestamps);
382
- }
383
- getAccumulator(context, createIfMissing = true) {
384
- if (context.indexOf("vessels.") === -1 && context.indexOf("aircraft.") === -1) return;
385
- let result = this.tracks[context];
386
- if (!result && createIfMissing) {
387
- const accParams = this.config.fetchInitialTrack ? {
388
- ...this.config,
389
- fetchTrackFor: context
390
- } : { ...this.config };
391
- result = this.tracks[context] = new TrackAccumulator(accParams);
392
- }
393
- return result;
394
- }
395
- get(context, window) {
396
- return this.getTimed(context, window).then((points) => points.map(({ position }) => position));
397
- }
398
- /**
399
- * Track points with their timestamps, optionally narrowed to a time window.
400
- *
401
- * Rejects with EmptyError if the stream completes without emitting; the route
402
- * handlers turn that into the same 404 as an unknown context.
403
- */
404
- getTimed(context, window) {
405
- const accumulator = this.getAccumulator(context, false);
406
- if (!accumulator) return Promise.reject(/* @__PURE__ */ new Error(`No track accumulator for ${context}`));
407
- return firstValueFrom(accumulator.timedTrack).then((points) => window ? points.filter(({ timestamp }) => timestamp >= window.from && (window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to)) : points);
408
- }
409
- getAllTracks(query) {
410
- return Promise.all(Object.keys(this.tracks).map((context) => this.getTimed(context, query?.window).then((points) => ({
411
- context,
412
- track: thin(points, query?.resolution).map(({ position }) => position)
413
- }))));
414
- }
415
- async getFilteredTracks(params, selfPosition, debug, query) {
416
- const timed = await this.getFilteredTimedTracks(params, selfPosition, debug, query);
417
- return Object.fromEntries(Object.entries(timed).map(([context, points]) => [context, points.map(({ position }) => position)]));
418
- }
419
- /**
420
- * The same filtering as `getFilteredTracks`, keeping each point's timestamp.
421
- *
422
- * The untimed form is derived from this one so the two cannot disagree about
423
- * which tracks match — the filter runs once, here.
424
- */
425
- async getFilteredTimedTracks(params, selfPosition, debug, query) {
426
- this.debug(params);
427
- this.debug("Self position", selfPosition);
428
- const matcher = createMatcher(params, selfPosition, debug);
429
- const contexts = Object.keys(this.tracks);
430
- return (await Promise.all(contexts.map((context) => this.getTimed(context, query?.window).then((points) => ({
431
- context,
432
- points: thin(points, query?.resolution)
433
- }))))).reduce((acc, { context, points }) => {
434
- if (matcher(points.map(({ position }) => position))) acc[context] = points;
435
- return acc;
436
- }, {});
437
- }
438
- prune(maxAge) {
439
- const cutoff = Date.now() - maxAge;
440
- const deleted = [];
441
- Object.entries(this.tracks).forEach(([key, value]) => {
442
- if (value.latestLatLngTuple < cutoff) {
443
- delete this.tracks[key];
444
- deleted.push(key);
445
- }
446
- });
447
- if (this.debug.enabled) this.debug(`deleted tracks for ${deleted.join(", ")}`);
448
- }
449
- };
450
- var TrackAccumulator = class {
451
- initialTrack = new BehaviorSubject([]);
452
- input = new Subject();
453
- latestLatLngTuple = 0;
454
- accumulatedTrack;
455
- /** Timestamped points, used to answer time-window queries. */
456
- timedTrack;
457
- /** Positions only — the long-standing public shape. */
458
- track;
459
- constructor({ resolution, pointsToKeep, fetchTrackFor }) {
460
- this.accumulatedTrack = connectable(this.input.pipe(throttleTime(resolution), scan((acc, point) => {
461
- acc.push(point);
462
- return acc.slice(Math.max(0, acc.length - pointsToKeep));
463
- }, []), startWith([])), {
464
- connector: () => new ReplaySubject(1),
465
- resetOnDisconnect: false
466
- });
467
- this.timedTrack = combineLatest([this.initialTrack, this.accumulatedTrack]).pipe(map(([initialTrack, accumulatedTrack]) => [...initialTrack, ...accumulatedTrack]));
468
- this.track = this.timedTrack.pipe(map((points) => points.map(({ position }) => position)));
469
- this.accumulatedTrack.connect();
470
- if (fetchTrackFor) fetchTrack(fetchTrackFor).then((trackGEOJson) => {
471
- const coordinates = trackGEOJson?.coordinates?.[0];
472
- if (coordinates) this.initialTrack.next(coordinates.map((position) => ({
473
- position,
474
- timestamp: 0
475
- })));
476
- });
477
- }
478
- nextLatLngTuple(position, timestamp = Date.now()) {
479
- this.input.next({
480
- position,
481
- timestamp
482
- });
483
- this.latestLatLngTuple = timestamp;
481
+ var historyRowPosition = (row) => {
482
+ if (!Array.isArray(row) || row.length < 2) return;
483
+ const value = row[1];
484
+ if (Array.isArray(value) && value.length === 2 && Number.isFinite(value[0]) && Number.isFinite(value[1])) {
485
+ const [longitude, latitude] = value;
486
+ return [latitude, longitude];
484
487
  }
485
- setInitialTrack(track, timestamps) {
486
- this.initialTrack.next(track.map((position, i) => ({
487
- position,
488
- timestamp: timestamps?.[i] ?? 0
489
- })));
488
+ if (value && typeof value === "object" && "latitude" in value && "longitude" in value) {
489
+ const { latitude, longitude } = value;
490
+ if (Number.isFinite(latitude) && Number.isFinite(longitude)) return [latitude, longitude];
490
491
  }
491
492
  };
492
- var fetchTrack = (context) => {
493
- const contextParts = context.split(".");
494
- if (contextParts[0] !== "vessels") return Promise.resolve({});
495
- return fetch(`/signalk/v1/api/vessels/${contextParts[1]}/track`, { credentials: "include" }).then((r) => r.status === 200 ? r.json() : Promise.resolve({}));
496
- };
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
+ }
497
578
  //#endregion
498
579
  //#region src/trackProvider.ts
499
580
  /** v2 sends `[west, south, east, north]`; a bounds here is `[lat, lng]` corners. */
@@ -614,7 +695,10 @@ var longitudeSpan = (longitudes) => {
614
695
  function createTrackProvider(deps) {
615
696
  const matching = async (query) => {
616
697
  const store = deps.store();
617
- if (!store) return /* @__PURE__ */ new Map();
698
+ if (!store) return {
699
+ tracks: /* @__PURE__ */ new Map(),
700
+ resolution: void 0
701
+ };
618
702
  const window = toTimeWindow(query);
619
703
  const resolution = query.resolution ? totalMilliseconds(query.resolution) : void 0;
620
704
  const wanted = query.contexts?.length ? new Set(query.contexts.map(resolveSelf(deps.selfContext()))) : void 0;
@@ -627,19 +711,22 @@ function createTrackProvider(deps) {
627
711
  ...resolution === void 0 ? {} : { resolution }
628
712
  });
629
713
  const result = /* @__PURE__ */ new Map();
630
- for (const [context, points] of Object.entries(collection)) {
631
- if (wanted && !wanted.has(context)) continue;
714
+ const contexts = Object.entries(collection).filter(([context]) => !wanted || wanted.has(context));
715
+ const reconciled = await Promise.all(contexts.map(async ([context, stored]) => [context, await deps.reconcileWithHistory(context, stored, window, resolution)]));
716
+ for (const [context, points] of reconciled) {
632
717
  if (points.length === 0) continue;
633
718
  result.set(context, points);
634
719
  }
635
- return result;
720
+ return {
721
+ tracks: result,
722
+ resolution
723
+ };
636
724
  };
637
725
  return {
638
726
  async getTracks(query) {
639
- const matched = await matching(query);
727
+ const { tracks: matched, resolution: requested } = await matching(query);
640
728
  const gap = deps.segmentGap();
641
729
  const selfContext = deps.selfContext();
642
- const requested = query.resolution ? totalMilliseconds(query.resolution) : void 0;
643
730
  const features = [];
644
731
  for (const [context, all] of matched) {
645
732
  const { points, resolution: appliedMs } = query.maxPoints === void 0 ? {
@@ -647,8 +734,11 @@ function createTrackProvider(deps) {
647
734
  resolution: requested
648
735
  } : thinToBudget(all, query.maxPoints, requested);
649
736
  if (points.length === 0) continue;
650
- const segments = segment(points, gap);
651
- 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);
652
742
  features.push({
653
743
  type: "Feature",
654
744
  geometry: query.geometry === false ? null : {
@@ -658,11 +748,13 @@ function createTrackProvider(deps) {
658
748
  properties: {
659
749
  context,
660
750
  isSelf: context === selfContext,
661
- from: new Date(points[0].timestamp).toISOString(),
662
- 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(),
663
754
  ...bbox ? { bbox } : {},
664
- pointCount: points.length,
755
+ pointCount: shaped.length,
665
756
  ...appliedMs === void 0 ? {} : { resolution: msToDuration(appliedMs).toString() },
757
+ ...chosenEpsilon === void 0 ? {} : { epsilon: chosenEpsilon },
666
758
  ...query.times ? { coordTimes: segments.map(toIsoTimes) } : {}
667
759
  }
668
760
  });
@@ -673,10 +765,71 @@ function createTrackProvider(deps) {
673
765
  };
674
766
  },
675
767
  async getTrackContexts(query) {
676
- return [...(await matching(query)).keys()];
768
+ return [...(await matching(query)).tracks.keys()];
677
769
  }
678
770
  };
679
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
+ }
680
833
  /** v2 accepts the `self` alias; the store keys on the qualified context. */
681
834
  var resolveSelf = (selfContext) => (context) => context === "self" || context === "vessels.self" ? selfContext : context;
682
835
  //#endregion
@@ -12058,16 +12211,20 @@ var SqliteTrackStore = class {
12058
12211
  }))));
12059
12212
  }
12060
12213
  /**
12061
- * Tracks whose last position matches the spatial predicate.
12214
+ * Tracks matching the spatial predicate, which depends on who is asking.
12215
+ *
12216
+ * `intersects: false` — the v1 routes — matches a track's *last* position:
12217
+ * "which vessels are near me now". `intersects: true` — the v2 Track API —
12218
+ * matches a track that passed through the box at any point in the window,
12219
+ * including one that has since left.
12062
12220
  *
12063
12221
  * Two filters run, and they are not redundant despite both testing bounds:
12064
12222
  *
12065
12223
  * - `contextsInBounds` asks the cell index which contexts have *any* position
12066
12224
  * near the box. It is a cheap prefilter that keeps the query off every row
12067
12225
  * in the table, and it decides nothing on its own.
12068
- * - `matcher` — the same predicate the in-memory store uses — then tests each
12069
- * track's *last* position, which is what the endpoint actually means by
12070
- * "in this box". That test is authoritative.
12226
+ * - `matcher` — the same predicate the in-memory store uses — then applies
12227
+ * whichever rule the caller asked for. That test is authoritative.
12071
12228
  *
12072
12229
  * Because `matcher` has the final say, dropping the exact-bounds check inside
12073
12230
  * `contextsInBounds` does not change any result; it only widens the candidate
@@ -12131,10 +12288,16 @@ var SqliteTrackStore = class {
12131
12288
  if (current.length > 0) result.push(current);
12132
12289
  return result;
12133
12290
  }
12134
- prune(maxAge) {
12291
+ prune(maxAge, keep) {
12135
12292
  const cutoff = Date.now() - maxAge;
12136
- this.db.prepare("DELETE FROM positions WHERE context IN (SELECT context FROM positions GROUP BY context HAVING MAX(timestamp) < ?)").run(cutoff);
12137
- if (this.retention > 0) this.db.prepare("DELETE FROM positions WHERE timestamp < ?").run(Date.now() - this.retention);
12293
+ const dropped = this.db.prepare("SELECT context FROM positions GROUP BY context HAVING MAX(timestamp) < ? AND context IS NOT ?").all(cutoff, keep ?? null);
12294
+ this.db.prepare("DELETE FROM positions WHERE context IN (SELECT context FROM positions GROUP BY context HAVING MAX(timestamp) < ?) AND context IS NOT ?").run(cutoff, keep ?? null);
12295
+ for (const { context } of dropped) this.lastStored.delete(context);
12296
+ if (this.retention > 0) {
12297
+ const oldest = Date.now() - this.retention;
12298
+ if (keep === void 0) this.db.prepare("DELETE FROM positions WHERE timestamp < ?").run(oldest);
12299
+ else this.db.prepare("DELETE FROM positions WHERE timestamp < ? AND context IS ?").run(oldest, keep);
12300
+ }
12138
12301
  }
12139
12302
  close() {
12140
12303
  this.db.close();
@@ -12368,11 +12531,11 @@ var SourceWatch = class {
12368
12531
  };
12369
12532
  //#endregion
12370
12533
  //#region src/stateGate.ts
12371
- /**
12372
- * Off unless a user opts in, per the request and because the states worth
12373
- * pausing on depend on how a boat is used.
12374
- */
12375
- var DEFAULT_PAUSE_STATES = [];
12534
+ var DEFAULT_PAUSE_STATES = [
12535
+ "moored",
12536
+ "not-under-way",
12537
+ "aground"
12538
+ ];
12376
12539
  /**
12377
12540
  * The states a boat is plausibly parked in, offered in the plugin UI.
12378
12541
  *
@@ -12437,87 +12600,515 @@ var StateGate = class {
12437
12600
  }
12438
12601
  };
12439
12602
  //#endregion
12440
- //#region src/index.ts
12603
+ //#region src/gpx.ts
12441
12604
  /**
12442
- * Resolve the effective source, honouring the setting this replaced.
12605
+ * GPX 1.1 serialisation and parsing for recorded tracks.
12443
12606
  *
12444
- * `bootstrapFromHistory` shipped as a boolean defaulting to true, so an
12445
- * installed plugin has it saved in its config either way. Reading `source`
12446
- * first and only then falling back keeps those installs working: an explicit
12447
- * `false` still means "do not touch the History API", and everything else
12448
- * lands on the old default of `history`.
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.
12449
12612
  */
12450
- var resolveSource = (config) => {
12451
- if (config.source) return config.source;
12452
- return config.bootstrapFromHistory === false ? "memory" : "history";
12453
- };
12454
- var toLngLat = ([lat, lng]) => [lng, lat];
12455
- var DEFAULT_RESOLUTION = 6e4;
12456
- var DEFAULT_POINTS_TO_KEEP = 120;
12457
- var DEFAULT_MAX_AGE = 600;
12458
- var DEFAULT_MAX_RADIUS = 5e4;
12459
- var DEFAULT_SEGMENT_GAP_MINUTES = 0;
12460
- var SOURCE_STATUS_INTERVAL_MS = 3e4;
12613
+ var LAT = 0;
12614
+ var LNG = 1;
12461
12615
  /**
12462
- * How long a history provider gets to answer a query before the store answers
12463
- * alone.
12464
- *
12465
- * The provider is an enrichment, not a dependency: a track is still correct
12466
- * without it, just coarser. Long enough for a database that has to warm up,
12467
- * short enough that a wedged provider does not hold a request open.
12468
- */
12469
- var HISTORY_QUERY_TIMEOUT_MS = 5e3;
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
+ }
12470
12725
  /**
12471
- * How far back a query with no window of its own reaches when the store is
12472
- * empty.
12726
+ * The text of a direct GPX child with this local name.
12473
12727
  *
12474
- * There is nothing else to derive a span from, and asking a provider for all
12475
- * of time would be expensive on a large one. A day covers the usual reason for
12476
- * a windowless request — draw the recent trail — and an explicit `from`,
12477
- * `duration` or `timespan` reaches further.
12728
+ * Direct rather than `getElementsByTagName`, which would reach a `<name>`
12729
+ * inside an `<extensions>` payload and label the track with a vendor's string.
12478
12730
  */
12479
- var WINDOWLESS_HISTORY_SPAN_MS = 864e5;
12480
- var BOOTSTRAP_INITIAL_DELAY = 5e3;
12481
- var BOOTSTRAP_RETRY_DELAY = 15e3;
12482
- var BOOTSTRAP_MAX_ATTEMPTS = 18;
12483
- var BOOTSTRAP_MAX_NO_PROVIDER = 3;
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
+ }
12484
12735
  /**
12485
- * Config values arrive from the plugin UI as numbers, but a hand-edited
12486
- * settings file can supply strings. Accept both, reject anything non-finite so
12487
- * a bad value falls back to the default instead of poisoning arithmetic with NaN.
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.
12488
12744
  */
12489
- var toNumber = (value) => {
12490
- if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
12491
- if (typeof value === "string" && value.trim() !== "") {
12492
- const parsed = Number(value);
12493
- return Number.isFinite(parsed) ? parsed : void 0;
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
+ }
12494
12752
  }
12495
- };
12496
- var notAvailable = (res) => {
12497
- res.status(404);
12498
- res.json({ message: "Tracks API not available because tracks plugin is not enabled" });
12499
- };
12500
- var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12501
- var errorDetail = (err) => err instanceof Error && err.stack ? err.stack : String(err);
12502
- var isNoProviderError = (err) => {
12503
- const text = String(err);
12504
- return text.includes("No history") && text.includes("provider");
12505
- };
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
+ }
12506
12770
  /**
12507
- * A history row is `[timestamp, position]`. Providers disagree on how the
12508
- * position is encoded: signalk-questdb returns `{latitude, longitude}`, while
12509
- * a `[lon, lat]` pair is the shape the History API's own docs describe. Accept
12510
- * both — rejecting either silently bootstraps an empty track.
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.
12511
12778
  */
12512
- var historyRowPosition = (row) => {
12513
- if (!Array.isArray(row) || row.length < 2) return;
12514
- const value = row[1];
12515
- if (Array.isArray(value) && value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number") return [value[1], value[0]];
12516
- if (value && typeof value === "object" && "latitude" in value && "longitude" in value) {
12517
- const { latitude, longitude } = value;
12518
- if (typeof latitude === "number" && typeof longitude === "number") return [latitude, longitude];
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
12882
+ //#region src/tracks.ts
12883
+ /**
12884
+ * The in-memory store: a bounded sliding window of positions per context, held
12885
+ * in RxJS accumulators and lost on restart.
12886
+ *
12887
+ * No longer used by the plugin, which always records to SQLite. It remains as
12888
+ * the client-side `TrackAccumulator` this package exports, where a browser
12889
+ * holding one vessel's recent track in memory is exactly the right shape.
12890
+ */
12891
+ var Tracks = class {
12892
+ tracks = {};
12893
+ debug;
12894
+ config;
12895
+ constructor(config, debug) {
12896
+ if (debug.enabled) debug(JSON.stringify(config));
12897
+ this.config = config;
12898
+ this.debug = debug;
12899
+ }
12900
+ newPosition(context, position, timestamp) {
12901
+ this.getAccumulator(context)?.nextLatLngTuple(position, timestamp);
12902
+ }
12903
+ initialTrack(context, track, timestamps) {
12904
+ this.getAccumulator(context)?.setInitialTrack(track, timestamps);
12905
+ }
12906
+ getAccumulator(context, createIfMissing = true) {
12907
+ if (context.indexOf("vessels.") === -1 && context.indexOf("aircraft.") === -1) return;
12908
+ let result = this.tracks[context];
12909
+ if (!result && createIfMissing) {
12910
+ const accParams = this.config.fetchInitialTrack ? {
12911
+ ...this.config,
12912
+ fetchTrackFor: context
12913
+ } : { ...this.config };
12914
+ result = this.tracks[context] = new TrackAccumulator(accParams);
12915
+ }
12916
+ return result;
12917
+ }
12918
+ get(context, window) {
12919
+ return this.getTimed(context, window).then((points) => points.map(({ position }) => position));
12920
+ }
12921
+ /**
12922
+ * Track points with their timestamps, optionally narrowed to a time window.
12923
+ *
12924
+ * Rejects with EmptyError if the stream completes without emitting; the route
12925
+ * handlers turn that into the same 404 as an unknown context.
12926
+ */
12927
+ getTimed(context, window) {
12928
+ const accumulator = this.getAccumulator(context, false);
12929
+ if (!accumulator) return Promise.reject(/* @__PURE__ */ new Error(`No track accumulator for ${context}`));
12930
+ return firstValueFrom(accumulator.timedTrack).then((points) => window ? points.filter(({ timestamp }) => timestamp >= window.from && (window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to)) : points);
12931
+ }
12932
+ getAllTracks(query) {
12933
+ return Promise.all(Object.keys(this.tracks).map((context) => this.getTimed(context, query?.window).then((points) => ({
12934
+ context,
12935
+ track: thin(points, query?.resolution).map(({ position }) => position)
12936
+ }))));
12937
+ }
12938
+ async getFilteredTracks(params, selfPosition, debug, query) {
12939
+ const timed = await this.getFilteredTimedTracks(params, selfPosition, debug, query);
12940
+ return Object.fromEntries(Object.entries(timed).map(([context, points]) => [context, points.map(({ position }) => position)]));
12941
+ }
12942
+ /**
12943
+ * The same filtering as `getFilteredTracks`, keeping each point's timestamp.
12944
+ *
12945
+ * The untimed form is derived from this one so the two cannot disagree about
12946
+ * which tracks match — the filter runs once, here.
12947
+ */
12948
+ async getFilteredTimedTracks(params, selfPosition, debug, query) {
12949
+ this.debug(params);
12950
+ this.debug("Self position", selfPosition);
12951
+ const matcher = createMatcher(params, selfPosition, debug);
12952
+ const contexts = Object.keys(this.tracks);
12953
+ return (await Promise.all(contexts.map((context) => this.getTimed(context, query?.window).then((points) => ({
12954
+ context,
12955
+ points: thin(points, query?.resolution)
12956
+ }))))).reduce((acc, { context, points }) => {
12957
+ if (matcher(points.map(({ position }) => position))) acc[context] = points;
12958
+ return acc;
12959
+ }, {});
12960
+ }
12961
+ prune(maxAge, keep) {
12962
+ const cutoff = Date.now() - maxAge;
12963
+ const deleted = [];
12964
+ Object.entries(this.tracks).forEach(([key, value]) => {
12965
+ if (key !== keep && value.latestLatLngTuple < cutoff) {
12966
+ delete this.tracks[key];
12967
+ deleted.push(key);
12968
+ }
12969
+ });
12970
+ if (this.debug.enabled) this.debug(`deleted tracks for ${deleted.join(", ")}`);
12971
+ }
12972
+ };
12973
+ var TrackAccumulator = class {
12974
+ initialTrack = new BehaviorSubject([]);
12975
+ input = new Subject();
12976
+ latestLatLngTuple = 0;
12977
+ accumulatedTrack;
12978
+ /** Timestamped points, used to answer time-window queries. */
12979
+ timedTrack;
12980
+ /** Positions only — the long-standing public shape. */
12981
+ track;
12982
+ constructor({ resolution, pointsToKeep, fetchTrackFor }) {
12983
+ this.accumulatedTrack = connectable(this.input.pipe(throttleTime(resolution), scan((acc, point) => {
12984
+ acc.push(point);
12985
+ return acc.slice(Math.max(0, acc.length - pointsToKeep));
12986
+ }, []), startWith([])), {
12987
+ connector: () => new ReplaySubject(1),
12988
+ resetOnDisconnect: false
12989
+ });
12990
+ this.timedTrack = combineLatest([this.initialTrack, this.accumulatedTrack]).pipe(map(([initialTrack, accumulatedTrack]) => [...initialTrack, ...accumulatedTrack]));
12991
+ this.track = this.timedTrack.pipe(map((points) => points.map(({ position }) => position)));
12992
+ this.accumulatedTrack.connect();
12993
+ if (fetchTrackFor) fetchTrack(fetchTrackFor).then((trackGEOJson) => {
12994
+ const coordinates = trackGEOJson?.coordinates?.[0];
12995
+ if (coordinates) this.initialTrack.next(coordinates.map((position) => ({
12996
+ position,
12997
+ timestamp: 0
12998
+ })));
12999
+ });
13000
+ }
13001
+ nextLatLngTuple(position, timestamp = Date.now()) {
13002
+ this.input.next({
13003
+ position,
13004
+ timestamp
13005
+ });
13006
+ this.latestLatLngTuple = timestamp;
13007
+ }
13008
+ setInitialTrack(track, timestamps) {
13009
+ this.initialTrack.next(track.map((position, i) => ({
13010
+ position,
13011
+ timestamp: timestamps?.[i] ?? 0
13012
+ })));
13013
+ }
13014
+ };
13015
+ var fetchTrack = (context) => {
13016
+ const contextParts = context.split(".");
13017
+ if (contextParts[0] !== "vessels") return Promise.resolve({});
13018
+ return fetch(`/signalk/v1/api/vessels/${contextParts[1]}/track`, { credentials: "include" }).then((r) => r.status === 200 ? r.json() : Promise.resolve({}));
13019
+ };
13020
+ //#endregion
13021
+ //#region src/index.ts
13022
+ var toLngLat = ([lat, lng]) => [lng, lat];
13023
+ var DEFAULT_RESOLUTION = 6e4;
13024
+ var DEFAULT_AIS_RETENTION_DAYS = 30;
13025
+ var PRUNE_INTERVAL_MS = 36e5;
13026
+ var DEFAULT_SEGMENT_GAP_MINUTES = 0;
13027
+ var SOURCE_STATUS_INTERVAL_MS = 3e4;
13028
+ /**
13029
+ * How long a history provider gets to answer a query before the store answers
13030
+ * alone.
13031
+ *
13032
+ * The provider is an enrichment, not a dependency: a track is still correct
13033
+ * without it, just coarser. Long enough for a database that has to warm up,
13034
+ * short enough that a wedged provider does not hold a request open.
13035
+ */
13036
+ var HISTORY_QUERY_TIMEOUT_MS = 5e3;
13037
+ /**
13038
+ * How far back a query with no window of its own reaches when the store is
13039
+ * empty.
13040
+ *
13041
+ * There is nothing else to derive a span from, and asking a provider for all
13042
+ * of time would be expensive on a large one. A day covers the usual reason for
13043
+ * a windowless request — draw the recent trail — and an explicit `from`,
13044
+ * `duration` or `timespan` reaches further.
13045
+ */
13046
+ var WINDOWLESS_HISTORY_SPAN_MS = 864e5;
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
+ /**
13087
+ * Config values arrive from the plugin UI as numbers, but a hand-edited
13088
+ * settings file can supply strings. Accept both, reject anything non-finite so
13089
+ * a bad value falls back to the default instead of poisoning arithmetic with NaN.
13090
+ */
13091
+ var toNumber = (value) => {
13092
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
13093
+ if (typeof value === "string" && value.trim() !== "") {
13094
+ const parsed = Number(value);
13095
+ return Number.isFinite(parsed) ? parsed : void 0;
12519
13096
  }
12520
13097
  };
13098
+ var notAvailable = (res) => {
13099
+ res.status(404);
13100
+ res.json({ message: "Tracks API not available because tracks plugin is not enabled" });
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 {};
13111
+ var errorDetail = (err) => err instanceof Error && err.stack ? err.stack : String(err);
12521
13112
  /**
12522
13113
  * A window covering everything the store holds, up to now.
12523
13114
  *
@@ -12547,9 +13138,15 @@ function windowSpanning(stored, fallbackMs) {
12547
13138
  * promise wins. A race leaves it armed, holding the event loop open for the
12548
13139
  * remainder of the timeout on every call that succeeds.
12549
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 {};
12550
13147
  function withTimeout(promise, ms) {
12551
13148
  return new Promise((resolve, reject) => {
12552
- 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);
12553
13150
  promise.then((value) => {
12554
13151
  clearTimeout(timer);
12555
13152
  resolve(value);
@@ -12560,6 +13157,48 @@ function withTimeout(promise, ms) {
12560
13157
  });
12561
13158
  }
12562
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
+ /**
12563
13202
  * Positions a history provider holds for a window, or none.
12564
13203
  *
12565
13204
  * Best-effort by design: a provider that is absent, slow, or failing must not
@@ -12572,10 +13211,23 @@ async function historyPositions(app, context, window, resolutionMs, debug) {
12572
13211
  const getHistoryApi = app.getHistoryApi;
12573
13212
  if (!getHistoryApi) return {
12574
13213
  points: [],
12575
- resolutionMs: applied
13214
+ resolutionMs: applied,
13215
+ failed: false
12576
13216
  };
13217
+ let historyApi;
12577
13218
  try {
12578
- const response = await withTimeout((await withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider), HISTORY_QUERY_TIMEOUT_MS)).getValues({
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
+ }
13229
+ try {
13230
+ const response = await withTimeout(historyApi.getValues({
12579
13231
  context,
12580
13232
  from: Temporal.Instant.from(new Date(window.from).toISOString()),
12581
13233
  to: Temporal.Instant.from(new Date(window.to).toISOString()),
@@ -12600,13 +13252,15 @@ async function historyPositions(app, context, window, resolutionMs, debug) {
12600
13252
  if (debug.enabled) debug(`History supplied ${points.length} position(s) for ${context}`);
12601
13253
  return {
12602
13254
  points,
12603
- resolutionMs: applied
13255
+ resolutionMs: applied,
13256
+ failed: false
12604
13257
  };
12605
13258
  } catch (err) {
12606
13259
  if (debug.enabled) debug(`History unavailable for ${context}: ${errorDetail(err)}`);
12607
13260
  return {
12608
13261
  points: [],
12609
- resolutionMs: applied
13262
+ resolutionMs: applied,
13263
+ failed: true
12610
13264
  };
12611
13265
  }
12612
13266
  }
@@ -12617,92 +13271,6 @@ var historyRowTimestamp = (row) => {
12617
13271
  const parsed = Date.parse(raw);
12618
13272
  return Number.isNaN(parsed) ? 0 : parsed;
12619
13273
  };
12620
- async function bootstrapSelfTrack(app, tracks, config, glitchFilter) {
12621
- const { debug } = app;
12622
- const getHistoryApi = app.getHistoryApi;
12623
- if (!getHistoryApi) {
12624
- debug("getHistoryApi not available on server, skipping track bootstrap");
12625
- return;
12626
- }
12627
- if (!app.selfContext) {
12628
- debug("selfContext not available, skipping track bootstrap");
12629
- return;
12630
- }
12631
- const configuredProvider = app.config?.settings?.historyApi?.defaultProvider;
12632
- const resolution = toNumber(config.resolution) ?? DEFAULT_RESOLUTION;
12633
- const timespanMs = resolution * (toNumber(config.pointsToKeep) ?? DEFAULT_POINTS_TO_KEEP);
12634
- const resolutionSecs = Math.max(1, Math.round(resolution / 1e3));
12635
- const timespanMinutes = Math.round(timespanMs / 1e3 / 60);
12636
- debug(`Track bootstrap: requesting ${timespanMinutes} minutes of history at ${resolutionSecs}s resolution (max ${BOOTSTRAP_MAX_ATTEMPTS} attempts)`);
12637
- let noProviderCount = 0;
12638
- for (let attempt = 1; attempt <= BOOTSTRAP_MAX_ATTEMPTS; attempt++) {
12639
- const delay = attempt === 1 ? BOOTSTRAP_INITIAL_DELAY : BOOTSTRAP_RETRY_DELAY;
12640
- debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}, waiting ${delay / 1e3}s...`);
12641
- await sleep(delay);
12642
- try {
12643
- const historyApi = await getHistoryApi(configuredProvider);
12644
- noProviderCount = 0;
12645
- const to = /* @__PURE__ */ new Date();
12646
- const from = new Date(to.getTime() - timespanMs);
12647
- const response = await historyApi.getValues({
12648
- context: app.selfContext,
12649
- from: Temporal.Instant.from(from.toISOString()),
12650
- to: Temporal.Instant.from(to.toISOString()),
12651
- pathSpecs: [{
12652
- path: "navigation.position",
12653
- aggregate: "first"
12654
- }],
12655
- resolution: resolutionSecs
12656
- });
12657
- if (response?.data && response.data.length > 0) {
12658
- const positions = [];
12659
- const timestamps = [];
12660
- let dropped = 0;
12661
- for (const row of response.data) {
12662
- const position = historyRowPosition(row);
12663
- if (position) {
12664
- const timestamp = historyRowTimestamp(row);
12665
- if (timestamp === 0) {
12666
- dropped++;
12667
- continue;
12668
- }
12669
- if (!glitchFilter.accept(app.selfContext, position, timestamp)) {
12670
- dropped++;
12671
- continue;
12672
- }
12673
- positions.push(position);
12674
- timestamps.push(timestamp);
12675
- }
12676
- }
12677
- if (dropped > 0) debug(`Track bootstrap discarded ${dropped} position(s) as glitches`);
12678
- if (positions.length > 0) {
12679
- tracks.initialTrack(app.selfContext, positions, timestamps);
12680
- debug(`Track bootstrap complete: loaded ${positions.length} positions for self (${timespanMinutes} min window) on attempt ${attempt}`);
12681
- return;
12682
- }
12683
- }
12684
- debug(`History API returned no position data on attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}; the configured provider may not have registered yet`);
12685
- if (attempt === BOOTSTRAP_MAX_ATTEMPTS) {
12686
- debug("History API returned no position data for bootstrap");
12687
- return;
12688
- }
12689
- continue;
12690
- } catch (err) {
12691
- if (isNoProviderError(err)) {
12692
- noProviderCount++;
12693
- debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}: no history provider registered yet (${noProviderCount}/${BOOTSTRAP_MAX_NO_PROVIDER})`);
12694
- if (noProviderCount >= BOOTSTRAP_MAX_NO_PROVIDER) {
12695
- debug(`No history provider registered after ${BOOTSTRAP_MAX_NO_PROVIDER} consecutive checks — no provider plugin appears to be installed. Giving up.`);
12696
- return;
12697
- }
12698
- } else {
12699
- noProviderCount = 0;
12700
- debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS} failed: ${errorDetail(err)}`);
12701
- }
12702
- if (attempt === BOOTSTRAP_MAX_ATTEMPTS) app.error(`Track bootstrap from History API failed after ${BOOTSTRAP_MAX_ATTEMPTS} attempts. Tracks will start empty and accumulate from live data.`);
12703
- }
12704
- }
12705
- }
12706
13274
  function ThePlugin(app) {
12707
13275
  let onStop = [];
12708
13276
  let tracks = void 0;
@@ -12715,10 +13283,16 @@ function ThePlugin(app) {
12715
13283
  * own.
12716
13284
  */
12717
13285
  let storeResolution = DEFAULT_RESOLUTION;
12718
- let defaultMaxRadius = void 0;
12719
13286
  const sourceWatch = new SourceWatch();
12720
13287
  let glitchFilter = new GlitchFilter({ maxSpeedKnots: 100 });
12721
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 };
12722
13296
  /** The own vessel's navigation.state, or undefined when it reports none. */
12723
13297
  function getVesselState() {
12724
13298
  const s = app.getSelfPath("navigation.state");
@@ -12736,25 +13310,28 @@ function ThePlugin(app) {
12736
13310
  }
12737
13311
  return {
12738
13312
  start: function(config) {
12739
- const { resolution, pointsToKeep, maxAge, maxRadius } = config;
12740
- defaultMaxRadius = toNumber(maxRadius);
12741
- const source = resolveSource(config);
13313
+ const { resolution } = config;
12742
13314
  storeResolution = toNumber(config.resolution) ?? DEFAULT_RESOLUTION;
12743
13315
  const segmentGapMinutes = toNumber(config.segmentGapMinutes) ?? DEFAULT_SEGMENT_GAP_MINUTES;
12744
13316
  segmentGap = segmentGapMinutes > 0 ? segmentGapMinutes * 60 * 1e3 : 0;
12745
13317
  glitchFilter = new GlitchFilter({ maxSpeedKnots: toNumber(config.maxSpeedKnots) ?? 100 });
12746
13318
  stateGate = new StateGate(app.selfContext, Array.isArray(config.pauseWhenState) ? config.pauseWhenState : DEFAULT_PAUSE_STATES);
12747
- const dataDir = source === "sqlite" ? app.getDataDirPath?.() : void 0;
12748
- if (source === "sqlite" && !dataDir) app.error("source is sqlite but this server provides no plugin data directory; using memory instead");
12749
- tracks = dataDir ? new SqliteTrackStore({
12750
- file: join(dataDir, "tracks.db"),
12751
- resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,
12752
- retention: (toNumber(config.retentionDays) ?? 0) * 24 * 60 * 60 * 1e3,
12753
- segmentGap
12754
- }, app.debug) : new Tracks({
12755
- resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,
12756
- pointsToKeep: toNumber(pointsToKeep) ?? DEFAULT_POINTS_TO_KEEP
12757
- }, app.debug);
13319
+ const dataDir = app.getDataDirPath?.();
13320
+ if (!dataDir) {
13321
+ app.error("This server provides no plugin data directory, so tracks cannot be recorded.");
13322
+ return;
13323
+ }
13324
+ try {
13325
+ tracks = new SqliteTrackStore({
13326
+ file: join(dataDir, "tracks.db"),
13327
+ resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,
13328
+ retention: (toNumber(config.retentionDays) ?? 0) * 24 * 60 * 60 * 1e3,
13329
+ segmentGap
13330
+ }, app.debug);
13331
+ } catch (err) {
13332
+ app.error(`Could not open the track database in ${dataDir}: ${errorDetail(err)}`);
13333
+ return;
13334
+ }
12758
13335
  onStop.push(app.streambundle.getBus("navigation.position").onValue((update) => {
12759
13336
  if (!update.value || update.value.latitude == null || update.value.longitude == null) return;
12760
13337
  sourceWatch.add(update.context, update.$source);
@@ -12765,8 +13342,10 @@ function ThePlugin(app) {
12765
13342
  if (!glitchFilter.accept(update.context, position, timestamp ?? Date.now())) return;
12766
13343
  tracks?.newPosition(update.context, position, timestamp);
12767
13344
  }));
12768
- const theMaxAge = toNumber(maxAge) ?? DEFAULT_MAX_AGE;
12769
- const pruneInterval = setInterval(() => tracks?.prune(theMaxAge * 1e3), theMaxAge * 1e3 / 2);
13345
+ const aisRetentionMs = (toNumber(config.aisRetentionDays) ?? DEFAULT_AIS_RETENTION_DAYS) * 24 * 60 * 60 * 1e3;
13346
+ const pruneInterval = setInterval(() => {
13347
+ tracks?.prune(aisRetentionMs > 0 ? aisRetentionMs : Infinity, app.selfContext);
13348
+ }, PRUNE_INTERVAL_MS);
12770
13349
  onStop.push(() => {
12771
13350
  clearInterval(pruneInterval);
12772
13351
  });
@@ -12784,11 +13363,14 @@ function ThePlugin(app) {
12784
13363
  app.registerTrackApiProvider?.(createTrackProvider({
12785
13364
  store: () => tracks,
12786
13365
  selfContext: () => app.selfContext,
12787
- segmentGap: () => segmentGap
13366
+ segmentGap: () => segmentGap,
13367
+ contextName: (context) => contextName(context, (path) => app.getPath?.(path)),
13368
+ reconcileWithHistory: async (context, stored, window, resolutionMs) => {
13369
+ const effective = resolutionMs ?? storeResolution;
13370
+ const history = await historyPositions(app, context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective, app.debug);
13371
+ return history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
13372
+ }
12788
13373
  }));
12789
- if (source === "history") bootstrapSelfTrack(app, tracks, config, glitchFilter).catch((err) => {
12790
- app.error(`Unexpected error in track bootstrap: ${errorDetail(err)}`);
12791
- });
12792
13374
  },
12793
13375
  stop: function() {
12794
13376
  onStop.forEach((f) => {
@@ -12802,13 +13384,44 @@ function ThePlugin(app) {
12802
13384
  sourceWatch.clear();
12803
13385
  glitchFilter.clear();
12804
13386
  stateGate.clear();
13387
+ knownContexts.current = void 0;
12805
13388
  try {
12806
13389
  tracks?.close?.();
12807
13390
  } catch (err) {
12808
13391
  app.error(err);
13392
+ } finally {
13393
+ tracks = void 0;
12809
13394
  }
12810
13395
  },
12811
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
+ };
12812
13425
  const singleTrackHandler = (contextOf) => (req, res) => {
12813
13426
  if (!tracks) {
12814
13427
  notAvailable(res);
@@ -12823,37 +13436,89 @@ function ThePlugin(app) {
12823
13436
  res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
12824
13437
  return;
12825
13438
  }
12826
- const effectiveResolution = query.resolution ?? storeResolution;
12827
- tracks.getTimed(context, query.window).then((points) => ({
12828
- points,
12829
- known: true
12830
- })).catch(() => ({
12831
- points: [],
12832
- known: false
12833
- })).then(async ({ points: stored, known }) => {
12834
- const window = query.window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS);
12835
- const history = await historyPositions(app, context, window, effectiveResolution, app.debug);
12836
- const points = history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
12837
- if (points.length === 0 && !known) {
13439
+ readSegments(context, query).then((segments) => {
13440
+ if (segments === void 0) {
12838
13441
  res.status(404);
12839
13442
  res.json({ message: `No track available for ${context}` });
12840
13443
  return;
12841
13444
  }
12842
- const segments = segment(thin(points, query.resolution), segmentGap);
12843
13445
  res.json({
12844
13446
  type: "MultiLineString",
12845
13447
  coordinates: segments.map((points) => points.map(({ position }) => toLngLat(position))),
12846
13448
  ...query.times ? { times: segments.map(toIsoTimes) } : {},
12847
13449
  context,
12848
- isSelf: context === app.selfContext
13450
+ isSelf: context === app.selfContext,
13451
+ name: nameOf(context)
12849
13452
  });
12850
- }).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
+ }
12851
13460
  res.status(404);
12852
13461
  res.json({ message: `No track available for ${context}` });
12853
13462
  });
12854
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
+ };
12855
13518
  const trackHandler = singleTrackHandler((req) => resolveContext(String(req.params.vesselId), app.selfContext));
12856
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));
12857
13522
  router.get("/self/track", singleTrackHandler(() => app.selfContext));
12858
13523
  const allTracksHandler = (req, res) => {
12859
13524
  app.debug(req.query);
@@ -12869,7 +13534,7 @@ function ThePlugin(app) {
12869
13534
  res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
12870
13535
  return;
12871
13536
  }
12872
- const params = validateParameters(req.query, defaultMaxRadius);
13537
+ const params = validateParameters(req.query, void 0);
12873
13538
  const selfPosition = getVesselPosition();
12874
13539
  (query.times ? tracks.getFilteredTimedTracks(params, selfPosition, app.debug, query).then((tc) => Object.entries(tc).reduce((acc, [context, points]) => {
12875
13540
  const segments = segment(points, segmentGap);
@@ -12877,14 +13542,16 @@ function ThePlugin(app) {
12877
13542
  type: "MultiLineString",
12878
13543
  coordinates: segments.map((s) => s.map(({ position }) => toLngLat(position))),
12879
13544
  times: segments.map(toIsoTimes),
12880
- isSelf: context === app.selfContext
13545
+ isSelf: context === app.selfContext,
13546
+ name: nameOf(context)
12881
13547
  };
12882
13548
  return acc;
12883
13549
  }, {})) : tracks.getFilteredTracks(params, selfPosition, app.debug, query).then((tc) => Object.entries(tc).reduce((acc, [context, track]) => {
12884
13550
  acc[context] = {
12885
13551
  type: "MultiLineString",
12886
13552
  coordinates: [track.map(toLngLat)],
12887
- isSelf: context === app.selfContext
13553
+ isSelf: context === app.selfContext,
13554
+ name: nameOf(context)
12888
13555
  };
12889
13556
  return acc;
12890
13557
  }, {}))).then((trks) => {
@@ -12901,55 +13568,31 @@ function ThePlugin(app) {
12901
13568
  getTracks: () => tracks,
12902
13569
  id: "tracks",
12903
13570
  name: "Tracks",
12904
- description: "Accumulate tracks in memory for the track API implementation",
13571
+ enabledByDefault: true,
13572
+ description: "Record vessel tracks to SQLite and serve them through the track API",
12905
13573
  schema: {
12906
13574
  type: "object",
12907
13575
  properties: {
12908
13576
  resolution: {
12909
13577
  type: "integer",
13578
+ minimum: 0,
12910
13579
  title: "Track resolution (milliseconds)",
12911
13580
  default: DEFAULT_RESOLUTION
12912
13581
  },
12913
- pointsToKeep: {
12914
- type: "integer",
12915
- title: "Points to keep",
12916
- description: "How many trackpoints to keep for each track",
12917
- default: DEFAULT_POINTS_TO_KEEP
12918
- },
12919
- maxAge: {
12920
- type: "integer",
12921
- title: "Maximum idle time (seconds)",
12922
- description: "Tracks with no updates longer than this are removed",
12923
- default: DEFAULT_MAX_AGE
12924
- },
12925
- maxRadius: {
12926
- type: "integer",
12927
- title: "Maximum Radius (meters) ",
12928
- description: "Include only vessels with position within this range. 0= all vessels",
12929
- default: DEFAULT_MAX_RADIUS
12930
- },
12931
- source: {
12932
- type: "string",
12933
- title: "Where tracks come from after a restart",
12934
- description: "In memory only: start empty and accumulate from live positions. History API: refill on startup from a history provider such as signalk-to-influxdb2 or signalk-questdb. SQLite: record positions to a database file so they survive a restart with no other plugin required.",
12935
- enum: [
12936
- "memory",
12937
- "history",
12938
- "sqlite"
12939
- ],
12940
- enumNames: [
12941
- "In memory only",
12942
- "Load from the History API on startup",
12943
- "Record to a SQLite database"
12944
- ],
12945
- default: "history"
12946
- },
12947
13582
  retentionDays: {
12948
13583
  type: "integer",
12949
- title: "Days of track history to keep (SQLite only)",
12950
- description: "Positions older than this are deleted. 0 keeps everything.",
13584
+ minimum: 0,
13585
+ title: "Days of the own vessel's track to keep",
13586
+ description: "Positions older than this are deleted. 0, the default, keeps everything — a track is worth more the longer it goes back, and a year of one vessel at the default resolution is a few megabytes.",
12951
13587
  default: 0
12952
13588
  },
13589
+ aisRetentionDays: {
13590
+ type: "integer",
13591
+ minimum: 0,
13592
+ title: "Days to keep another vessel after its last fix",
13593
+ description: "The own vessel is never removed. Other vessels are: a busy harbour puts hundreds of AIS targets past the receiver in a day, and keeping all of them forever is rarely what anyone wants. 0 keeps every vessel indefinitely.",
13594
+ default: DEFAULT_AIS_RETENTION_DAYS
13595
+ },
12953
13596
  segmentGapMinutes: {
12954
13597
  type: "integer",
12955
13598
  title: "Split a track after this many minutes without a fix",
@@ -12978,6 +13621,6 @@ function ThePlugin(app) {
12978
13621
  };
12979
13622
  }
12980
13623
  //#endregion
12981
- export { TrackAccumulator, Tracks, ThePlugin as default, resolveSource };
13624
+ export { TrackAccumulator, Tracks, ThePlugin as default, fromGpx, toGpx };
12982
13625
 
12983
13626
  //# sourceMappingURL=index.js.map