@signalk/tracks-plugin 3.0.0-beta.2 → 3.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +880 -51
- package/dist/index.js.map +1 -1
- package/dist/simplify.d.ts +46 -0
- package/dist/simplify.test.d.ts +1 -0
- package/dist/sourceWatch.d.ts +17 -28
- 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 -3
- 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
|
|
@@ -12220,26 +12483,108 @@ function reconcileByExtent(history, stored) {
|
|
|
12220
12483
|
* Detection is therefore empirical rather than reading the priority config:
|
|
12221
12484
|
* seeing several sources here *is* the evidence that no rule is matching,
|
|
12222
12485
|
* which a config that merely exists would not tell us.
|
|
12486
|
+
*
|
|
12487
|
+
* With one qualification. A priority rule has a failover window — when the
|
|
12488
|
+
* top-ranked source goes quiet the engine promotes the next one — so over a
|
|
12489
|
+
* long run several sources legitimately reach this bus even with a rule in
|
|
12490
|
+
* place, one at a time. Counting every source ever seen would therefore
|
|
12491
|
+
* report a conflict on a correctly configured boat whose GPS drops out
|
|
12492
|
+
* occasionally. What distinguishes competition is *alternation*: unfiltered
|
|
12493
|
+
* sources hand back and forth repeatedly within seconds, while a failover
|
|
12494
|
+
* switches once and stays.
|
|
12495
|
+
*/
|
|
12496
|
+
/**
|
|
12497
|
+
* How quickly the source must change again for it to count as alternation.
|
|
12498
|
+
*
|
|
12499
|
+
* Shorter than the server's 15s default failover, so a promotion — one
|
|
12500
|
+
* switch after a silence — never chains into a sequence. Positions are a
|
|
12501
|
+
* rapid update, often 10Hz, so genuinely competing sources alternate far
|
|
12502
|
+
* faster than this.
|
|
12503
|
+
*/
|
|
12504
|
+
var ALTERNATION_WINDOW_MS = 1e4;
|
|
12505
|
+
/**
|
|
12506
|
+
* How long a conflict is remembered after it was last observed.
|
|
12507
|
+
*
|
|
12508
|
+
* Longer than the 30s status tick, so a conflict seen just after one tick is
|
|
12509
|
+
* still reported at the next and does not flicker. Finite, so fixing the
|
|
12510
|
+
* priorities clears the warning on its own rather than needing a restart —
|
|
12511
|
+
* which is the whole complaint about a watcher that only ever accumulates.
|
|
12512
|
+
*/
|
|
12513
|
+
var CONFLICT_RETENTION_MS = 9e4;
|
|
12514
|
+
/**
|
|
12515
|
+
* How many source changes in a row before it counts as competition.
|
|
12516
|
+
*
|
|
12517
|
+
* One change is a failover, two is a failback — the preferred source
|
|
12518
|
+
* returning and winning immediately, which the server does on purpose. Three
|
|
12519
|
+
* means the sources are trading the path back and forth, which no priority
|
|
12520
|
+
* rule does.
|
|
12223
12521
|
*/
|
|
12522
|
+
var CHANGES_BEFORE_WARNING = 3;
|
|
12224
12523
|
var SourceWatch = class {
|
|
12524
|
+
/** Last time each source produced a position, per context. */
|
|
12225
12525
|
seen = /* @__PURE__ */ new Map();
|
|
12526
|
+
/** Sources found competing, per context, with when each was last seen so. */
|
|
12527
|
+
competing = /* @__PURE__ */ new Map();
|
|
12528
|
+
/** The current alternation per context: who is in it, and how long it is. */
|
|
12529
|
+
lastSeen = /* @__PURE__ */ new Map();
|
|
12226
12530
|
/** Record that `context` produced a position from `$source`. */
|
|
12227
|
-
add(context, source) {
|
|
12531
|
+
add(context, source, now = Date.now()) {
|
|
12228
12532
|
if (!source) return;
|
|
12229
12533
|
let sources = this.seen.get(context);
|
|
12230
12534
|
if (!sources) {
|
|
12231
|
-
sources = /* @__PURE__ */ new
|
|
12535
|
+
sources = /* @__PURE__ */ new Map();
|
|
12232
12536
|
this.seen.set(context, sources);
|
|
12233
12537
|
}
|
|
12234
|
-
|
|
12538
|
+
const previous = this.lastSeen.get(context);
|
|
12539
|
+
sources.set(source, now);
|
|
12540
|
+
const stale = !previous || now - previous.changedAt > ALTERNATION_WINDOW_MS;
|
|
12541
|
+
if (stale || previous.source === source) this.lastSeen.set(context, {
|
|
12542
|
+
source,
|
|
12543
|
+
at: now,
|
|
12544
|
+
changedAt: stale ? now : previous.changedAt,
|
|
12545
|
+
changes: stale ? 0 : previous.changes,
|
|
12546
|
+
participants: stale ? /* @__PURE__ */ new Set([source]) : previous.participants
|
|
12547
|
+
});
|
|
12548
|
+
else {
|
|
12549
|
+
const changes = previous.changes + 1;
|
|
12550
|
+
const participants = previous.participants.add(source);
|
|
12551
|
+
this.lastSeen.set(context, {
|
|
12552
|
+
source,
|
|
12553
|
+
at: now,
|
|
12554
|
+
changedAt: now,
|
|
12555
|
+
changes,
|
|
12556
|
+
participants
|
|
12557
|
+
});
|
|
12558
|
+
if (changes >= CHANGES_BEFORE_WARNING) {
|
|
12559
|
+
const found = this.competing.get(context) ?? /* @__PURE__ */ new Map();
|
|
12560
|
+
for (const participant of participants) found.set(participant, sources.get(participant) ?? now);
|
|
12561
|
+
this.competing.set(context, found);
|
|
12562
|
+
}
|
|
12563
|
+
}
|
|
12235
12564
|
}
|
|
12236
|
-
/**
|
|
12565
|
+
/** Every source seen for a context, in the order they first appeared. */
|
|
12237
12566
|
sourcesFor(context) {
|
|
12238
|
-
return [...this.seen.get(context) ??
|
|
12567
|
+
return [...(this.seen.get(context) ?? /* @__PURE__ */ new Map()).keys()];
|
|
12568
|
+
}
|
|
12569
|
+
/**
|
|
12570
|
+
* The sources found live at the same time, which is what a warning names.
|
|
12571
|
+
*
|
|
12572
|
+
* Expired entries are dropped rather than merely hidden, so a conflict that
|
|
12573
|
+
* has stopped recurring leaves nothing behind.
|
|
12574
|
+
*/
|
|
12575
|
+
competingFor(context, now = Date.now()) {
|
|
12576
|
+
const found = this.competing.get(context);
|
|
12577
|
+
if (!found) return [];
|
|
12578
|
+
for (const [source, at] of found) if (now - at > CONFLICT_RETENTION_MS) found.delete(source);
|
|
12579
|
+
if (found.size < 2) {
|
|
12580
|
+
this.competing.delete(context);
|
|
12581
|
+
return [];
|
|
12582
|
+
}
|
|
12583
|
+
return [...found.keys()];
|
|
12239
12584
|
}
|
|
12240
|
-
/** Contexts
|
|
12241
|
-
conflicted() {
|
|
12242
|
-
return [...this.
|
|
12585
|
+
/** Contexts that have received positions from two sources at once. */
|
|
12586
|
+
conflicted(now = Date.now()) {
|
|
12587
|
+
return [...this.competing.keys()].filter((context) => this.competingFor(context, now).length > 1);
|
|
12243
12588
|
}
|
|
12244
12589
|
/**
|
|
12245
12590
|
* A status line naming the problem, or undefined when nothing is wrong.
|
|
@@ -12249,21 +12594,24 @@ var SourceWatch = class {
|
|
|
12249
12594
|
* vessels are usually AIS, where multiple receivers are expected and less
|
|
12250
12595
|
* worth nagging about — they are counted, not named.
|
|
12251
12596
|
*/
|
|
12252
|
-
warning(selfContext) {
|
|
12253
|
-
const conflicted = this.conflicted();
|
|
12597
|
+
warning(selfContext, now = Date.now()) {
|
|
12598
|
+
const conflicted = this.conflicted(now);
|
|
12254
12599
|
if (conflicted.length === 0) return;
|
|
12255
12600
|
const selfConflicted = selfContext !== void 0 && conflicted.includes(selfContext);
|
|
12256
12601
|
const others = conflicted.length - (selfConflicted ? 1 : 0);
|
|
12257
12602
|
if (selfConflicted) {
|
|
12258
|
-
const
|
|
12603
|
+
const recent = this.competingFor(selfContext, now);
|
|
12604
|
+
const sources = recent.join(", ");
|
|
12259
12605
|
const tail = others > 0 ? ` (and ${others} other ${others === 1 ? "vessel" : "vessels"})` : "";
|
|
12260
|
-
return `navigation.position for the own vessel is arriving from ${
|
|
12606
|
+
return `navigation.position for the own vessel is arriving from ${recent.length} sources (${sources})${tail}. Set source priority for navigation.position, or the track will zigzag between them.`;
|
|
12261
12607
|
}
|
|
12262
12608
|
return `navigation.position is arriving from multiple sources for ${others} ${others === 1 ? "vessel" : "vessels"}.`;
|
|
12263
12609
|
}
|
|
12264
12610
|
/** Forget everything seen; used when the plugin restarts. */
|
|
12265
12611
|
clear() {
|
|
12266
12612
|
this.seen.clear();
|
|
12613
|
+
this.competing.clear();
|
|
12614
|
+
this.lastSeen.clear();
|
|
12267
12615
|
}
|
|
12268
12616
|
};
|
|
12269
12617
|
//#endregion
|
|
@@ -12337,6 +12685,285 @@ var StateGate = class {
|
|
|
12337
12685
|
}
|
|
12338
12686
|
};
|
|
12339
12687
|
//#endregion
|
|
12688
|
+
//#region src/gpx.ts
|
|
12689
|
+
/**
|
|
12690
|
+
* GPX 1.1 serialisation and parsing for recorded tracks.
|
|
12691
|
+
*
|
|
12692
|
+
* Hand-written rather than through a library: the vocabulary is `gpx`,
|
|
12693
|
+
* `metadata`, `trk`, `name`, `extensions`, `signalk:context`, `trkseg`,
|
|
12694
|
+
* `trkpt` and `time` — nine element types — and a dependency for that would be
|
|
12695
|
+
* more surface than the code it replaces.
|
|
12696
|
+
* What matters is getting the details right, and those are documented below.
|
|
12697
|
+
*/
|
|
12698
|
+
var LAT = 0;
|
|
12699
|
+
var LNG = 1;
|
|
12700
|
+
/**
|
|
12701
|
+
* Serialise tracks as a GPX 1.1 document.
|
|
12702
|
+
*
|
|
12703
|
+
* Several tracks go in one file, each as its own `<trk>`. GPX allows it, and
|
|
12704
|
+
* it is what makes "select three trips, export" produce one file rather than
|
|
12705
|
+
* three — TimeZero writes one file per vessel, which is a choice rather than a
|
|
12706
|
+
* constraint of the format.
|
|
12707
|
+
*
|
|
12708
|
+
* The Signal K context travels in `<extensions>`. TimeZero puts an opaque
|
|
12709
|
+
* GUID there and drops the MMSI entirely, so its exports cannot round-trip an
|
|
12710
|
+
* identity; carrying the context means ours can, while staying readable by
|
|
12711
|
+
* anything else, since `<extensions>` is ignorable by specification.
|
|
12712
|
+
*/
|
|
12713
|
+
function toGpx(tracks, now = /* @__PURE__ */ new Date()) {
|
|
12714
|
+
const lines = [
|
|
12715
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
12716
|
+
"<gpx version=\"1.1\" creator=\"@signalk/tracks-plugin\" xmlns=\"http://www.topografix.com/GPX/1/1\">",
|
|
12717
|
+
" <metadata>",
|
|
12718
|
+
` <time>${iso(now.getTime())}</time>`,
|
|
12719
|
+
" </metadata>"
|
|
12720
|
+
];
|
|
12721
|
+
for (const track of tracks) {
|
|
12722
|
+
const segments = track.segments.map((points) => points.filter((p) => inRange(p.position[LAT], p.position[LNG]))).filter((points) => points.length > 0);
|
|
12723
|
+
if (segments.length === 0) continue;
|
|
12724
|
+
lines.push(" <trk>");
|
|
12725
|
+
lines.push(` <name>${escapeXml(track.name)}</name>`);
|
|
12726
|
+
if (track.context !== void 0) {
|
|
12727
|
+
lines.push(" <extensions>");
|
|
12728
|
+
lines.push(` <signalk:context xmlns:signalk="${NAMESPACE}">${escapeXml(track.context)}</signalk:context>`);
|
|
12729
|
+
lines.push(" </extensions>");
|
|
12730
|
+
}
|
|
12731
|
+
for (const points of segments) {
|
|
12732
|
+
lines.push(" <trkseg>");
|
|
12733
|
+
for (const { position, timestamp } of points) {
|
|
12734
|
+
lines.push(` <trkpt lat="${coordinate(position[LAT])}" lon="${coordinate(position[LNG])}">`);
|
|
12735
|
+
lines.push(` <time>${iso(timestamp)}</time>`);
|
|
12736
|
+
lines.push(" </trkpt>");
|
|
12737
|
+
}
|
|
12738
|
+
lines.push(" </trkseg>");
|
|
12739
|
+
}
|
|
12740
|
+
lines.push(" </trk>");
|
|
12741
|
+
}
|
|
12742
|
+
lines.push("</gpx>");
|
|
12743
|
+
return lines.join("\n") + "\n";
|
|
12744
|
+
}
|
|
12745
|
+
/** GPX's own namespace, which structural elements must be in. */
|
|
12746
|
+
var GPX_NAMESPACE = "http://www.topografix.com/GPX/1/1";
|
|
12747
|
+
/** Namespace for the one extension element this plugin writes. */
|
|
12748
|
+
var NAMESPACE = "https://signalk.org/specification/1.7.0/";
|
|
12749
|
+
/**
|
|
12750
|
+
* Parse a GPX document into tracks.
|
|
12751
|
+
*
|
|
12752
|
+
* Structure follows GPX's own hierarchy: the root is `<gpx>`, tracks are its
|
|
12753
|
+
* children, segments are a track's, points are a segment's. Anything else —
|
|
12754
|
+
* a `<trkseg>` inside an `<extensions>` payload, a `<trk>` below the root — is
|
|
12755
|
+
* not track data, and a namespace check alone cannot tell the difference,
|
|
12756
|
+
* since a payload declaring no namespace inherits GPX's own.
|
|
12757
|
+
*
|
|
12758
|
+
* Deliberately forgiving about structure and strict about values: elements it
|
|
12759
|
+
* does not recognise are ignored rather than rejected, but a coordinate that is
|
|
12760
|
+
* not an `xsd:decimal` in range, or a time that is not an `xsd:dateTime`, is
|
|
12761
|
+
* refused. One NaN reaching the store stretches every bounding box that track
|
|
12762
|
+
* appears in.
|
|
12763
|
+
*
|
|
12764
|
+
* **Points are sorted by time within each segment.** TimeZero paginates its
|
|
12765
|
+
* export in blocks of 150 and the block seams overlap: the last point of a
|
|
12766
|
+
* block can be a few milliseconds newer than the first point of the next. Out
|
|
12767
|
+
* of 5,550 points in a real export, 16 pairs are out of order, every one on a
|
|
12768
|
+
* 150-point boundary. Anything downstream that assumes ordering — time-window
|
|
12769
|
+
* queries, the history reconciliation — would quietly misbehave on such a file.
|
|
12770
|
+
*
|
|
12771
|
+
* Sorting stops at the segment boundary, deliberately: a segment is a stretch
|
|
12772
|
+
* of continuous recording, and reordering points across a gap would merge two
|
|
12773
|
+
* passages into one line. So `segments.flat()` is not globally sorted for a
|
|
12774
|
+
* file whose segments interleave in time, and a caller that needs that must
|
|
12775
|
+
* sort for itself.
|
|
12776
|
+
*
|
|
12777
|
+
* A document the parser rejects yields no tracks rather than throwing: this
|
|
12778
|
+
* reads files the plugin did not write, and an import that fails should say so
|
|
12779
|
+
* by returning nothing rather than by taking the caller down.
|
|
12780
|
+
*/
|
|
12781
|
+
function fromGpx(xml) {
|
|
12782
|
+
let document;
|
|
12783
|
+
try {
|
|
12784
|
+
document = new DOMParser({ onError: (level) => {
|
|
12785
|
+
if (level !== "warning") throw new Error("malformed XML");
|
|
12786
|
+
} }).parseFromString(xml, "text/xml");
|
|
12787
|
+
} catch {
|
|
12788
|
+
return [];
|
|
12789
|
+
}
|
|
12790
|
+
const root = document.documentElement;
|
|
12791
|
+
if (root === null || root.localName !== "gpx" || !isGpxNamespace(root)) return [];
|
|
12792
|
+
const tracks = [];
|
|
12793
|
+
for (const trk of gpxChildren(root, "trk")) {
|
|
12794
|
+
const segments = [];
|
|
12795
|
+
for (const trkseg of gpxChildren(trk, "trkseg")) {
|
|
12796
|
+
const points = readPoints(trkseg);
|
|
12797
|
+
if (points.length > 0) segments.push(points);
|
|
12798
|
+
}
|
|
12799
|
+
if (segments.length === 0) continue;
|
|
12800
|
+
const name = childText(trk, "name").trim() || "Track";
|
|
12801
|
+
const context = signalKContext(trk);
|
|
12802
|
+
tracks.push({
|
|
12803
|
+
name,
|
|
12804
|
+
segments,
|
|
12805
|
+
...context === void 0 ? {} : { context }
|
|
12806
|
+
});
|
|
12807
|
+
}
|
|
12808
|
+
return tracks;
|
|
12809
|
+
}
|
|
12810
|
+
/**
|
|
12811
|
+
* The text of a direct GPX child with this local name.
|
|
12812
|
+
*
|
|
12813
|
+
* Direct rather than `getElementsByTagName`, which would reach a `<name>`
|
|
12814
|
+
* inside an `<extensions>` payload and label the track with a vendor's string.
|
|
12815
|
+
*/
|
|
12816
|
+
function childText(trk, local) {
|
|
12817
|
+
for (const child of Array.from(trk.childNodes)) if (isElement(child) && child.localName === local && isGpxNamespace(child)) return child.textContent ?? "";
|
|
12818
|
+
return "";
|
|
12819
|
+
}
|
|
12820
|
+
/**
|
|
12821
|
+
* The Signal K context declared in a track's own `<extensions>`.
|
|
12822
|
+
*
|
|
12823
|
+
* Matched on namespace URI, which is what the prefix actually means: GPX
|
|
12824
|
+
* extensions are an open field any vendor may write into, so a foreign
|
|
12825
|
+
* `<context>` — or one under a different prefix bound elsewhere — must not
|
|
12826
|
+
* claim the track's identity. Read only from the track's direct
|
|
12827
|
+
* `<extensions>` children, because a trackpoint carries its own and those
|
|
12828
|
+
* belong to that point.
|
|
12829
|
+
*/
|
|
12830
|
+
function signalKContext(trk) {
|
|
12831
|
+
for (const child of Array.from(trk.childNodes)) {
|
|
12832
|
+
if (!isElement(child) || child.localName !== "extensions" || !isGpxNamespace(child)) continue;
|
|
12833
|
+
for (const candidate of Array.from(child.getElementsByTagNameNS(NAMESPACE, "context"))) {
|
|
12834
|
+
const value = (candidate.textContent ?? "").trim();
|
|
12835
|
+
if (value !== "") return value;
|
|
12836
|
+
}
|
|
12837
|
+
}
|
|
12838
|
+
}
|
|
12839
|
+
/** The points of one `<trkseg>`, oldest first. */
|
|
12840
|
+
function readPoints(trkseg) {
|
|
12841
|
+
const points = [];
|
|
12842
|
+
for (const trkpt of gpxChildren(trkseg, "trkpt")) {
|
|
12843
|
+
const latitude = decimal(trkpt.getAttribute("lat") ?? void 0);
|
|
12844
|
+
const longitude = decimal(trkpt.getAttribute("lon") ?? void 0);
|
|
12845
|
+
if (!inRange(latitude, longitude)) continue;
|
|
12846
|
+
const raw = childText(trkpt, "time").trim();
|
|
12847
|
+
const timestamp = raw === "" ? NaN : dateTime(raw);
|
|
12848
|
+
points.push({
|
|
12849
|
+
position: [latitude, longitude],
|
|
12850
|
+
timestamp: Number.isFinite(timestamp) ? timestamp : 0
|
|
12851
|
+
});
|
|
12852
|
+
}
|
|
12853
|
+
return points.sort((a, b) => a.timestamp - b.timestamp);
|
|
12854
|
+
}
|
|
12855
|
+
/**
|
|
12856
|
+
* Direct GPX children of `root` with this local name.
|
|
12857
|
+
*
|
|
12858
|
+
* Direct, because GPX fixes the hierarchy: a `<trkseg>` is a child of `<trk>`
|
|
12859
|
+
* and a `<trkpt>` a child of `<trkseg>`. Scanning descendants instead lets an
|
|
12860
|
+
* `<extensions>` payload contribute track data — and a payload that declares
|
|
12861
|
+
* no namespace of its own inherits GPX's, so a namespace check cannot catch
|
|
12862
|
+
* it. A vendor's `<trkseg>` inside `<extensions>` became a phantom segment.
|
|
12863
|
+
*/
|
|
12864
|
+
function gpxChildren(root, local) {
|
|
12865
|
+
return Array.from(root.childNodes).filter((node) => isElement(node) && node.localName === local && isGpxNamespace(node));
|
|
12866
|
+
}
|
|
12867
|
+
/**
|
|
12868
|
+
* Whether an element is GPX's own.
|
|
12869
|
+
*
|
|
12870
|
+
* A null namespace counts: a great many files in the wild omit the xmlns
|
|
12871
|
+
* declaration entirely, and refusing those would reject most of what users
|
|
12872
|
+
* actually have.
|
|
12873
|
+
*/
|
|
12874
|
+
function isGpxNamespace(element) {
|
|
12875
|
+
return element.namespaceURI === null || element.namespaceURI === GPX_NAMESPACE;
|
|
12876
|
+
}
|
|
12877
|
+
/** Narrow a DOM node to an element without relying on a global Node. */
|
|
12878
|
+
function isElement(node) {
|
|
12879
|
+
return node.nodeType === 1;
|
|
12880
|
+
}
|
|
12881
|
+
/**
|
|
12882
|
+
* Coordinates at seven decimal places: ~11 mm, finer than any GPS fix and
|
|
12883
|
+
* short enough that a long track does not carry pointless digits.
|
|
12884
|
+
*/
|
|
12885
|
+
function coordinate(value) {
|
|
12886
|
+
const fixed = value.toFixed(7).replace(/\.?0+$/, "");
|
|
12887
|
+
return fixed === "-0" ? "0" : fixed;
|
|
12888
|
+
}
|
|
12889
|
+
/**
|
|
12890
|
+
* A GPX `<time>` as epoch milliseconds, or NaN when it is not one.
|
|
12891
|
+
*
|
|
12892
|
+
* GPX types the element as `xsd:dateTime`, and `Date.parse` is far looser: it
|
|
12893
|
+
* reads a bare `2024` and `Jan 1 2024` as instants, and silently rolls
|
|
12894
|
+
* `2024-02-30` forward to March. A date that never existed should reach the
|
|
12895
|
+
* caller's fallback rather than arrive as a real -- and wrong -- position in
|
|
12896
|
+
* time.
|
|
12897
|
+
*/
|
|
12898
|
+
function dateTime(value) {
|
|
12899
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.exec(value);
|
|
12900
|
+
if (!match) return NaN;
|
|
12901
|
+
const parsed = Date.parse(value);
|
|
12902
|
+
if (Number.isNaN(parsed)) return NaN;
|
|
12903
|
+
const year = Number(match[1]);
|
|
12904
|
+
const month = Number(match[2]);
|
|
12905
|
+
const day = Number(match[3]);
|
|
12906
|
+
const utc = new Date(Date.UTC(year, month - 1, day));
|
|
12907
|
+
return utc.getUTCFullYear() === year && utc.getUTCMonth() === month - 1 && utc.getUTCDate() === day ? parsed : NaN;
|
|
12908
|
+
}
|
|
12909
|
+
/**
|
|
12910
|
+
* ISO-8601 UTC, because a track outlives the timezone it was recorded in.
|
|
12911
|
+
*
|
|
12912
|
+
* A timestamp outside the Date range throws out of `toISOString`, and one bad
|
|
12913
|
+
* point would abort the export of every track in the file. It is dated to the
|
|
12914
|
+
* start of time instead, which is what `fromGpx` does with a time it cannot
|
|
12915
|
+
* read -- losing one point's time beats losing the document.
|
|
12916
|
+
*/
|
|
12917
|
+
function iso(timestamp) {
|
|
12918
|
+
const at = new Date(timestamp);
|
|
12919
|
+
return Number.isNaN(at.getTime()) ? (/* @__PURE__ */ new Date(0)).toISOString() : at.toISOString();
|
|
12920
|
+
}
|
|
12921
|
+
/**
|
|
12922
|
+
* XML-escape a value for element content.
|
|
12923
|
+
*
|
|
12924
|
+
* Characters XML cannot express are dropped rather than escaped, because they
|
|
12925
|
+
* have no entity form: emitting one produces a document no conformant reader
|
|
12926
|
+
* will load, and losing the character beats losing the file.
|
|
12927
|
+
*/
|
|
12928
|
+
function escapeXml(value) {
|
|
12929
|
+
return [...value].filter((character) => isXmlChar(character.codePointAt(0) ?? -1)).join("").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
12930
|
+
}
|
|
12931
|
+
/**
|
|
12932
|
+
* Whether a code point is one XML 1.0 permits in content.
|
|
12933
|
+
*
|
|
12934
|
+
* The range has holes: NUL, most C0 controls and the surrogate block are all
|
|
12935
|
+
* excluded. Decoding `�` or `�` puts a character into a track name
|
|
12936
|
+
* that cannot be written back out as well-formed XML, so the reference is
|
|
12937
|
+
* kept verbatim instead -- the same treatment as one that names no character
|
|
12938
|
+
* at all.
|
|
12939
|
+
*/
|
|
12940
|
+
function isXmlChar(value) {
|
|
12941
|
+
if (!Number.isInteger(value)) return false;
|
|
12942
|
+
return value === 9 || value === 10 || value === 13 || value >= 32 && value <= 55295 || value >= 57344 && value <= 65533 || value >= 65536 && value <= 1114111;
|
|
12943
|
+
}
|
|
12944
|
+
/**
|
|
12945
|
+
* An attribute value as an XSD decimal, or NaN when it is not one.
|
|
12946
|
+
*
|
|
12947
|
+
* GPX types lat and lon as `xsd:decimal`, which is digits with an optional
|
|
12948
|
+
* sign and point -- no exponent, no hexadecimal. `Number()` is far more
|
|
12949
|
+
* generous: it reads `0x10` as 16 and `1e1` as 10, so a file using either
|
|
12950
|
+
* imported a position it never expressed.
|
|
12951
|
+
*/
|
|
12952
|
+
function decimal(value) {
|
|
12953
|
+
return value !== void 0 && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value) ? Number(value) : NaN;
|
|
12954
|
+
}
|
|
12955
|
+
/**
|
|
12956
|
+
* Whether a position is one GPX can express.
|
|
12957
|
+
*
|
|
12958
|
+
* Latitude beyond ±90 or longitude beyond ±180 is not a place. Such a value
|
|
12959
|
+
* reaching the store stretches every bounding box the track appears in, and
|
|
12960
|
+
* writing one produces a document a validating reader rejects — so it is
|
|
12961
|
+
* refused in both directions rather than trusted from a file or passed on.
|
|
12962
|
+
*/
|
|
12963
|
+
function inRange(latitude, longitude) {
|
|
12964
|
+
return Number.isFinite(latitude) && Number.isFinite(longitude) && latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180;
|
|
12965
|
+
}
|
|
12966
|
+
//#endregion
|
|
12340
12967
|
//#region src/tracks.ts
|
|
12341
12968
|
/**
|
|
12342
12969
|
* The in-memory store: a bounded sliding window of positions per context, held
|
|
@@ -12503,6 +13130,45 @@ var HISTORY_QUERY_TIMEOUT_MS = 5e3;
|
|
|
12503
13130
|
*/
|
|
12504
13131
|
var WINDOWLESS_HISTORY_SPAN_MS = 864e5;
|
|
12505
13132
|
/**
|
|
13133
|
+
* The earliest instant the existence probe asks about.
|
|
13134
|
+
*
|
|
13135
|
+
* The probe has to name a bound: the History API's time range has no
|
|
13136
|
+
* unbounded form, every branch of `TimeRangeParams` carries one. A provider
|
|
13137
|
+
* filters its context list by the range it is given — questdb builds a SQL
|
|
13138
|
+
* `WHERE` from it — so any bound later than the provider's oldest row can
|
|
13139
|
+
* still miss a vessel and 404 it, which is the case the probe exists to catch.
|
|
13140
|
+
*
|
|
13141
|
+
* The Unix epoch is the bound rather than some span of years: it predates
|
|
13142
|
+
* satellite navigation, so no position fix can lie before it, and unlike a
|
|
13143
|
+
* fixed "wide enough" window it cannot be outlived. The cost lands only on the
|
|
13144
|
+
* branch that was already about to 404, and the answer is cached.
|
|
13145
|
+
*/
|
|
13146
|
+
var EXISTENCE_PROBE_FROM_MS = 0;
|
|
13147
|
+
/**
|
|
13148
|
+
* How long a provider's context list is reused before asking again.
|
|
13149
|
+
*
|
|
13150
|
+
* The probe runs only for a vessel about to 404, which is precisely the
|
|
13151
|
+
* request a client can repeat without limit — the routes are open, so
|
|
13152
|
+
* enumerating vessel ids would otherwise drive one epoch-wide `getContexts`
|
|
13153
|
+
* per request, each able to block for the timeout.
|
|
13154
|
+
*
|
|
13155
|
+
* The whole list is cached rather than a per-context answer, or enumeration
|
|
13156
|
+
* would simply fill the cache with distinct keys and query just as often. The
|
|
13157
|
+
* cost is that a vessel a provider learns about becomes visible up to this
|
|
13158
|
+
* long after the fact, which is not a delay anyone can perceive in a track
|
|
13159
|
+
* that is already minutes old.
|
|
13160
|
+
*/
|
|
13161
|
+
var KNOWN_CONTEXTS_TTL_MS = 3e4;
|
|
13162
|
+
/**
|
|
13163
|
+
* How long a *failed* context query is remembered.
|
|
13164
|
+
*
|
|
13165
|
+
* Short, so a provider that recovers is noticed quickly, but not zero: with
|
|
13166
|
+
* the entry simply dropped, a provider that hangs is asked again by every
|
|
13167
|
+
* subsequent miss while the earlier calls are still pending — reinstating per
|
|
13168
|
+
* request exactly the cost the cache exists to prevent.
|
|
13169
|
+
*/
|
|
13170
|
+
var KNOWN_CONTEXTS_FAILURE_TTL_MS = 1e3;
|
|
13171
|
+
/**
|
|
12506
13172
|
* Config values arrive from the plugin UI as numbers, but a hand-edited
|
|
12507
13173
|
* settings file can supply strings. Accept both, reject anything non-finite so
|
|
12508
13174
|
* a bad value falls back to the default instead of poisoning arithmetic with NaN.
|
|
@@ -12518,6 +13184,15 @@ var notAvailable = (res) => {
|
|
|
12518
13184
|
res.status(404);
|
|
12519
13185
|
res.json({ message: "Tracks API not available because tracks plugin is not enabled" });
|
|
12520
13186
|
};
|
|
13187
|
+
/**
|
|
13188
|
+
* The history provider could not answer, and nothing else could either.
|
|
13189
|
+
*
|
|
13190
|
+
* Distinct from a missing track: a 404 asserts that no source knows the
|
|
13191
|
+
* vessel, which a failed provider read cannot support. Reporting the failure
|
|
13192
|
+
* as an empty track would present an outage as "this vessel has no positions
|
|
13193
|
+
* here" — the one answer that is certainly wrong.
|
|
13194
|
+
*/
|
|
13195
|
+
var HistoryUnavailableError = class extends Error {};
|
|
12521
13196
|
var errorDetail = (err) => err instanceof Error && err.stack ? err.stack : String(err);
|
|
12522
13197
|
/**
|
|
12523
13198
|
* A window covering everything the store holds, up to now.
|
|
@@ -12548,9 +13223,15 @@ function windowSpanning(stored, fallbackMs) {
|
|
|
12548
13223
|
* promise wins. A race leaves it armed, holding the event loop open for the
|
|
12549
13224
|
* remainder of the timeout on every call that succeeds.
|
|
12550
13225
|
*/
|
|
13226
|
+
/**
|
|
13227
|
+
* A bound expired. Distinct from the error a provider itself raises, because
|
|
13228
|
+
* "did not answer in time" and "is not installed" are different answers and
|
|
13229
|
+
* only one of them is an outage.
|
|
13230
|
+
*/
|
|
13231
|
+
var HistoryTimeoutError = class extends Error {};
|
|
12551
13232
|
function withTimeout(promise, ms) {
|
|
12552
13233
|
return new Promise((resolve, reject) => {
|
|
12553
|
-
const timer = setTimeout(() => reject(
|
|
13234
|
+
const timer = setTimeout(() => reject(new HistoryTimeoutError(`timed out after ${ms}ms`)), ms);
|
|
12554
13235
|
promise.then((value) => {
|
|
12555
13236
|
clearTimeout(timer);
|
|
12556
13237
|
resolve(value);
|
|
@@ -12561,6 +13242,48 @@ function withTimeout(promise, ms) {
|
|
|
12561
13242
|
});
|
|
12562
13243
|
}
|
|
12563
13244
|
/**
|
|
13245
|
+
* Whether a history provider holds anything at all for a context.
|
|
13246
|
+
*
|
|
13247
|
+
* Asked only when a track is about to 404: `getValues` narrowed to a window
|
|
13248
|
+
* cannot tell "this vessel is unknown" from "this vessel has nothing *here*",
|
|
13249
|
+
* and 404ing on the second is wrong — the contract is that a 404 means neither
|
|
13250
|
+
* source knows the vessel. A vessel whose history lies wholly outside the
|
|
13251
|
+
* asked window is known, and deserves an empty track.
|
|
13252
|
+
*
|
|
13253
|
+
* Best-effort, like every other provider call here: a provider that lacks the
|
|
13254
|
+
* method, errors, or hangs leaves the 404 standing rather than holding the
|
|
13255
|
+
* request open.
|
|
13256
|
+
*/
|
|
13257
|
+
async function historyKnowsContext(app, context, debug, cache) {
|
|
13258
|
+
const getHistoryApi = app.getHistoryApi;
|
|
13259
|
+
if (!getHistoryApi) return false;
|
|
13260
|
+
try {
|
|
13261
|
+
const now = Date.now();
|
|
13262
|
+
const cached = cache.current;
|
|
13263
|
+
if (cached === void 0 || now - cached.at >= KNOWN_CONTEXTS_TTL_MS) {
|
|
13264
|
+
const contexts = withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider).then((historyApi) => historyApi.getContexts ? historyApi.getContexts({
|
|
13265
|
+
from: Temporal.Instant.from((/* @__PURE__ */ new Date(EXISTENCE_PROBE_FROM_MS)).toISOString()),
|
|
13266
|
+
to: Temporal.Instant.from(new Date(now).toISOString())
|
|
13267
|
+
}) : []).then((listed) => new Set((listed ?? []).filter((c) => typeof c === "string").map((c) => c === "vessels.self" ? app.selfContext : c))), HISTORY_QUERY_TIMEOUT_MS);
|
|
13268
|
+
const entry = {
|
|
13269
|
+
at: now,
|
|
13270
|
+
contexts
|
|
13271
|
+
};
|
|
13272
|
+
contexts.catch(() => {
|
|
13273
|
+
if (cache.current === entry) cache.current = {
|
|
13274
|
+
at: now - KNOWN_CONTEXTS_TTL_MS + KNOWN_CONTEXTS_FAILURE_TTL_MS,
|
|
13275
|
+
contexts
|
|
13276
|
+
};
|
|
13277
|
+
});
|
|
13278
|
+
cache.current = entry;
|
|
13279
|
+
}
|
|
13280
|
+
return (await cache.current.contexts).has(context);
|
|
13281
|
+
} catch (err) {
|
|
13282
|
+
debug(`History contexts unavailable for ${context}: ${errorDetail(err)}`);
|
|
13283
|
+
return false;
|
|
13284
|
+
}
|
|
13285
|
+
}
|
|
13286
|
+
/**
|
|
12564
13287
|
* Positions a history provider holds for a window, or none.
|
|
12565
13288
|
*
|
|
12566
13289
|
* Best-effort by design: a provider that is absent, slow, or failing must not
|
|
@@ -12573,10 +13296,23 @@ async function historyPositions(app, context, window, resolutionMs, debug) {
|
|
|
12573
13296
|
const getHistoryApi = app.getHistoryApi;
|
|
12574
13297
|
if (!getHistoryApi) return {
|
|
12575
13298
|
points: [],
|
|
12576
|
-
resolutionMs: applied
|
|
13299
|
+
resolutionMs: applied,
|
|
13300
|
+
failed: false
|
|
12577
13301
|
};
|
|
13302
|
+
let historyApi;
|
|
13303
|
+
try {
|
|
13304
|
+
historyApi = await withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider), HISTORY_QUERY_TIMEOUT_MS);
|
|
13305
|
+
} catch (err) {
|
|
13306
|
+
const timedOut = err instanceof HistoryTimeoutError;
|
|
13307
|
+
if (debug.enabled) debug(`${timedOut ? "History provider timed out" : "No history provider"} for ${context}: ${errorDetail(err)}`);
|
|
13308
|
+
return {
|
|
13309
|
+
points: [],
|
|
13310
|
+
resolutionMs: applied,
|
|
13311
|
+
failed: timedOut
|
|
13312
|
+
};
|
|
13313
|
+
}
|
|
12578
13314
|
try {
|
|
12579
|
-
const response = await withTimeout(
|
|
13315
|
+
const response = await withTimeout(historyApi.getValues({
|
|
12580
13316
|
context,
|
|
12581
13317
|
from: Temporal.Instant.from(new Date(window.from).toISOString()),
|
|
12582
13318
|
to: Temporal.Instant.from(new Date(window.to).toISOString()),
|
|
@@ -12601,13 +13337,15 @@ async function historyPositions(app, context, window, resolutionMs, debug) {
|
|
|
12601
13337
|
if (debug.enabled) debug(`History supplied ${points.length} position(s) for ${context}`);
|
|
12602
13338
|
return {
|
|
12603
13339
|
points,
|
|
12604
|
-
resolutionMs: applied
|
|
13340
|
+
resolutionMs: applied,
|
|
13341
|
+
failed: false
|
|
12605
13342
|
};
|
|
12606
13343
|
} catch (err) {
|
|
12607
13344
|
if (debug.enabled) debug(`History unavailable for ${context}: ${errorDetail(err)}`);
|
|
12608
13345
|
return {
|
|
12609
13346
|
points: [],
|
|
12610
|
-
resolutionMs: applied
|
|
13347
|
+
resolutionMs: applied,
|
|
13348
|
+
failed: true
|
|
12611
13349
|
};
|
|
12612
13350
|
}
|
|
12613
13351
|
}
|
|
@@ -12633,6 +13371,13 @@ function ThePlugin(app) {
|
|
|
12633
13371
|
const sourceWatch = new SourceWatch();
|
|
12634
13372
|
let glitchFilter = new GlitchFilter({ maxSpeedKnots: 100 });
|
|
12635
13373
|
let stateGate = new StateGate(app.selfContext, DEFAULT_PAUSE_STATES);
|
|
13374
|
+
/**
|
|
13375
|
+
* The history provider's context list, cached between existence probes.
|
|
13376
|
+
*
|
|
13377
|
+
* Per plugin instance rather than module-global: two instances would
|
|
13378
|
+
* otherwise answer from each other's provider.
|
|
13379
|
+
*/
|
|
13380
|
+
const knownContexts = { current: void 0 };
|
|
12636
13381
|
/** The own vessel's navigation.state, or undefined when it reports none. */
|
|
12637
13382
|
function getVesselState() {
|
|
12638
13383
|
const s = app.getSelfPath("navigation.state");
|
|
@@ -12704,6 +13449,7 @@ function ThePlugin(app) {
|
|
|
12704
13449
|
store: () => tracks,
|
|
12705
13450
|
selfContext: () => app.selfContext,
|
|
12706
13451
|
segmentGap: () => segmentGap,
|
|
13452
|
+
contextName: (context) => contextName(context, (path) => app.getPath?.(path)),
|
|
12707
13453
|
reconcileWithHistory: async (context, stored, window, resolutionMs) => {
|
|
12708
13454
|
const effective = resolutionMs ?? storeResolution;
|
|
12709
13455
|
const history = await historyPositions(app, context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective, app.debug);
|
|
@@ -12723,6 +13469,7 @@ function ThePlugin(app) {
|
|
|
12723
13469
|
sourceWatch.clear();
|
|
12724
13470
|
glitchFilter.clear();
|
|
12725
13471
|
stateGate.clear();
|
|
13472
|
+
knownContexts.current = void 0;
|
|
12726
13473
|
try {
|
|
12727
13474
|
tracks?.close?.();
|
|
12728
13475
|
} catch (err) {
|
|
@@ -12732,6 +13479,34 @@ function ThePlugin(app) {
|
|
|
12732
13479
|
}
|
|
12733
13480
|
},
|
|
12734
13481
|
signalKApiRoutes: function(router) {
|
|
13482
|
+
const nameOf = (context) => trackLabel(context, app.selfContext, (path) => app.getPath?.(path));
|
|
13483
|
+
/**
|
|
13484
|
+
* The points of one context, reconciled with history and segmented.
|
|
13485
|
+
*
|
|
13486
|
+
* Shared by the JSON and GPX routes so the two cannot answer the same
|
|
13487
|
+
* question differently: the history reconciliation, the windowless
|
|
13488
|
+
* fallback and the 404-only-for-an-unknown-vessel rule are subtle enough
|
|
13489
|
+
* that a second copy would drift.
|
|
13490
|
+
*
|
|
13491
|
+
* Rejects with `undefined` when neither source knows the vessel at all.
|
|
13492
|
+
*/
|
|
13493
|
+
const readSegments = async (context, query) => {
|
|
13494
|
+
const effectiveResolution = query.resolution ?? storeResolution;
|
|
13495
|
+
const { points: stored, known } = await tracks.getTimed(context, query.window).then((points) => ({
|
|
13496
|
+
points,
|
|
13497
|
+
known: true
|
|
13498
|
+
})).catch(() => ({
|
|
13499
|
+
points: [],
|
|
13500
|
+
known: false
|
|
13501
|
+
}));
|
|
13502
|
+
const history = await historyPositions(app, context, query.window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effectiveResolution, app.debug);
|
|
13503
|
+
const points = history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
|
|
13504
|
+
if (points.length === 0) {
|
|
13505
|
+
if (!(known || await historyKnowsContext(app, context, app.debug, knownContexts))) return;
|
|
13506
|
+
if (history.failed) throw new HistoryUnavailableError(`History provider could not be read for ${context}`);
|
|
13507
|
+
}
|
|
13508
|
+
return segment(thin(points, query.resolution), segmentGap);
|
|
13509
|
+
};
|
|
12735
13510
|
const singleTrackHandler = (contextOf) => (req, res) => {
|
|
12736
13511
|
if (!tracks) {
|
|
12737
13512
|
notAvailable(res);
|
|
@@ -12746,37 +13521,89 @@ function ThePlugin(app) {
|
|
|
12746
13521
|
res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
|
|
12747
13522
|
return;
|
|
12748
13523
|
}
|
|
12749
|
-
|
|
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) {
|
|
13524
|
+
readSegments(context, query).then((segments) => {
|
|
13525
|
+
if (segments === void 0) {
|
|
12761
13526
|
res.status(404);
|
|
12762
13527
|
res.json({ message: `No track available for ${context}` });
|
|
12763
13528
|
return;
|
|
12764
13529
|
}
|
|
12765
|
-
const segments = segment(thin(points, query.resolution), segmentGap);
|
|
12766
13530
|
res.json({
|
|
12767
13531
|
type: "MultiLineString",
|
|
12768
13532
|
coordinates: segments.map((points) => points.map(({ position }) => toLngLat(position))),
|
|
12769
13533
|
...query.times ? { times: segments.map(toIsoTimes) } : {},
|
|
12770
13534
|
context,
|
|
12771
|
-
isSelf: context === app.selfContext
|
|
13535
|
+
isSelf: context === app.selfContext,
|
|
13536
|
+
name: nameOf(context)
|
|
12772
13537
|
});
|
|
12773
|
-
}).catch(() => {
|
|
13538
|
+
}).catch((err) => {
|
|
13539
|
+
if (err instanceof HistoryUnavailableError) {
|
|
13540
|
+
app.error(`${err.message}`);
|
|
13541
|
+
res.status(503);
|
|
13542
|
+
res.json({ message: `Track history is temporarily unavailable for ${context}` });
|
|
13543
|
+
return;
|
|
13544
|
+
}
|
|
12774
13545
|
res.status(404);
|
|
12775
13546
|
res.json({ message: `No track available for ${context}` });
|
|
12776
13547
|
});
|
|
12777
13548
|
};
|
|
13549
|
+
/**
|
|
13550
|
+
* One vessel's track as a GPX file.
|
|
13551
|
+
*
|
|
13552
|
+
* Serialised by `toGpx` rather than assembled here, so the export a user
|
|
13553
|
+
* downloads is the same document the module produces everywhere else.
|
|
13554
|
+
* The webapp cannot reach that module — it ships as static files with no
|
|
13555
|
+
* bundler — which is why the conversion lives behind a route instead of
|
|
13556
|
+
* being duplicated in the page.
|
|
13557
|
+
*/
|
|
13558
|
+
const gpxHandler = (contextOf) => (req, res) => {
|
|
13559
|
+
if (!tracks) {
|
|
13560
|
+
notAvailable(res);
|
|
13561
|
+
return;
|
|
13562
|
+
}
|
|
13563
|
+
const context = contextOf(req);
|
|
13564
|
+
let query;
|
|
13565
|
+
try {
|
|
13566
|
+
query = parseTrackQuery(req.query);
|
|
13567
|
+
} catch (err) {
|
|
13568
|
+
res.status(400);
|
|
13569
|
+
res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
|
|
13570
|
+
return;
|
|
13571
|
+
}
|
|
13572
|
+
readSegments(context, query).then((segments) => {
|
|
13573
|
+
if (segments === void 0) {
|
|
13574
|
+
res.status(404);
|
|
13575
|
+
res.json({ message: `No track available for ${context}` });
|
|
13576
|
+
return;
|
|
13577
|
+
}
|
|
13578
|
+
const label = nameOf(context);
|
|
13579
|
+
const filename = gpxFilename(label);
|
|
13580
|
+
res.type("application/gpx+xml");
|
|
13581
|
+
res.setHeader("Content-Disposition", `attachment; filename="${asciiFilename(filename)}"; filename*=UTF-8''${rfc8187(filename)}`);
|
|
13582
|
+
res.send(toGpx([{
|
|
13583
|
+
name: label,
|
|
13584
|
+
context,
|
|
13585
|
+
segments
|
|
13586
|
+
}]));
|
|
13587
|
+
}).catch((err) => {
|
|
13588
|
+
if (err instanceof HistoryUnavailableError) {
|
|
13589
|
+
app.error(`${err.message}`);
|
|
13590
|
+
if (!res.headersSent) {
|
|
13591
|
+
res.status(503);
|
|
13592
|
+
res.json({ message: `Track history is temporarily unavailable for ${context}` });
|
|
13593
|
+
}
|
|
13594
|
+
return;
|
|
13595
|
+
}
|
|
13596
|
+
app.error(`Could not export GPX for ${context}: ${errorDetail(err)}`);
|
|
13597
|
+
if (!res.headersSent) {
|
|
13598
|
+
res.status(500);
|
|
13599
|
+
res.json({ message: `Could not export the track for ${context}` });
|
|
13600
|
+
}
|
|
13601
|
+
});
|
|
13602
|
+
};
|
|
12778
13603
|
const trackHandler = singleTrackHandler((req) => resolveContext(String(req.params.vesselId), app.selfContext));
|
|
12779
13604
|
router.get("/vessels/:vesselId/track", trackHandler);
|
|
13605
|
+
router.get("/vessels/:vesselId/track.gpx", gpxHandler((req) => resolveContext(String(req.params.vesselId), app.selfContext)));
|
|
13606
|
+
router.get("/self/track.gpx", gpxHandler(() => app.selfContext));
|
|
12780
13607
|
router.get("/self/track", singleTrackHandler(() => app.selfContext));
|
|
12781
13608
|
const allTracksHandler = (req, res) => {
|
|
12782
13609
|
app.debug(req.query);
|
|
@@ -12800,14 +13627,16 @@ function ThePlugin(app) {
|
|
|
12800
13627
|
type: "MultiLineString",
|
|
12801
13628
|
coordinates: segments.map((s) => s.map(({ position }) => toLngLat(position))),
|
|
12802
13629
|
times: segments.map(toIsoTimes),
|
|
12803
|
-
isSelf: context === app.selfContext
|
|
13630
|
+
isSelf: context === app.selfContext,
|
|
13631
|
+
name: nameOf(context)
|
|
12804
13632
|
};
|
|
12805
13633
|
return acc;
|
|
12806
13634
|
}, {})) : tracks.getFilteredTracks(params, selfPosition, app.debug, query).then((tc) => Object.entries(tc).reduce((acc, [context, track]) => {
|
|
12807
13635
|
acc[context] = {
|
|
12808
13636
|
type: "MultiLineString",
|
|
12809
13637
|
coordinates: [track.map(toLngLat)],
|
|
12810
|
-
isSelf: context === app.selfContext
|
|
13638
|
+
isSelf: context === app.selfContext,
|
|
13639
|
+
name: nameOf(context)
|
|
12811
13640
|
};
|
|
12812
13641
|
return acc;
|
|
12813
13642
|
}, {}))).then((trks) => {
|
|
@@ -12877,6 +13706,6 @@ function ThePlugin(app) {
|
|
|
12877
13706
|
};
|
|
12878
13707
|
}
|
|
12879
13708
|
//#endregion
|
|
12880
|
-
export { TrackAccumulator, Tracks, ThePlugin as default };
|
|
13709
|
+
export { TrackAccumulator, Tracks, ThePlugin as default, fromGpx, toGpx };
|
|
12881
13710
|
|
|
12882
13711
|
//# sourceMappingURL=index.js.map
|