@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/README.md +8 -2
- package/dist/e2e.test-utils.d.ts +10 -0
- package/dist/gpx.d.ts +59 -0
- package/dist/gpx.test.d.ts +1 -0
- package/dist/harness.test-utils.d.ts +68 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.html +30 -0
- package/dist/index.js +783 -39
- package/dist/index.js.map +1 -1
- package/dist/simplify.d.ts +46 -0
- package/dist/simplify.test.d.ts +1 -0
- package/dist/style.css +49 -0
- package/dist/trackApi.d.ts +9 -11
- package/dist/trackProvider.d.ts +9 -0
- package/dist/tracks.js +192 -0
- package/dist/utils.d.ts +60 -0
- package/dist/webapp.e2e.test.d.ts +1 -0
- package/dist/webapp.test.d.ts +1 -0
- package/package.json +6 -2
- package/public/index.html +30 -0
- package/public/style.css +49 -0
- package/public/tracks.js +192 -0
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
|
|
541
|
-
const
|
|
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
|
-
|
|
552
|
-
|
|
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:
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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 `�` or `�` 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(
|
|
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(
|
|
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
|
-
|
|
12750
|
-
|
|
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
|