@signalk/tracks-plugin 3.0.0-beta.1 → 3.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -28
- package/dist/e2e.test-utils.d.ts +13 -3
- package/dist/gpx.d.ts +59 -0
- package/dist/harness.test-utils.d.ts +76 -1
- package/dist/index.d.ts +28 -29
- package/dist/index.html +30 -0
- package/dist/index.js +1046 -403
- package/dist/index.js.map +1 -1
- package/dist/simplify.d.ts +46 -0
- package/dist/simplify.test.d.ts +1 -0
- package/dist/sqliteStore.d.ts +9 -5
- package/dist/stateGate.d.ts +0 -4
- package/dist/store.d.ts +28 -9
- package/dist/style.css +49 -0
- package/dist/trackApi.d.ts +9 -11
- package/dist/trackProvider.d.ts +24 -0
- package/dist/tracks.d.ts +7 -4
- package/dist/tracks.js +192 -0
- package/dist/utils.d.ts +69 -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/{bootstrap.test.d.ts → gpx.test.d.ts} +0 -0
- /package/dist/{source.test.d.ts → historyRow.test.d.ts} +0 -0
package/dist/index.js
CHANGED
|
@@ -1,13 +1,125 @@
|
|
|
1
1
|
import { Temporal } from "@js-temporal/polyfill";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { BehaviorSubject, ReplaySubject, Subject, combineLatest, connectable, firstValueFrom } from "rxjs";
|
|
4
|
-
import { map, scan, startWith, throttleTime } from "rxjs/operators";
|
|
5
3
|
import { Type } from "typebox";
|
|
6
4
|
import { Value } from "typebox/value";
|
|
7
5
|
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { DOMParser } from "@xmldom/xmldom";
|
|
7
|
+
import { BehaviorSubject, ReplaySubject, Subject, combineLatest, connectable, firstValueFrom } from "rxjs";
|
|
8
|
+
import { map, scan, startWith, throttleTime } from "rxjs/operators";
|
|
8
9
|
//#region \0rolldown/runtime.js
|
|
9
10
|
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
10
11
|
//#endregion
|
|
12
|
+
//#region src/simplify.ts
|
|
13
|
+
var LAT$2 = 0;
|
|
14
|
+
var LNG$2 = 1;
|
|
15
|
+
/**
|
|
16
|
+
* Metres per degree of latitude. Good to ~0.5% over the WGS84 ellipsoid.
|
|
17
|
+
*
|
|
18
|
+
* Exported because the provider derives its automatic tolerance from the same
|
|
19
|
+
* figure: "one part in a thousand of the diagonal" only holds while both use
|
|
20
|
+
* one metres-per-degree value.
|
|
21
|
+
*/
|
|
22
|
+
var M_PER_DEG = 111320;
|
|
23
|
+
/**
|
|
24
|
+
* Span, in scaled degrees, beyond which the flat projection is not trusted.
|
|
25
|
+
*
|
|
26
|
+
* Ten degrees is ~1,100 km, where the projection still agrees with a spherical
|
|
27
|
+
* cross-track calculation to 0.1%. No leg between two recorded positions comes
|
|
28
|
+
* close; a segment that does is a data artefact rather than a passage.
|
|
29
|
+
*/
|
|
30
|
+
var SPAN_LIMIT = 10;
|
|
31
|
+
/**
|
|
32
|
+
* Douglas-Peucker simplification of a recorded track.
|
|
33
|
+
*
|
|
34
|
+
* Distinct from `thin()`, and the difference is the whole point: thinning
|
|
35
|
+
* decimates by *time*, so it keeps a point every N seconds whether the boat was
|
|
36
|
+
* turning or holding a course, and a corner falling between two samples is
|
|
37
|
+
* simply lost. Simplification decimates by *shape* — it keeps the points that
|
|
38
|
+
* carry the geometry and drops the ones a straight line already describes.
|
|
39
|
+
*
|
|
40
|
+
* That is what makes this the piece a route conversion needs. 5,550 points at
|
|
41
|
+
* 1.2 s spacing is a recording, not a route; thinning it to 30 points gives 30
|
|
42
|
+
* evenly spaced positions that miss every turn, while simplifying it to 30
|
|
43
|
+
* gives the turns and nothing else.
|
|
44
|
+
*
|
|
45
|
+
* `epsilon` is a tolerance in **metres**: no point of the original track is
|
|
46
|
+
* further than this from the simplified line.
|
|
47
|
+
*/
|
|
48
|
+
function simplify(points, epsilon) {
|
|
49
|
+
if (!(epsilon > 0) || points.length <= 2) return points;
|
|
50
|
+
const keep = new Uint8Array(points.length);
|
|
51
|
+
keep[0] = 1;
|
|
52
|
+
keep[points.length - 1] = 1;
|
|
53
|
+
const stack = [[0, points.length - 1]];
|
|
54
|
+
while (stack.length > 0) {
|
|
55
|
+
const [first, last] = stack.pop();
|
|
56
|
+
if (last <= first + 1) continue;
|
|
57
|
+
let worst = 0;
|
|
58
|
+
let worstAt = -1;
|
|
59
|
+
for (let i = first + 1; i < last; i++) {
|
|
60
|
+
const d = perpendicularDistance(points[i], points[first], points[last]);
|
|
61
|
+
if (d > worst) {
|
|
62
|
+
worst = d;
|
|
63
|
+
worstAt = i;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (worstAt >= 0 && worst > epsilon) {
|
|
67
|
+
keep[worstAt] = 1;
|
|
68
|
+
stack.push([first, worstAt], [worstAt, last]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return points.filter((_, i) => keep[i] === 1);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Distance in metres from `p` to the segment `a`-`b`.
|
|
75
|
+
*
|
|
76
|
+
* Positions are projected to a local plane with longitude scaled by
|
|
77
|
+
* cos(latitude) at the segment's midpoint. Measured against a spherical
|
|
78
|
+
* cross-track reference the error is 0.1% for segments spanning up to ten
|
|
79
|
+
* degrees of latitude — some 1,100 km, longer than any leg between two
|
|
80
|
+
* recorded fixes — which is far below the metre-scale tolerances this runs
|
|
81
|
+
* with, and it avoids trigonometry per point per recursion level (2.9 ms for
|
|
82
|
+
* 5,550 points, and `simplifyToBudget` calls this ~40 times).
|
|
83
|
+
*
|
|
84
|
+
* One flat scale cannot hold across a segment spanning most of a hemisphere:
|
|
85
|
+
* an 80N-to-equator segment reads 85 km where the truth is 111 km. Such a
|
|
86
|
+
* segment means two consecutive fixes an ocean and a continent apart, which
|
|
87
|
+
* recorded data does not contain — but the tolerance is documented in metres,
|
|
88
|
+
* so `SPAN_LIMIT` refuses to answer rather than answer wrongly, in either
|
|
89
|
+
* axis.
|
|
90
|
+
*/
|
|
91
|
+
function perpendicularDistance(p, a, b) {
|
|
92
|
+
const cos = Math.cos((a.position[LAT$2] + b.position[LAT$2]) / 2 * Math.PI / 180);
|
|
93
|
+
const originLng = a.position[LNG$2];
|
|
94
|
+
if (Math.abs(a.position[LAT$2] - b.position[LAT$2]) > SPAN_LIMIT || Math.abs(nearestLongitude(b.position[LNG$2], originLng) - originLng) * cos > SPAN_LIMIT) return Infinity;
|
|
95
|
+
const ax = originLng * cos;
|
|
96
|
+
const ay = a.position[LAT$2];
|
|
97
|
+
const bx = nearestLongitude(b.position[LNG$2], originLng) * cos;
|
|
98
|
+
const by = b.position[LAT$2];
|
|
99
|
+
const px = nearestLongitude(p.position[LNG$2], originLng) * cos;
|
|
100
|
+
const py = p.position[LAT$2];
|
|
101
|
+
const dx = bx - ax;
|
|
102
|
+
const dy = by - ay;
|
|
103
|
+
let t = 0;
|
|
104
|
+
const lenSq = dx * dx + dy * dy;
|
|
105
|
+
if (lenSq > 0) t = Math.min(1, Math.max(0, ((px - ax) * dx + (py - ay) * dy) / lenSq));
|
|
106
|
+
const cx = ax + t * dx;
|
|
107
|
+
const cy = ay + t * dy;
|
|
108
|
+
return Math.hypot(px - cx, py - cy) * M_PER_DEG;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `lng` shifted by whole turns to sit within 180 degrees of `origin`.
|
|
112
|
+
*
|
|
113
|
+
* A track crossing the antimeridian is stored as 179.9 then -179.9, a tenth of
|
|
114
|
+
* a degree apart on the ground but 359.8 apart numerically. Without this, a
|
|
115
|
+
* straight line across it reads as a segment spanning almost the whole globe,
|
|
116
|
+
* and points that lie exactly on that line measure kilometres away from it —
|
|
117
|
+
* so simplification keeps every one of them.
|
|
118
|
+
*/
|
|
119
|
+
function nearestLongitude(lng, origin) {
|
|
120
|
+
return lng - 360 * Math.round((lng - origin) / 360);
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
11
123
|
//#region src/timeWindow.ts
|
|
12
124
|
/**
|
|
13
125
|
* Time-window and resolution query parameters.
|
|
@@ -232,8 +344,8 @@ function segment(points, gap) {
|
|
|
232
344
|
}
|
|
233
345
|
//#endregion
|
|
234
346
|
//#region src/utils.ts
|
|
235
|
-
var LAT = 0;
|
|
236
|
-
var LNG = 1;
|
|
347
|
+
var LAT$1 = 0;
|
|
348
|
+
var LNG$1 = 1;
|
|
237
349
|
/**
|
|
238
350
|
* The recording times of a segment, as ISO-8601 UTC, positionally aligned with
|
|
239
351
|
* the segment's coordinates.
|
|
@@ -247,13 +359,13 @@ function toIsoTimes(segment) {
|
|
|
247
359
|
return segment.map(({ timestamp }) => new Date(timestamp).toISOString());
|
|
248
360
|
}
|
|
249
361
|
function createInBounds(bounds) {
|
|
250
|
-
const minLat = bounds.sw[LAT];
|
|
251
|
-
const maxLat = bounds.ne[LAT];
|
|
362
|
+
const minLat = bounds.sw[LAT$1];
|
|
363
|
+
const maxLat = bounds.ne[LAT$1];
|
|
252
364
|
if (minLat > maxLat) throw new Error(`Bounding box south must be <= north, got ${JSON.stringify(bounds)}`);
|
|
253
|
-
const minLng = bounds.sw[LNG];
|
|
254
|
-
const maxLng = (bounds.sw[LNG] > bounds.ne[LNG] ? 360 : 0) + bounds.ne[LNG];
|
|
365
|
+
const minLng = bounds.sw[LNG$1];
|
|
366
|
+
const maxLng = (bounds.sw[LNG$1] > bounds.ne[LNG$1] ? 360 : 0) + bounds.ne[LNG$1];
|
|
255
367
|
return (p) => {
|
|
256
|
-
return p !== null && p[LAT] >= minLat && p[LAT] <= maxLat && (p[LNG] >= minLng && p[LNG] <= maxLng || p[LNG] + 360 >= minLng && p[LNG] + 360 <= maxLng);
|
|
368
|
+
return p !== null && p[LAT$1] >= minLat && p[LAT$1] <= maxLat && (p[LNG$1] >= minLng && p[LNG$1] <= maxLng || p[LNG$1] + 360 >= minLng && p[LNG$1] + 360 <= maxLng);
|
|
257
369
|
};
|
|
258
370
|
}
|
|
259
371
|
/**
|
|
@@ -271,11 +383,11 @@ function createInBounds(bounds) {
|
|
|
271
383
|
* iterate the result.
|
|
272
384
|
*/
|
|
273
385
|
function splitAtAntimeridian(bounds) {
|
|
274
|
-
const west = bounds.sw[LNG];
|
|
275
|
-
const east = bounds.ne[LNG];
|
|
386
|
+
const west = bounds.sw[LNG$1];
|
|
387
|
+
const east = bounds.ne[LNG$1];
|
|
276
388
|
if (west <= east) return [bounds];
|
|
277
|
-
const south = bounds.sw[LAT];
|
|
278
|
-
const north = bounds.ne[LAT];
|
|
389
|
+
const south = bounds.sw[LAT$1];
|
|
390
|
+
const north = bounds.ne[LAT$1];
|
|
279
391
|
return [{
|
|
280
392
|
sw: [south, west],
|
|
281
393
|
ne: [north, 180]
|
|
@@ -358,142 +470,111 @@ function createMatcher(params, selfPosition, debug) {
|
|
|
358
470
|
}
|
|
359
471
|
return () => true;
|
|
360
472
|
}
|
|
361
|
-
//#endregion
|
|
362
|
-
//#region src/tracks.ts
|
|
363
473
|
/**
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
474
|
+
* A history row is `[timestamp, position]`. Both encodings are legal: the
|
|
475
|
+
* History API types a value as `unknown` and its own comment names "objects
|
|
476
|
+
* (like navigation.position)" alongside primitives, so signalk-questdb returns
|
|
477
|
+
* `{latitude, longitude}` and signalk-to-influxdb2 returns `[lon, lat]`.
|
|
478
|
+
* Accept both — neither is an error, so rejecting one silently yields an empty
|
|
479
|
+
* track rather than a failure anybody can see.
|
|
367
480
|
*/
|
|
368
|
-
var
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
this.config = config;
|
|
375
|
-
this.debug = debug;
|
|
376
|
-
}
|
|
377
|
-
newPosition(context, position, timestamp) {
|
|
378
|
-
this.getAccumulator(context)?.nextLatLngTuple(position, timestamp);
|
|
379
|
-
}
|
|
380
|
-
initialTrack(context, track, timestamps) {
|
|
381
|
-
this.getAccumulator(context)?.setInitialTrack(track, timestamps);
|
|
382
|
-
}
|
|
383
|
-
getAccumulator(context, createIfMissing = true) {
|
|
384
|
-
if (context.indexOf("vessels.") === -1 && context.indexOf("aircraft.") === -1) return;
|
|
385
|
-
let result = this.tracks[context];
|
|
386
|
-
if (!result && createIfMissing) {
|
|
387
|
-
const accParams = this.config.fetchInitialTrack ? {
|
|
388
|
-
...this.config,
|
|
389
|
-
fetchTrackFor: context
|
|
390
|
-
} : { ...this.config };
|
|
391
|
-
result = this.tracks[context] = new TrackAccumulator(accParams);
|
|
392
|
-
}
|
|
393
|
-
return result;
|
|
394
|
-
}
|
|
395
|
-
get(context, window) {
|
|
396
|
-
return this.getTimed(context, window).then((points) => points.map(({ position }) => position));
|
|
397
|
-
}
|
|
398
|
-
/**
|
|
399
|
-
* Track points with their timestamps, optionally narrowed to a time window.
|
|
400
|
-
*
|
|
401
|
-
* Rejects with EmptyError if the stream completes without emitting; the route
|
|
402
|
-
* handlers turn that into the same 404 as an unknown context.
|
|
403
|
-
*/
|
|
404
|
-
getTimed(context, window) {
|
|
405
|
-
const accumulator = this.getAccumulator(context, false);
|
|
406
|
-
if (!accumulator) return Promise.reject(/* @__PURE__ */ new Error(`No track accumulator for ${context}`));
|
|
407
|
-
return firstValueFrom(accumulator.timedTrack).then((points) => window ? points.filter(({ timestamp }) => timestamp >= window.from && (window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to)) : points);
|
|
408
|
-
}
|
|
409
|
-
getAllTracks(query) {
|
|
410
|
-
return Promise.all(Object.keys(this.tracks).map((context) => this.getTimed(context, query?.window).then((points) => ({
|
|
411
|
-
context,
|
|
412
|
-
track: thin(points, query?.resolution).map(({ position }) => position)
|
|
413
|
-
}))));
|
|
414
|
-
}
|
|
415
|
-
async getFilteredTracks(params, selfPosition, debug, query) {
|
|
416
|
-
const timed = await this.getFilteredTimedTracks(params, selfPosition, debug, query);
|
|
417
|
-
return Object.fromEntries(Object.entries(timed).map(([context, points]) => [context, points.map(({ position }) => position)]));
|
|
418
|
-
}
|
|
419
|
-
/**
|
|
420
|
-
* The same filtering as `getFilteredTracks`, keeping each point's timestamp.
|
|
421
|
-
*
|
|
422
|
-
* The untimed form is derived from this one so the two cannot disagree about
|
|
423
|
-
* which tracks match — the filter runs once, here.
|
|
424
|
-
*/
|
|
425
|
-
async getFilteredTimedTracks(params, selfPosition, debug, query) {
|
|
426
|
-
this.debug(params);
|
|
427
|
-
this.debug("Self position", selfPosition);
|
|
428
|
-
const matcher = createMatcher(params, selfPosition, debug);
|
|
429
|
-
const contexts = Object.keys(this.tracks);
|
|
430
|
-
return (await Promise.all(contexts.map((context) => this.getTimed(context, query?.window).then((points) => ({
|
|
431
|
-
context,
|
|
432
|
-
points: thin(points, query?.resolution)
|
|
433
|
-
}))))).reduce((acc, { context, points }) => {
|
|
434
|
-
if (matcher(points.map(({ position }) => position))) acc[context] = points;
|
|
435
|
-
return acc;
|
|
436
|
-
}, {});
|
|
437
|
-
}
|
|
438
|
-
prune(maxAge) {
|
|
439
|
-
const cutoff = Date.now() - maxAge;
|
|
440
|
-
const deleted = [];
|
|
441
|
-
Object.entries(this.tracks).forEach(([key, value]) => {
|
|
442
|
-
if (value.latestLatLngTuple < cutoff) {
|
|
443
|
-
delete this.tracks[key];
|
|
444
|
-
deleted.push(key);
|
|
445
|
-
}
|
|
446
|
-
});
|
|
447
|
-
if (this.debug.enabled) this.debug(`deleted tracks for ${deleted.join(", ")}`);
|
|
448
|
-
}
|
|
449
|
-
};
|
|
450
|
-
var TrackAccumulator = class {
|
|
451
|
-
initialTrack = new BehaviorSubject([]);
|
|
452
|
-
input = new Subject();
|
|
453
|
-
latestLatLngTuple = 0;
|
|
454
|
-
accumulatedTrack;
|
|
455
|
-
/** Timestamped points, used to answer time-window queries. */
|
|
456
|
-
timedTrack;
|
|
457
|
-
/** Positions only — the long-standing public shape. */
|
|
458
|
-
track;
|
|
459
|
-
constructor({ resolution, pointsToKeep, fetchTrackFor }) {
|
|
460
|
-
this.accumulatedTrack = connectable(this.input.pipe(throttleTime(resolution), scan((acc, point) => {
|
|
461
|
-
acc.push(point);
|
|
462
|
-
return acc.slice(Math.max(0, acc.length - pointsToKeep));
|
|
463
|
-
}, []), startWith([])), {
|
|
464
|
-
connector: () => new ReplaySubject(1),
|
|
465
|
-
resetOnDisconnect: false
|
|
466
|
-
});
|
|
467
|
-
this.timedTrack = combineLatest([this.initialTrack, this.accumulatedTrack]).pipe(map(([initialTrack, accumulatedTrack]) => [...initialTrack, ...accumulatedTrack]));
|
|
468
|
-
this.track = this.timedTrack.pipe(map((points) => points.map(({ position }) => position)));
|
|
469
|
-
this.accumulatedTrack.connect();
|
|
470
|
-
if (fetchTrackFor) fetchTrack(fetchTrackFor).then((trackGEOJson) => {
|
|
471
|
-
const coordinates = trackGEOJson?.coordinates?.[0];
|
|
472
|
-
if (coordinates) this.initialTrack.next(coordinates.map((position) => ({
|
|
473
|
-
position,
|
|
474
|
-
timestamp: 0
|
|
475
|
-
})));
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
nextLatLngTuple(position, timestamp = Date.now()) {
|
|
479
|
-
this.input.next({
|
|
480
|
-
position,
|
|
481
|
-
timestamp
|
|
482
|
-
});
|
|
483
|
-
this.latestLatLngTuple = timestamp;
|
|
481
|
+
var historyRowPosition = (row) => {
|
|
482
|
+
if (!Array.isArray(row) || row.length < 2) return;
|
|
483
|
+
const value = row[1];
|
|
484
|
+
if (Array.isArray(value) && value.length === 2 && Number.isFinite(value[0]) && Number.isFinite(value[1])) {
|
|
485
|
+
const [longitude, latitude] = value;
|
|
486
|
+
return [latitude, longitude];
|
|
484
487
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
timestamp: timestamps?.[i] ?? 0
|
|
489
|
-
})));
|
|
488
|
+
if (value && typeof value === "object" && "latitude" in value && "longitude" in value) {
|
|
489
|
+
const { latitude, longitude } = value;
|
|
490
|
+
if (Number.isFinite(latitude) && Number.isFinite(longitude)) return [latitude, longitude];
|
|
490
491
|
}
|
|
491
492
|
};
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
493
|
+
/**
|
|
494
|
+
* The name of the vessel (or aircraft, or aton) a context refers to.
|
|
495
|
+
*
|
|
496
|
+
* Deliberately bare — `Ariadne`, not `AIS Ariadne` and not `Own Ship`. This is
|
|
497
|
+
* the v2 Track API's `contextName`, whose spec says "Name of the vessel,
|
|
498
|
+
* aircraft or other context, where known. Not a name for the track itself."
|
|
499
|
+
* Decoration belongs to whoever is drawing a list; see `trackLabel`.
|
|
500
|
+
*
|
|
501
|
+
* `lookup` reads the data model (`app.getPath`). Note the shape: a vessel's
|
|
502
|
+
* `name` is a bare string in the full model, not the `{value}` wrapper that
|
|
503
|
+
* `navigation.position` and friends carry, so both are accepted rather than
|
|
504
|
+
* guessed at.
|
|
505
|
+
*
|
|
506
|
+
* Resolves `name` and nothing else, matching the server's own `findContextName`
|
|
507
|
+
* (`packages/server-admin-ui/src/utils/pathKeys.ts`), which returns undefined
|
|
508
|
+
* rather than substituting an identifier. An MMSI is how a vessel is addressed,
|
|
509
|
+
* not what it is called, so it belongs in `trackLabel` where something has to
|
|
510
|
+
* render — putting it here would make "where known" untrue.
|
|
511
|
+
*/
|
|
512
|
+
function contextName(context, lookup) {
|
|
513
|
+
return unwrapString(lookup(`${context}.name`));
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* A display label for a track, as a chart plotter would show it.
|
|
517
|
+
*
|
|
518
|
+
* TimeZero's GPX exports label tracks `Own Ship` and `AIS <shipname>` — never a
|
|
519
|
+
* serial number — and that is what makes a list of tracks readable. A UI
|
|
520
|
+
* listing `vessels.urn:mrn:imo:mmsi:211234567` is unusable.
|
|
521
|
+
*
|
|
522
|
+
* Distinct from `contextName`: this one *is* a name for the track, so it says
|
|
523
|
+
* whose track it is and where the identity came from. Falls back to the raw
|
|
524
|
+
* context, because a label has to render something.
|
|
525
|
+
*/
|
|
526
|
+
function trackLabel(context, selfContext, lookup) {
|
|
527
|
+
if (context === selfContext) return "Own Ship";
|
|
528
|
+
const identity = contextName(context, lookup) ?? unwrapString(lookup(`${context}.mmsi`)) ?? mmsiFromContext(context);
|
|
529
|
+
return identity === void 0 ? context : `AIS ${identity}`;
|
|
530
|
+
}
|
|
531
|
+
/** Accepts both a bare string and the `{value}` wrapper deltas arrive in. */
|
|
532
|
+
function unwrapString(raw) {
|
|
533
|
+
const value = raw && typeof raw === "object" && "value" in raw ? raw.value : raw;
|
|
534
|
+
if (typeof value !== "string") return;
|
|
535
|
+
const trimmed = value.trim();
|
|
536
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
537
|
+
}
|
|
538
|
+
/** `vessels.urn:mrn:imo:mmsi:211234567` -> `211234567`. */
|
|
539
|
+
function mmsiFromContext(context) {
|
|
540
|
+
return /urn:mrn:imo:mmsi:(\d+)$/.exec(context)?.[1];
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* A track label as a filename a browser can save.
|
|
544
|
+
*
|
|
545
|
+
* A vessel name arrives from an AIS transmission and is not ours to trust in a
|
|
546
|
+
* path, so the characters that could break out of one — separators, quotes,
|
|
547
|
+
* controls — are replaced. Everything else is kept, including non-ASCII: a
|
|
548
|
+
* boat called `Ärger` keeps its name.
|
|
549
|
+
*/
|
|
550
|
+
function gpxFilename(label) {
|
|
551
|
+
const safe = label.replace(/[\p{Cc}\p{Cf}"'\\/:*?<>|]+/gu, "-").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
|
552
|
+
return `${safe === "" ? "track" : safe}.gpx`;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* The ASCII-only form of a filename, for the quoted `Content-Disposition`
|
|
556
|
+
* parameter.
|
|
557
|
+
*
|
|
558
|
+
* An HTTP header carries bytes: anything outside Latin-1 makes `setHeader`
|
|
559
|
+
* throw, and anything outside ASCII is reinterpreted byte-wise by the client,
|
|
560
|
+
* so `Ärger.gpx` arrives as mojibake. The real name travels in the `filename*`
|
|
561
|
+
* parameter; this is the fallback for readers that do not implement it.
|
|
562
|
+
*/
|
|
563
|
+
function asciiFilename(filename) {
|
|
564
|
+
const trimmed = [...filename].map((character) => character < "" ? character : "-").join("").replace(/-{2,}/g, "-").replace(/^-+/, "");
|
|
565
|
+
return trimmed === ".gpx" || trimmed === "" ? "track.gpx" : trimmed;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* A filename as an RFC 8187 extended value, for `filename*`.
|
|
569
|
+
*
|
|
570
|
+
* `encodeURIComponent` is close but not exact: it leaves `!'()*` unescaped,
|
|
571
|
+
* and RFC 8187's `attr-char` set excludes them. A vessel called
|
|
572
|
+
* `Boat (Test)` would otherwise put raw parentheses into the header, which is
|
|
573
|
+
* not a valid extended value.
|
|
574
|
+
*/
|
|
575
|
+
function rfc8187(filename) {
|
|
576
|
+
return encodeURIComponent(filename).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
577
|
+
}
|
|
497
578
|
//#endregion
|
|
498
579
|
//#region src/trackProvider.ts
|
|
499
580
|
/** v2 sends `[west, south, east, north]`; a bounds here is `[lat, lng]` corners. */
|
|
@@ -614,7 +695,10 @@ var longitudeSpan = (longitudes) => {
|
|
|
614
695
|
function createTrackProvider(deps) {
|
|
615
696
|
const matching = async (query) => {
|
|
616
697
|
const store = deps.store();
|
|
617
|
-
if (!store) return
|
|
698
|
+
if (!store) return {
|
|
699
|
+
tracks: /* @__PURE__ */ new Map(),
|
|
700
|
+
resolution: void 0
|
|
701
|
+
};
|
|
618
702
|
const window = toTimeWindow(query);
|
|
619
703
|
const resolution = query.resolution ? totalMilliseconds(query.resolution) : void 0;
|
|
620
704
|
const wanted = query.contexts?.length ? new Set(query.contexts.map(resolveSelf(deps.selfContext()))) : void 0;
|
|
@@ -627,19 +711,22 @@ function createTrackProvider(deps) {
|
|
|
627
711
|
...resolution === void 0 ? {} : { resolution }
|
|
628
712
|
});
|
|
629
713
|
const result = /* @__PURE__ */ new Map();
|
|
630
|
-
|
|
631
|
-
|
|
714
|
+
const contexts = Object.entries(collection).filter(([context]) => !wanted || wanted.has(context));
|
|
715
|
+
const reconciled = await Promise.all(contexts.map(async ([context, stored]) => [context, await deps.reconcileWithHistory(context, stored, window, resolution)]));
|
|
716
|
+
for (const [context, points] of reconciled) {
|
|
632
717
|
if (points.length === 0) continue;
|
|
633
718
|
result.set(context, points);
|
|
634
719
|
}
|
|
635
|
-
return
|
|
720
|
+
return {
|
|
721
|
+
tracks: result,
|
|
722
|
+
resolution
|
|
723
|
+
};
|
|
636
724
|
};
|
|
637
725
|
return {
|
|
638
726
|
async getTracks(query) {
|
|
639
|
-
const matched = await matching(query);
|
|
727
|
+
const { tracks: matched, resolution: requested } = await matching(query);
|
|
640
728
|
const gap = deps.segmentGap();
|
|
641
729
|
const selfContext = deps.selfContext();
|
|
642
|
-
const requested = query.resolution ? totalMilliseconds(query.resolution) : void 0;
|
|
643
730
|
const features = [];
|
|
644
731
|
for (const [context, all] of matched) {
|
|
645
732
|
const { points, resolution: appliedMs } = query.maxPoints === void 0 ? {
|
|
@@ -647,8 +734,11 @@ function createTrackProvider(deps) {
|
|
|
647
734
|
resolution: requested
|
|
648
735
|
} : thinToBudget(all, query.maxPoints, requested);
|
|
649
736
|
if (points.length === 0) continue;
|
|
650
|
-
const
|
|
651
|
-
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);
|
|
652
742
|
features.push({
|
|
653
743
|
type: "Feature",
|
|
654
744
|
geometry: query.geometry === false ? null : {
|
|
@@ -658,11 +748,13 @@ function createTrackProvider(deps) {
|
|
|
658
748
|
properties: {
|
|
659
749
|
context,
|
|
660
750
|
isSelf: context === selfContext,
|
|
661
|
-
|
|
662
|
-
|
|
751
|
+
...name === void 0 ? {} : { contextName: name },
|
|
752
|
+
from: new Date(shaped[0].timestamp).toISOString(),
|
|
753
|
+
to: new Date(shaped[shaped.length - 1].timestamp).toISOString(),
|
|
663
754
|
...bbox ? { bbox } : {},
|
|
664
|
-
pointCount:
|
|
755
|
+
pointCount: shaped.length,
|
|
665
756
|
...appliedMs === void 0 ? {} : { resolution: msToDuration(appliedMs).toString() },
|
|
757
|
+
...chosenEpsilon === void 0 ? {} : { epsilon: chosenEpsilon },
|
|
666
758
|
...query.times ? { coordTimes: segments.map(toIsoTimes) } : {}
|
|
667
759
|
}
|
|
668
760
|
});
|
|
@@ -673,10 +765,71 @@ function createTrackProvider(deps) {
|
|
|
673
765
|
};
|
|
674
766
|
},
|
|
675
767
|
async getTrackContexts(query) {
|
|
676
|
-
return [...(await matching(query)).keys()];
|
|
768
|
+
return [...(await matching(query)).tracks.keys()];
|
|
677
769
|
}
|
|
678
770
|
};
|
|
679
771
|
}
|
|
772
|
+
/**
|
|
773
|
+
* The tolerance to simplify with, or undefined to leave the geometry alone.
|
|
774
|
+
*
|
|
775
|
+
* `epsilon` implies `simplify=true`, so an explicit tolerance is honoured
|
|
776
|
+
* whether or not the flag came with it. `simplify` alone leaves the tolerance
|
|
777
|
+
* to the provider: the spec says "a tolerance suited to the size of the box",
|
|
778
|
+
* and the extent of the track *as thinning left it* is the honest basis for
|
|
779
|
+
* that — a query bbox may be absent, and when present it is what the client
|
|
780
|
+
* searched in rather than what came back. It cannot be the simplified extent,
|
|
781
|
+
* since that is the thing being computed.
|
|
782
|
+
*
|
|
783
|
+
* Chosen from the whole track rather than per segment, so every leg of one
|
|
784
|
+
* track is simplified to the same tolerance and the single reported `epsilon`
|
|
785
|
+
* describes all of them.
|
|
786
|
+
*
|
|
787
|
+
* A non-positive `epsilon` is not reachable through the API — the schema
|
|
788
|
+
* declares `exclusiveMinimum: 0`, so the server rejects it — and is treated
|
|
789
|
+
* here as no tolerance given, falling through to `simplify` if that was asked
|
|
790
|
+
* for. Simplifying by zero would be a no-op reported as if it had done
|
|
791
|
+
* something.
|
|
792
|
+
*/
|
|
793
|
+
function chooseEpsilon(points, query) {
|
|
794
|
+
const explicit = query.epsilon;
|
|
795
|
+
if (explicit !== void 0 && explicit > 0) return explicit;
|
|
796
|
+
return query.simplify === true ? autoEpsilon(points) : void 0;
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* How much of a track's own extent the automatic tolerance may deviate by.
|
|
800
|
+
*
|
|
801
|
+
* One part in a thousand. Provisional while the v2 API is designed in
|
|
802
|
+
* SignalK/signalk-server#2504: it is only a provider-chosen default for
|
|
803
|
+
* `simplify` without an explicit epsilon, and a client that calibrates against
|
|
804
|
+
* the exact ratio rather than reading the reported `epsilon` back would make
|
|
805
|
+
* it expensive to change.
|
|
806
|
+
*/
|
|
807
|
+
var AUTO_EPSILON_DIVISOR = 1e3;
|
|
808
|
+
/**
|
|
809
|
+
* Floor, in metres, under the automatic tolerance.
|
|
810
|
+
*
|
|
811
|
+
* Below the noise of any GPS fix, so it collapses the thousands of near
|
|
812
|
+
* identical points a vessel records at anchor without altering a track that
|
|
813
|
+
* actually moved.
|
|
814
|
+
*/
|
|
815
|
+
var MIN_AUTO_EPSILON = 1;
|
|
816
|
+
/**
|
|
817
|
+
* A tolerance scaled to how much ground a track covers.
|
|
818
|
+
*
|
|
819
|
+
* One part in a thousand of the track's diagonal: enough to drop the jitter of
|
|
820
|
+
* a boat holding station while keeping every turn a passage is made of. A
|
|
821
|
+
* fixed metre value cannot do both — 10 m erases nothing on an ocean crossing
|
|
822
|
+
* and erases a marina approach entirely.
|
|
823
|
+
*/
|
|
824
|
+
function autoEpsilon(points) {
|
|
825
|
+
const bounds = boundsOf(points);
|
|
826
|
+
if (!bounds) return;
|
|
827
|
+
const [west, south, east, north] = bounds;
|
|
828
|
+
const midLat = (south + north) / 2;
|
|
829
|
+
const height = (north - south) * M_PER_DEG;
|
|
830
|
+
const width = (east >= west ? east - west : east + 360 - west) * M_PER_DEG * Math.cos(midLat * Math.PI / 180);
|
|
831
|
+
return Math.max(Math.hypot(width, height) / AUTO_EPSILON_DIVISOR, MIN_AUTO_EPSILON);
|
|
832
|
+
}
|
|
680
833
|
/** v2 accepts the `self` alias; the store keys on the qualified context. */
|
|
681
834
|
var resolveSelf = (selfContext) => (context) => context === "self" || context === "vessels.self" ? selfContext : context;
|
|
682
835
|
//#endregion
|
|
@@ -12058,16 +12211,20 @@ var SqliteTrackStore = class {
|
|
|
12058
12211
|
}))));
|
|
12059
12212
|
}
|
|
12060
12213
|
/**
|
|
12061
|
-
* Tracks
|
|
12214
|
+
* Tracks matching the spatial predicate, which depends on who is asking.
|
|
12215
|
+
*
|
|
12216
|
+
* `intersects: false` — the v1 routes — matches a track's *last* position:
|
|
12217
|
+
* "which vessels are near me now". `intersects: true` — the v2 Track API —
|
|
12218
|
+
* matches a track that passed through the box at any point in the window,
|
|
12219
|
+
* including one that has since left.
|
|
12062
12220
|
*
|
|
12063
12221
|
* Two filters run, and they are not redundant despite both testing bounds:
|
|
12064
12222
|
*
|
|
12065
12223
|
* - `contextsInBounds` asks the cell index which contexts have *any* position
|
|
12066
12224
|
* near the box. It is a cheap prefilter that keeps the query off every row
|
|
12067
12225
|
* in the table, and it decides nothing on its own.
|
|
12068
|
-
* - `matcher` — the same predicate the in-memory store uses — then
|
|
12069
|
-
*
|
|
12070
|
-
* "in this box". That test is authoritative.
|
|
12226
|
+
* - `matcher` — the same predicate the in-memory store uses — then applies
|
|
12227
|
+
* whichever rule the caller asked for. That test is authoritative.
|
|
12071
12228
|
*
|
|
12072
12229
|
* Because `matcher` has the final say, dropping the exact-bounds check inside
|
|
12073
12230
|
* `contextsInBounds` does not change any result; it only widens the candidate
|
|
@@ -12131,10 +12288,16 @@ var SqliteTrackStore = class {
|
|
|
12131
12288
|
if (current.length > 0) result.push(current);
|
|
12132
12289
|
return result;
|
|
12133
12290
|
}
|
|
12134
|
-
prune(maxAge) {
|
|
12291
|
+
prune(maxAge, keep) {
|
|
12135
12292
|
const cutoff = Date.now() - maxAge;
|
|
12136
|
-
this.db.prepare("
|
|
12137
|
-
|
|
12293
|
+
const dropped = this.db.prepare("SELECT context FROM positions GROUP BY context HAVING MAX(timestamp) < ? AND context IS NOT ?").all(cutoff, keep ?? null);
|
|
12294
|
+
this.db.prepare("DELETE FROM positions WHERE context IN (SELECT context FROM positions GROUP BY context HAVING MAX(timestamp) < ?) AND context IS NOT ?").run(cutoff, keep ?? null);
|
|
12295
|
+
for (const { context } of dropped) this.lastStored.delete(context);
|
|
12296
|
+
if (this.retention > 0) {
|
|
12297
|
+
const oldest = Date.now() - this.retention;
|
|
12298
|
+
if (keep === void 0) this.db.prepare("DELETE FROM positions WHERE timestamp < ?").run(oldest);
|
|
12299
|
+
else this.db.prepare("DELETE FROM positions WHERE timestamp < ? AND context IS ?").run(oldest, keep);
|
|
12300
|
+
}
|
|
12138
12301
|
}
|
|
12139
12302
|
close() {
|
|
12140
12303
|
this.db.close();
|
|
@@ -12368,11 +12531,11 @@ var SourceWatch = class {
|
|
|
12368
12531
|
};
|
|
12369
12532
|
//#endregion
|
|
12370
12533
|
//#region src/stateGate.ts
|
|
12371
|
-
|
|
12372
|
-
|
|
12373
|
-
|
|
12374
|
-
|
|
12375
|
-
|
|
12534
|
+
var DEFAULT_PAUSE_STATES = [
|
|
12535
|
+
"moored",
|
|
12536
|
+
"not-under-way",
|
|
12537
|
+
"aground"
|
|
12538
|
+
];
|
|
12376
12539
|
/**
|
|
12377
12540
|
* The states a boat is plausibly parked in, offered in the plugin UI.
|
|
12378
12541
|
*
|
|
@@ -12437,87 +12600,515 @@ var StateGate = class {
|
|
|
12437
12600
|
}
|
|
12438
12601
|
};
|
|
12439
12602
|
//#endregion
|
|
12440
|
-
//#region src/
|
|
12603
|
+
//#region src/gpx.ts
|
|
12441
12604
|
/**
|
|
12442
|
-
*
|
|
12605
|
+
* GPX 1.1 serialisation and parsing for recorded tracks.
|
|
12443
12606
|
*
|
|
12444
|
-
*
|
|
12445
|
-
*
|
|
12446
|
-
*
|
|
12447
|
-
*
|
|
12448
|
-
*
|
|
12607
|
+
* Hand-written rather than through a library: the vocabulary is `gpx`,
|
|
12608
|
+
* `metadata`, `trk`, `name`, `extensions`, `signalk:context`, `trkseg`,
|
|
12609
|
+
* `trkpt` and `time` — nine element types — and a dependency for that would be
|
|
12610
|
+
* more surface than the code it replaces.
|
|
12611
|
+
* What matters is getting the details right, and those are documented below.
|
|
12449
12612
|
*/
|
|
12450
|
-
var
|
|
12451
|
-
|
|
12452
|
-
return config.bootstrapFromHistory === false ? "memory" : "history";
|
|
12453
|
-
};
|
|
12454
|
-
var toLngLat = ([lat, lng]) => [lng, lat];
|
|
12455
|
-
var DEFAULT_RESOLUTION = 6e4;
|
|
12456
|
-
var DEFAULT_POINTS_TO_KEEP = 120;
|
|
12457
|
-
var DEFAULT_MAX_AGE = 600;
|
|
12458
|
-
var DEFAULT_MAX_RADIUS = 5e4;
|
|
12459
|
-
var DEFAULT_SEGMENT_GAP_MINUTES = 0;
|
|
12460
|
-
var SOURCE_STATUS_INTERVAL_MS = 3e4;
|
|
12613
|
+
var LAT = 0;
|
|
12614
|
+
var LNG = 1;
|
|
12461
12615
|
/**
|
|
12462
|
-
*
|
|
12463
|
-
*
|
|
12464
|
-
*
|
|
12465
|
-
*
|
|
12466
|
-
*
|
|
12467
|
-
*
|
|
12468
|
-
|
|
12469
|
-
|
|
12616
|
+
* Serialise tracks as a GPX 1.1 document.
|
|
12617
|
+
*
|
|
12618
|
+
* Several tracks go in one file, each as its own `<trk>`. GPX allows it, and
|
|
12619
|
+
* it is what makes "select three trips, export" produce one file rather than
|
|
12620
|
+
* three — TimeZero writes one file per vessel, which is a choice rather than a
|
|
12621
|
+
* constraint of the format.
|
|
12622
|
+
*
|
|
12623
|
+
* The Signal K context travels in `<extensions>`. TimeZero puts an opaque
|
|
12624
|
+
* GUID there and drops the MMSI entirely, so its exports cannot round-trip an
|
|
12625
|
+
* identity; carrying the context means ours can, while staying readable by
|
|
12626
|
+
* anything else, since `<extensions>` is ignorable by specification.
|
|
12627
|
+
*/
|
|
12628
|
+
function toGpx(tracks, now = /* @__PURE__ */ new Date()) {
|
|
12629
|
+
const lines = [
|
|
12630
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
12631
|
+
"<gpx version=\"1.1\" creator=\"@signalk/tracks-plugin\" xmlns=\"http://www.topografix.com/GPX/1/1\">",
|
|
12632
|
+
" <metadata>",
|
|
12633
|
+
` <time>${iso(now.getTime())}</time>`,
|
|
12634
|
+
" </metadata>"
|
|
12635
|
+
];
|
|
12636
|
+
for (const track of tracks) {
|
|
12637
|
+
const segments = track.segments.map((points) => points.filter((p) => inRange(p.position[LAT], p.position[LNG]))).filter((points) => points.length > 0);
|
|
12638
|
+
if (segments.length === 0) continue;
|
|
12639
|
+
lines.push(" <trk>");
|
|
12640
|
+
lines.push(` <name>${escapeXml(track.name)}</name>`);
|
|
12641
|
+
if (track.context !== void 0) {
|
|
12642
|
+
lines.push(" <extensions>");
|
|
12643
|
+
lines.push(` <signalk:context xmlns:signalk="${NAMESPACE}">${escapeXml(track.context)}</signalk:context>`);
|
|
12644
|
+
lines.push(" </extensions>");
|
|
12645
|
+
}
|
|
12646
|
+
for (const points of segments) {
|
|
12647
|
+
lines.push(" <trkseg>");
|
|
12648
|
+
for (const { position, timestamp } of points) {
|
|
12649
|
+
lines.push(` <trkpt lat="${coordinate(position[LAT])}" lon="${coordinate(position[LNG])}">`);
|
|
12650
|
+
lines.push(` <time>${iso(timestamp)}</time>`);
|
|
12651
|
+
lines.push(" </trkpt>");
|
|
12652
|
+
}
|
|
12653
|
+
lines.push(" </trkseg>");
|
|
12654
|
+
}
|
|
12655
|
+
lines.push(" </trk>");
|
|
12656
|
+
}
|
|
12657
|
+
lines.push("</gpx>");
|
|
12658
|
+
return lines.join("\n") + "\n";
|
|
12659
|
+
}
|
|
12660
|
+
/** GPX's own namespace, which structural elements must be in. */
|
|
12661
|
+
var GPX_NAMESPACE = "http://www.topografix.com/GPX/1/1";
|
|
12662
|
+
/** Namespace for the one extension element this plugin writes. */
|
|
12663
|
+
var NAMESPACE = "https://signalk.org/specification/1.7.0/";
|
|
12664
|
+
/**
|
|
12665
|
+
* Parse a GPX document into tracks.
|
|
12666
|
+
*
|
|
12667
|
+
* Structure follows GPX's own hierarchy: the root is `<gpx>`, tracks are its
|
|
12668
|
+
* children, segments are a track's, points are a segment's. Anything else —
|
|
12669
|
+
* a `<trkseg>` inside an `<extensions>` payload, a `<trk>` below the root — is
|
|
12670
|
+
* not track data, and a namespace check alone cannot tell the difference,
|
|
12671
|
+
* since a payload declaring no namespace inherits GPX's own.
|
|
12672
|
+
*
|
|
12673
|
+
* Deliberately forgiving about structure and strict about values: elements it
|
|
12674
|
+
* does not recognise are ignored rather than rejected, but a coordinate that is
|
|
12675
|
+
* not an `xsd:decimal` in range, or a time that is not an `xsd:dateTime`, is
|
|
12676
|
+
* refused. One NaN reaching the store stretches every bounding box that track
|
|
12677
|
+
* appears in.
|
|
12678
|
+
*
|
|
12679
|
+
* **Points are sorted by time within each segment.** TimeZero paginates its
|
|
12680
|
+
* export in blocks of 150 and the block seams overlap: the last point of a
|
|
12681
|
+
* block can be a few milliseconds newer than the first point of the next. Out
|
|
12682
|
+
* of 5,550 points in a real export, 16 pairs are out of order, every one on a
|
|
12683
|
+
* 150-point boundary. Anything downstream that assumes ordering — time-window
|
|
12684
|
+
* queries, the history reconciliation — would quietly misbehave on such a file.
|
|
12685
|
+
*
|
|
12686
|
+
* Sorting stops at the segment boundary, deliberately: a segment is a stretch
|
|
12687
|
+
* of continuous recording, and reordering points across a gap would merge two
|
|
12688
|
+
* passages into one line. So `segments.flat()` is not globally sorted for a
|
|
12689
|
+
* file whose segments interleave in time, and a caller that needs that must
|
|
12690
|
+
* sort for itself.
|
|
12691
|
+
*
|
|
12692
|
+
* A document the parser rejects yields no tracks rather than throwing: this
|
|
12693
|
+
* reads files the plugin did not write, and an import that fails should say so
|
|
12694
|
+
* by returning nothing rather than by taking the caller down.
|
|
12695
|
+
*/
|
|
12696
|
+
function fromGpx(xml) {
|
|
12697
|
+
let document;
|
|
12698
|
+
try {
|
|
12699
|
+
document = new DOMParser({ onError: (level) => {
|
|
12700
|
+
if (level !== "warning") throw new Error("malformed XML");
|
|
12701
|
+
} }).parseFromString(xml, "text/xml");
|
|
12702
|
+
} catch {
|
|
12703
|
+
return [];
|
|
12704
|
+
}
|
|
12705
|
+
const root = document.documentElement;
|
|
12706
|
+
if (root === null || root.localName !== "gpx" || !isGpxNamespace(root)) return [];
|
|
12707
|
+
const tracks = [];
|
|
12708
|
+
for (const trk of gpxChildren(root, "trk")) {
|
|
12709
|
+
const segments = [];
|
|
12710
|
+
for (const trkseg of gpxChildren(trk, "trkseg")) {
|
|
12711
|
+
const points = readPoints(trkseg);
|
|
12712
|
+
if (points.length > 0) segments.push(points);
|
|
12713
|
+
}
|
|
12714
|
+
if (segments.length === 0) continue;
|
|
12715
|
+
const name = childText(trk, "name").trim() || "Track";
|
|
12716
|
+
const context = signalKContext(trk);
|
|
12717
|
+
tracks.push({
|
|
12718
|
+
name,
|
|
12719
|
+
segments,
|
|
12720
|
+
...context === void 0 ? {} : { context }
|
|
12721
|
+
});
|
|
12722
|
+
}
|
|
12723
|
+
return tracks;
|
|
12724
|
+
}
|
|
12470
12725
|
/**
|
|
12471
|
-
*
|
|
12472
|
-
* empty.
|
|
12726
|
+
* The text of a direct GPX child with this local name.
|
|
12473
12727
|
*
|
|
12474
|
-
*
|
|
12475
|
-
*
|
|
12476
|
-
* a windowless request — draw the recent trail — and an explicit `from`,
|
|
12477
|
-
* `duration` or `timespan` reaches further.
|
|
12728
|
+
* Direct rather than `getElementsByTagName`, which would reach a `<name>`
|
|
12729
|
+
* inside an `<extensions>` payload and label the track with a vendor's string.
|
|
12478
12730
|
*/
|
|
12479
|
-
|
|
12480
|
-
|
|
12481
|
-
|
|
12482
|
-
|
|
12483
|
-
var BOOTSTRAP_MAX_NO_PROVIDER = 3;
|
|
12731
|
+
function childText(trk, local) {
|
|
12732
|
+
for (const child of Array.from(trk.childNodes)) if (isElement(child) && child.localName === local && isGpxNamespace(child)) return child.textContent ?? "";
|
|
12733
|
+
return "";
|
|
12734
|
+
}
|
|
12484
12735
|
/**
|
|
12485
|
-
*
|
|
12486
|
-
*
|
|
12487
|
-
*
|
|
12736
|
+
* The Signal K context declared in a track's own `<extensions>`.
|
|
12737
|
+
*
|
|
12738
|
+
* Matched on namespace URI, which is what the prefix actually means: GPX
|
|
12739
|
+
* extensions are an open field any vendor may write into, so a foreign
|
|
12740
|
+
* `<context>` — or one under a different prefix bound elsewhere — must not
|
|
12741
|
+
* claim the track's identity. Read only from the track's direct
|
|
12742
|
+
* `<extensions>` children, because a trackpoint carries its own and those
|
|
12743
|
+
* belong to that point.
|
|
12488
12744
|
*/
|
|
12489
|
-
|
|
12490
|
-
|
|
12491
|
-
|
|
12492
|
-
const
|
|
12493
|
-
|
|
12745
|
+
function signalKContext(trk) {
|
|
12746
|
+
for (const child of Array.from(trk.childNodes)) {
|
|
12747
|
+
if (!isElement(child) || child.localName !== "extensions" || !isGpxNamespace(child)) continue;
|
|
12748
|
+
for (const candidate of Array.from(child.getElementsByTagNameNS(NAMESPACE, "context"))) {
|
|
12749
|
+
const value = (candidate.textContent ?? "").trim();
|
|
12750
|
+
if (value !== "") return value;
|
|
12751
|
+
}
|
|
12494
12752
|
}
|
|
12495
|
-
}
|
|
12496
|
-
|
|
12497
|
-
|
|
12498
|
-
|
|
12499
|
-
|
|
12500
|
-
|
|
12501
|
-
|
|
12502
|
-
|
|
12503
|
-
|
|
12504
|
-
|
|
12505
|
-
|
|
12753
|
+
}
|
|
12754
|
+
/** The points of one `<trkseg>`, oldest first. */
|
|
12755
|
+
function readPoints(trkseg) {
|
|
12756
|
+
const points = [];
|
|
12757
|
+
for (const trkpt of gpxChildren(trkseg, "trkpt")) {
|
|
12758
|
+
const latitude = decimal(trkpt.getAttribute("lat") ?? void 0);
|
|
12759
|
+
const longitude = decimal(trkpt.getAttribute("lon") ?? void 0);
|
|
12760
|
+
if (!inRange(latitude, longitude)) continue;
|
|
12761
|
+
const raw = childText(trkpt, "time").trim();
|
|
12762
|
+
const timestamp = raw === "" ? NaN : dateTime(raw);
|
|
12763
|
+
points.push({
|
|
12764
|
+
position: [latitude, longitude],
|
|
12765
|
+
timestamp: Number.isFinite(timestamp) ? timestamp : 0
|
|
12766
|
+
});
|
|
12767
|
+
}
|
|
12768
|
+
return points.sort((a, b) => a.timestamp - b.timestamp);
|
|
12769
|
+
}
|
|
12506
12770
|
/**
|
|
12507
|
-
*
|
|
12508
|
-
*
|
|
12509
|
-
*
|
|
12510
|
-
*
|
|
12771
|
+
* Direct GPX children of `root` with this local name.
|
|
12772
|
+
*
|
|
12773
|
+
* Direct, because GPX fixes the hierarchy: a `<trkseg>` is a child of `<trk>`
|
|
12774
|
+
* and a `<trkpt>` a child of `<trkseg>`. Scanning descendants instead lets an
|
|
12775
|
+
* `<extensions>` payload contribute track data — and a payload that declares
|
|
12776
|
+
* no namespace of its own inherits GPX's, so a namespace check cannot catch
|
|
12777
|
+
* it. A vendor's `<trkseg>` inside `<extensions>` became a phantom segment.
|
|
12511
12778
|
*/
|
|
12512
|
-
|
|
12513
|
-
|
|
12514
|
-
|
|
12515
|
-
|
|
12516
|
-
|
|
12517
|
-
|
|
12518
|
-
|
|
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
|
|
12882
|
+
//#region src/tracks.ts
|
|
12883
|
+
/**
|
|
12884
|
+
* The in-memory store: a bounded sliding window of positions per context, held
|
|
12885
|
+
* in RxJS accumulators and lost on restart.
|
|
12886
|
+
*
|
|
12887
|
+
* No longer used by the plugin, which always records to SQLite. It remains as
|
|
12888
|
+
* the client-side `TrackAccumulator` this package exports, where a browser
|
|
12889
|
+
* holding one vessel's recent track in memory is exactly the right shape.
|
|
12890
|
+
*/
|
|
12891
|
+
var Tracks = class {
|
|
12892
|
+
tracks = {};
|
|
12893
|
+
debug;
|
|
12894
|
+
config;
|
|
12895
|
+
constructor(config, debug) {
|
|
12896
|
+
if (debug.enabled) debug(JSON.stringify(config));
|
|
12897
|
+
this.config = config;
|
|
12898
|
+
this.debug = debug;
|
|
12899
|
+
}
|
|
12900
|
+
newPosition(context, position, timestamp) {
|
|
12901
|
+
this.getAccumulator(context)?.nextLatLngTuple(position, timestamp);
|
|
12902
|
+
}
|
|
12903
|
+
initialTrack(context, track, timestamps) {
|
|
12904
|
+
this.getAccumulator(context)?.setInitialTrack(track, timestamps);
|
|
12905
|
+
}
|
|
12906
|
+
getAccumulator(context, createIfMissing = true) {
|
|
12907
|
+
if (context.indexOf("vessels.") === -1 && context.indexOf("aircraft.") === -1) return;
|
|
12908
|
+
let result = this.tracks[context];
|
|
12909
|
+
if (!result && createIfMissing) {
|
|
12910
|
+
const accParams = this.config.fetchInitialTrack ? {
|
|
12911
|
+
...this.config,
|
|
12912
|
+
fetchTrackFor: context
|
|
12913
|
+
} : { ...this.config };
|
|
12914
|
+
result = this.tracks[context] = new TrackAccumulator(accParams);
|
|
12915
|
+
}
|
|
12916
|
+
return result;
|
|
12917
|
+
}
|
|
12918
|
+
get(context, window) {
|
|
12919
|
+
return this.getTimed(context, window).then((points) => points.map(({ position }) => position));
|
|
12920
|
+
}
|
|
12921
|
+
/**
|
|
12922
|
+
* Track points with their timestamps, optionally narrowed to a time window.
|
|
12923
|
+
*
|
|
12924
|
+
* Rejects with EmptyError if the stream completes without emitting; the route
|
|
12925
|
+
* handlers turn that into the same 404 as an unknown context.
|
|
12926
|
+
*/
|
|
12927
|
+
getTimed(context, window) {
|
|
12928
|
+
const accumulator = this.getAccumulator(context, false);
|
|
12929
|
+
if (!accumulator) return Promise.reject(/* @__PURE__ */ new Error(`No track accumulator for ${context}`));
|
|
12930
|
+
return firstValueFrom(accumulator.timedTrack).then((points) => window ? points.filter(({ timestamp }) => timestamp >= window.from && (window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to)) : points);
|
|
12931
|
+
}
|
|
12932
|
+
getAllTracks(query) {
|
|
12933
|
+
return Promise.all(Object.keys(this.tracks).map((context) => this.getTimed(context, query?.window).then((points) => ({
|
|
12934
|
+
context,
|
|
12935
|
+
track: thin(points, query?.resolution).map(({ position }) => position)
|
|
12936
|
+
}))));
|
|
12937
|
+
}
|
|
12938
|
+
async getFilteredTracks(params, selfPosition, debug, query) {
|
|
12939
|
+
const timed = await this.getFilteredTimedTracks(params, selfPosition, debug, query);
|
|
12940
|
+
return Object.fromEntries(Object.entries(timed).map(([context, points]) => [context, points.map(({ position }) => position)]));
|
|
12941
|
+
}
|
|
12942
|
+
/**
|
|
12943
|
+
* The same filtering as `getFilteredTracks`, keeping each point's timestamp.
|
|
12944
|
+
*
|
|
12945
|
+
* The untimed form is derived from this one so the two cannot disagree about
|
|
12946
|
+
* which tracks match — the filter runs once, here.
|
|
12947
|
+
*/
|
|
12948
|
+
async getFilteredTimedTracks(params, selfPosition, debug, query) {
|
|
12949
|
+
this.debug(params);
|
|
12950
|
+
this.debug("Self position", selfPosition);
|
|
12951
|
+
const matcher = createMatcher(params, selfPosition, debug);
|
|
12952
|
+
const contexts = Object.keys(this.tracks);
|
|
12953
|
+
return (await Promise.all(contexts.map((context) => this.getTimed(context, query?.window).then((points) => ({
|
|
12954
|
+
context,
|
|
12955
|
+
points: thin(points, query?.resolution)
|
|
12956
|
+
}))))).reduce((acc, { context, points }) => {
|
|
12957
|
+
if (matcher(points.map(({ position }) => position))) acc[context] = points;
|
|
12958
|
+
return acc;
|
|
12959
|
+
}, {});
|
|
12960
|
+
}
|
|
12961
|
+
prune(maxAge, keep) {
|
|
12962
|
+
const cutoff = Date.now() - maxAge;
|
|
12963
|
+
const deleted = [];
|
|
12964
|
+
Object.entries(this.tracks).forEach(([key, value]) => {
|
|
12965
|
+
if (key !== keep && value.latestLatLngTuple < cutoff) {
|
|
12966
|
+
delete this.tracks[key];
|
|
12967
|
+
deleted.push(key);
|
|
12968
|
+
}
|
|
12969
|
+
});
|
|
12970
|
+
if (this.debug.enabled) this.debug(`deleted tracks for ${deleted.join(", ")}`);
|
|
12971
|
+
}
|
|
12972
|
+
};
|
|
12973
|
+
var TrackAccumulator = class {
|
|
12974
|
+
initialTrack = new BehaviorSubject([]);
|
|
12975
|
+
input = new Subject();
|
|
12976
|
+
latestLatLngTuple = 0;
|
|
12977
|
+
accumulatedTrack;
|
|
12978
|
+
/** Timestamped points, used to answer time-window queries. */
|
|
12979
|
+
timedTrack;
|
|
12980
|
+
/** Positions only — the long-standing public shape. */
|
|
12981
|
+
track;
|
|
12982
|
+
constructor({ resolution, pointsToKeep, fetchTrackFor }) {
|
|
12983
|
+
this.accumulatedTrack = connectable(this.input.pipe(throttleTime(resolution), scan((acc, point) => {
|
|
12984
|
+
acc.push(point);
|
|
12985
|
+
return acc.slice(Math.max(0, acc.length - pointsToKeep));
|
|
12986
|
+
}, []), startWith([])), {
|
|
12987
|
+
connector: () => new ReplaySubject(1),
|
|
12988
|
+
resetOnDisconnect: false
|
|
12989
|
+
});
|
|
12990
|
+
this.timedTrack = combineLatest([this.initialTrack, this.accumulatedTrack]).pipe(map(([initialTrack, accumulatedTrack]) => [...initialTrack, ...accumulatedTrack]));
|
|
12991
|
+
this.track = this.timedTrack.pipe(map((points) => points.map(({ position }) => position)));
|
|
12992
|
+
this.accumulatedTrack.connect();
|
|
12993
|
+
if (fetchTrackFor) fetchTrack(fetchTrackFor).then((trackGEOJson) => {
|
|
12994
|
+
const coordinates = trackGEOJson?.coordinates?.[0];
|
|
12995
|
+
if (coordinates) this.initialTrack.next(coordinates.map((position) => ({
|
|
12996
|
+
position,
|
|
12997
|
+
timestamp: 0
|
|
12998
|
+
})));
|
|
12999
|
+
});
|
|
13000
|
+
}
|
|
13001
|
+
nextLatLngTuple(position, timestamp = Date.now()) {
|
|
13002
|
+
this.input.next({
|
|
13003
|
+
position,
|
|
13004
|
+
timestamp
|
|
13005
|
+
});
|
|
13006
|
+
this.latestLatLngTuple = timestamp;
|
|
13007
|
+
}
|
|
13008
|
+
setInitialTrack(track, timestamps) {
|
|
13009
|
+
this.initialTrack.next(track.map((position, i) => ({
|
|
13010
|
+
position,
|
|
13011
|
+
timestamp: timestamps?.[i] ?? 0
|
|
13012
|
+
})));
|
|
13013
|
+
}
|
|
13014
|
+
};
|
|
13015
|
+
var fetchTrack = (context) => {
|
|
13016
|
+
const contextParts = context.split(".");
|
|
13017
|
+
if (contextParts[0] !== "vessels") return Promise.resolve({});
|
|
13018
|
+
return fetch(`/signalk/v1/api/vessels/${contextParts[1]}/track`, { credentials: "include" }).then((r) => r.status === 200 ? r.json() : Promise.resolve({}));
|
|
13019
|
+
};
|
|
13020
|
+
//#endregion
|
|
13021
|
+
//#region src/index.ts
|
|
13022
|
+
var toLngLat = ([lat, lng]) => [lng, lat];
|
|
13023
|
+
var DEFAULT_RESOLUTION = 6e4;
|
|
13024
|
+
var DEFAULT_AIS_RETENTION_DAYS = 30;
|
|
13025
|
+
var PRUNE_INTERVAL_MS = 36e5;
|
|
13026
|
+
var DEFAULT_SEGMENT_GAP_MINUTES = 0;
|
|
13027
|
+
var SOURCE_STATUS_INTERVAL_MS = 3e4;
|
|
13028
|
+
/**
|
|
13029
|
+
* How long a history provider gets to answer a query before the store answers
|
|
13030
|
+
* alone.
|
|
13031
|
+
*
|
|
13032
|
+
* The provider is an enrichment, not a dependency: a track is still correct
|
|
13033
|
+
* without it, just coarser. Long enough for a database that has to warm up,
|
|
13034
|
+
* short enough that a wedged provider does not hold a request open.
|
|
13035
|
+
*/
|
|
13036
|
+
var HISTORY_QUERY_TIMEOUT_MS = 5e3;
|
|
13037
|
+
/**
|
|
13038
|
+
* How far back a query with no window of its own reaches when the store is
|
|
13039
|
+
* empty.
|
|
13040
|
+
*
|
|
13041
|
+
* There is nothing else to derive a span from, and asking a provider for all
|
|
13042
|
+
* of time would be expensive on a large one. A day covers the usual reason for
|
|
13043
|
+
* a windowless request — draw the recent trail — and an explicit `from`,
|
|
13044
|
+
* `duration` or `timespan` reaches further.
|
|
13045
|
+
*/
|
|
13046
|
+
var WINDOWLESS_HISTORY_SPAN_MS = 864e5;
|
|
13047
|
+
/**
|
|
13048
|
+
* The earliest instant the existence probe asks about.
|
|
13049
|
+
*
|
|
13050
|
+
* The probe has to name a bound: the History API's time range has no
|
|
13051
|
+
* unbounded form, every branch of `TimeRangeParams` carries one. A provider
|
|
13052
|
+
* filters its context list by the range it is given — questdb builds a SQL
|
|
13053
|
+
* `WHERE` from it — so any bound later than the provider's oldest row can
|
|
13054
|
+
* still miss a vessel and 404 it, which is the case the probe exists to catch.
|
|
13055
|
+
*
|
|
13056
|
+
* The Unix epoch is the bound rather than some span of years: it predates
|
|
13057
|
+
* satellite navigation, so no position fix can lie before it, and unlike a
|
|
13058
|
+
* fixed "wide enough" window it cannot be outlived. The cost lands only on the
|
|
13059
|
+
* branch that was already about to 404, and the answer is cached.
|
|
13060
|
+
*/
|
|
13061
|
+
var EXISTENCE_PROBE_FROM_MS = 0;
|
|
13062
|
+
/**
|
|
13063
|
+
* How long a provider's context list is reused before asking again.
|
|
13064
|
+
*
|
|
13065
|
+
* The probe runs only for a vessel about to 404, which is precisely the
|
|
13066
|
+
* request a client can repeat without limit — the routes are open, so
|
|
13067
|
+
* enumerating vessel ids would otherwise drive one epoch-wide `getContexts`
|
|
13068
|
+
* per request, each able to block for the timeout.
|
|
13069
|
+
*
|
|
13070
|
+
* The whole list is cached rather than a per-context answer, or enumeration
|
|
13071
|
+
* would simply fill the cache with distinct keys and query just as often. The
|
|
13072
|
+
* cost is that a vessel a provider learns about becomes visible up to this
|
|
13073
|
+
* long after the fact, which is not a delay anyone can perceive in a track
|
|
13074
|
+
* that is already minutes old.
|
|
13075
|
+
*/
|
|
13076
|
+
var KNOWN_CONTEXTS_TTL_MS = 3e4;
|
|
13077
|
+
/**
|
|
13078
|
+
* How long a *failed* context query is remembered.
|
|
13079
|
+
*
|
|
13080
|
+
* Short, so a provider that recovers is noticed quickly, but not zero: with
|
|
13081
|
+
* the entry simply dropped, a provider that hangs is asked again by every
|
|
13082
|
+
* subsequent miss while the earlier calls are still pending — reinstating per
|
|
13083
|
+
* request exactly the cost the cache exists to prevent.
|
|
13084
|
+
*/
|
|
13085
|
+
var KNOWN_CONTEXTS_FAILURE_TTL_MS = 1e3;
|
|
13086
|
+
/**
|
|
13087
|
+
* Config values arrive from the plugin UI as numbers, but a hand-edited
|
|
13088
|
+
* settings file can supply strings. Accept both, reject anything non-finite so
|
|
13089
|
+
* a bad value falls back to the default instead of poisoning arithmetic with NaN.
|
|
13090
|
+
*/
|
|
13091
|
+
var toNumber = (value) => {
|
|
13092
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
13093
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
13094
|
+
const parsed = Number(value);
|
|
13095
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
12519
13096
|
}
|
|
12520
13097
|
};
|
|
13098
|
+
var notAvailable = (res) => {
|
|
13099
|
+
res.status(404);
|
|
13100
|
+
res.json({ message: "Tracks API not available because tracks plugin is not enabled" });
|
|
13101
|
+
};
|
|
13102
|
+
/**
|
|
13103
|
+
* The history provider could not answer, and nothing else could either.
|
|
13104
|
+
*
|
|
13105
|
+
* Distinct from a missing track: a 404 asserts that no source knows the
|
|
13106
|
+
* vessel, which a failed provider read cannot support. Reporting the failure
|
|
13107
|
+
* as an empty track would present an outage as "this vessel has no positions
|
|
13108
|
+
* here" — the one answer that is certainly wrong.
|
|
13109
|
+
*/
|
|
13110
|
+
var HistoryUnavailableError = class extends Error {};
|
|
13111
|
+
var errorDetail = (err) => err instanceof Error && err.stack ? err.stack : String(err);
|
|
12521
13112
|
/**
|
|
12522
13113
|
* A window covering everything the store holds, up to now.
|
|
12523
13114
|
*
|
|
@@ -12547,9 +13138,15 @@ function windowSpanning(stored, fallbackMs) {
|
|
|
12547
13138
|
* promise wins. A race leaves it armed, holding the event loop open for the
|
|
12548
13139
|
* remainder of the timeout on every call that succeeds.
|
|
12549
13140
|
*/
|
|
13141
|
+
/**
|
|
13142
|
+
* A bound expired. Distinct from the error a provider itself raises, because
|
|
13143
|
+
* "did not answer in time" and "is not installed" are different answers and
|
|
13144
|
+
* only one of them is an outage.
|
|
13145
|
+
*/
|
|
13146
|
+
var HistoryTimeoutError = class extends Error {};
|
|
12550
13147
|
function withTimeout(promise, ms) {
|
|
12551
13148
|
return new Promise((resolve, reject) => {
|
|
12552
|
-
const timer = setTimeout(() => reject(
|
|
13149
|
+
const timer = setTimeout(() => reject(new HistoryTimeoutError(`timed out after ${ms}ms`)), ms);
|
|
12553
13150
|
promise.then((value) => {
|
|
12554
13151
|
clearTimeout(timer);
|
|
12555
13152
|
resolve(value);
|
|
@@ -12560,6 +13157,48 @@ function withTimeout(promise, ms) {
|
|
|
12560
13157
|
});
|
|
12561
13158
|
}
|
|
12562
13159
|
/**
|
|
13160
|
+
* Whether a history provider holds anything at all for a context.
|
|
13161
|
+
*
|
|
13162
|
+
* Asked only when a track is about to 404: `getValues` narrowed to a window
|
|
13163
|
+
* cannot tell "this vessel is unknown" from "this vessel has nothing *here*",
|
|
13164
|
+
* and 404ing on the second is wrong — the contract is that a 404 means neither
|
|
13165
|
+
* source knows the vessel. A vessel whose history lies wholly outside the
|
|
13166
|
+
* asked window is known, and deserves an empty track.
|
|
13167
|
+
*
|
|
13168
|
+
* Best-effort, like every other provider call here: a provider that lacks the
|
|
13169
|
+
* method, errors, or hangs leaves the 404 standing rather than holding the
|
|
13170
|
+
* request open.
|
|
13171
|
+
*/
|
|
13172
|
+
async function historyKnowsContext(app, context, debug, cache) {
|
|
13173
|
+
const getHistoryApi = app.getHistoryApi;
|
|
13174
|
+
if (!getHistoryApi) return false;
|
|
13175
|
+
try {
|
|
13176
|
+
const now = Date.now();
|
|
13177
|
+
const cached = cache.current;
|
|
13178
|
+
if (cached === void 0 || now - cached.at >= KNOWN_CONTEXTS_TTL_MS) {
|
|
13179
|
+
const contexts = withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider).then((historyApi) => historyApi.getContexts ? historyApi.getContexts({
|
|
13180
|
+
from: Temporal.Instant.from((/* @__PURE__ */ new Date(EXISTENCE_PROBE_FROM_MS)).toISOString()),
|
|
13181
|
+
to: Temporal.Instant.from(new Date(now).toISOString())
|
|
13182
|
+
}) : []).then((listed) => new Set((listed ?? []).filter((c) => typeof c === "string").map((c) => c === "vessels.self" ? app.selfContext : c))), HISTORY_QUERY_TIMEOUT_MS);
|
|
13183
|
+
const entry = {
|
|
13184
|
+
at: now,
|
|
13185
|
+
contexts
|
|
13186
|
+
};
|
|
13187
|
+
contexts.catch(() => {
|
|
13188
|
+
if (cache.current === entry) cache.current = {
|
|
13189
|
+
at: now - KNOWN_CONTEXTS_TTL_MS + KNOWN_CONTEXTS_FAILURE_TTL_MS,
|
|
13190
|
+
contexts
|
|
13191
|
+
};
|
|
13192
|
+
});
|
|
13193
|
+
cache.current = entry;
|
|
13194
|
+
}
|
|
13195
|
+
return (await cache.current.contexts).has(context);
|
|
13196
|
+
} catch (err) {
|
|
13197
|
+
debug(`History contexts unavailable for ${context}: ${errorDetail(err)}`);
|
|
13198
|
+
return false;
|
|
13199
|
+
}
|
|
13200
|
+
}
|
|
13201
|
+
/**
|
|
12563
13202
|
* Positions a history provider holds for a window, or none.
|
|
12564
13203
|
*
|
|
12565
13204
|
* Best-effort by design: a provider that is absent, slow, or failing must not
|
|
@@ -12572,10 +13211,23 @@ async function historyPositions(app, context, window, resolutionMs, debug) {
|
|
|
12572
13211
|
const getHistoryApi = app.getHistoryApi;
|
|
12573
13212
|
if (!getHistoryApi) return {
|
|
12574
13213
|
points: [],
|
|
12575
|
-
resolutionMs: applied
|
|
13214
|
+
resolutionMs: applied,
|
|
13215
|
+
failed: false
|
|
12576
13216
|
};
|
|
13217
|
+
let historyApi;
|
|
12577
13218
|
try {
|
|
12578
|
-
|
|
13219
|
+
historyApi = await withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider), HISTORY_QUERY_TIMEOUT_MS);
|
|
13220
|
+
} catch (err) {
|
|
13221
|
+
const timedOut = err instanceof HistoryTimeoutError;
|
|
13222
|
+
if (debug.enabled) debug(`${timedOut ? "History provider timed out" : "No history provider"} for ${context}: ${errorDetail(err)}`);
|
|
13223
|
+
return {
|
|
13224
|
+
points: [],
|
|
13225
|
+
resolutionMs: applied,
|
|
13226
|
+
failed: timedOut
|
|
13227
|
+
};
|
|
13228
|
+
}
|
|
13229
|
+
try {
|
|
13230
|
+
const response = await withTimeout(historyApi.getValues({
|
|
12579
13231
|
context,
|
|
12580
13232
|
from: Temporal.Instant.from(new Date(window.from).toISOString()),
|
|
12581
13233
|
to: Temporal.Instant.from(new Date(window.to).toISOString()),
|
|
@@ -12600,13 +13252,15 @@ async function historyPositions(app, context, window, resolutionMs, debug) {
|
|
|
12600
13252
|
if (debug.enabled) debug(`History supplied ${points.length} position(s) for ${context}`);
|
|
12601
13253
|
return {
|
|
12602
13254
|
points,
|
|
12603
|
-
resolutionMs: applied
|
|
13255
|
+
resolutionMs: applied,
|
|
13256
|
+
failed: false
|
|
12604
13257
|
};
|
|
12605
13258
|
} catch (err) {
|
|
12606
13259
|
if (debug.enabled) debug(`History unavailable for ${context}: ${errorDetail(err)}`);
|
|
12607
13260
|
return {
|
|
12608
13261
|
points: [],
|
|
12609
|
-
resolutionMs: applied
|
|
13262
|
+
resolutionMs: applied,
|
|
13263
|
+
failed: true
|
|
12610
13264
|
};
|
|
12611
13265
|
}
|
|
12612
13266
|
}
|
|
@@ -12617,92 +13271,6 @@ var historyRowTimestamp = (row) => {
|
|
|
12617
13271
|
const parsed = Date.parse(raw);
|
|
12618
13272
|
return Number.isNaN(parsed) ? 0 : parsed;
|
|
12619
13273
|
};
|
|
12620
|
-
async function bootstrapSelfTrack(app, tracks, config, glitchFilter) {
|
|
12621
|
-
const { debug } = app;
|
|
12622
|
-
const getHistoryApi = app.getHistoryApi;
|
|
12623
|
-
if (!getHistoryApi) {
|
|
12624
|
-
debug("getHistoryApi not available on server, skipping track bootstrap");
|
|
12625
|
-
return;
|
|
12626
|
-
}
|
|
12627
|
-
if (!app.selfContext) {
|
|
12628
|
-
debug("selfContext not available, skipping track bootstrap");
|
|
12629
|
-
return;
|
|
12630
|
-
}
|
|
12631
|
-
const configuredProvider = app.config?.settings?.historyApi?.defaultProvider;
|
|
12632
|
-
const resolution = toNumber(config.resolution) ?? DEFAULT_RESOLUTION;
|
|
12633
|
-
const timespanMs = resolution * (toNumber(config.pointsToKeep) ?? DEFAULT_POINTS_TO_KEEP);
|
|
12634
|
-
const resolutionSecs = Math.max(1, Math.round(resolution / 1e3));
|
|
12635
|
-
const timespanMinutes = Math.round(timespanMs / 1e3 / 60);
|
|
12636
|
-
debug(`Track bootstrap: requesting ${timespanMinutes} minutes of history at ${resolutionSecs}s resolution (max ${BOOTSTRAP_MAX_ATTEMPTS} attempts)`);
|
|
12637
|
-
let noProviderCount = 0;
|
|
12638
|
-
for (let attempt = 1; attempt <= BOOTSTRAP_MAX_ATTEMPTS; attempt++) {
|
|
12639
|
-
const delay = attempt === 1 ? BOOTSTRAP_INITIAL_DELAY : BOOTSTRAP_RETRY_DELAY;
|
|
12640
|
-
debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}, waiting ${delay / 1e3}s...`);
|
|
12641
|
-
await sleep(delay);
|
|
12642
|
-
try {
|
|
12643
|
-
const historyApi = await getHistoryApi(configuredProvider);
|
|
12644
|
-
noProviderCount = 0;
|
|
12645
|
-
const to = /* @__PURE__ */ new Date();
|
|
12646
|
-
const from = new Date(to.getTime() - timespanMs);
|
|
12647
|
-
const response = await historyApi.getValues({
|
|
12648
|
-
context: app.selfContext,
|
|
12649
|
-
from: Temporal.Instant.from(from.toISOString()),
|
|
12650
|
-
to: Temporal.Instant.from(to.toISOString()),
|
|
12651
|
-
pathSpecs: [{
|
|
12652
|
-
path: "navigation.position",
|
|
12653
|
-
aggregate: "first"
|
|
12654
|
-
}],
|
|
12655
|
-
resolution: resolutionSecs
|
|
12656
|
-
});
|
|
12657
|
-
if (response?.data && response.data.length > 0) {
|
|
12658
|
-
const positions = [];
|
|
12659
|
-
const timestamps = [];
|
|
12660
|
-
let dropped = 0;
|
|
12661
|
-
for (const row of response.data) {
|
|
12662
|
-
const position = historyRowPosition(row);
|
|
12663
|
-
if (position) {
|
|
12664
|
-
const timestamp = historyRowTimestamp(row);
|
|
12665
|
-
if (timestamp === 0) {
|
|
12666
|
-
dropped++;
|
|
12667
|
-
continue;
|
|
12668
|
-
}
|
|
12669
|
-
if (!glitchFilter.accept(app.selfContext, position, timestamp)) {
|
|
12670
|
-
dropped++;
|
|
12671
|
-
continue;
|
|
12672
|
-
}
|
|
12673
|
-
positions.push(position);
|
|
12674
|
-
timestamps.push(timestamp);
|
|
12675
|
-
}
|
|
12676
|
-
}
|
|
12677
|
-
if (dropped > 0) debug(`Track bootstrap discarded ${dropped} position(s) as glitches`);
|
|
12678
|
-
if (positions.length > 0) {
|
|
12679
|
-
tracks.initialTrack(app.selfContext, positions, timestamps);
|
|
12680
|
-
debug(`Track bootstrap complete: loaded ${positions.length} positions for self (${timespanMinutes} min window) on attempt ${attempt}`);
|
|
12681
|
-
return;
|
|
12682
|
-
}
|
|
12683
|
-
}
|
|
12684
|
-
debug(`History API returned no position data on attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}; the configured provider may not have registered yet`);
|
|
12685
|
-
if (attempt === BOOTSTRAP_MAX_ATTEMPTS) {
|
|
12686
|
-
debug("History API returned no position data for bootstrap");
|
|
12687
|
-
return;
|
|
12688
|
-
}
|
|
12689
|
-
continue;
|
|
12690
|
-
} catch (err) {
|
|
12691
|
-
if (isNoProviderError(err)) {
|
|
12692
|
-
noProviderCount++;
|
|
12693
|
-
debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}: no history provider registered yet (${noProviderCount}/${BOOTSTRAP_MAX_NO_PROVIDER})`);
|
|
12694
|
-
if (noProviderCount >= BOOTSTRAP_MAX_NO_PROVIDER) {
|
|
12695
|
-
debug(`No history provider registered after ${BOOTSTRAP_MAX_NO_PROVIDER} consecutive checks — no provider plugin appears to be installed. Giving up.`);
|
|
12696
|
-
return;
|
|
12697
|
-
}
|
|
12698
|
-
} else {
|
|
12699
|
-
noProviderCount = 0;
|
|
12700
|
-
debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS} failed: ${errorDetail(err)}`);
|
|
12701
|
-
}
|
|
12702
|
-
if (attempt === BOOTSTRAP_MAX_ATTEMPTS) app.error(`Track bootstrap from History API failed after ${BOOTSTRAP_MAX_ATTEMPTS} attempts. Tracks will start empty and accumulate from live data.`);
|
|
12703
|
-
}
|
|
12704
|
-
}
|
|
12705
|
-
}
|
|
12706
13274
|
function ThePlugin(app) {
|
|
12707
13275
|
let onStop = [];
|
|
12708
13276
|
let tracks = void 0;
|
|
@@ -12715,10 +13283,16 @@ function ThePlugin(app) {
|
|
|
12715
13283
|
* own.
|
|
12716
13284
|
*/
|
|
12717
13285
|
let storeResolution = DEFAULT_RESOLUTION;
|
|
12718
|
-
let defaultMaxRadius = void 0;
|
|
12719
13286
|
const sourceWatch = new SourceWatch();
|
|
12720
13287
|
let glitchFilter = new GlitchFilter({ maxSpeedKnots: 100 });
|
|
12721
13288
|
let stateGate = new StateGate(app.selfContext, DEFAULT_PAUSE_STATES);
|
|
13289
|
+
/**
|
|
13290
|
+
* The history provider's context list, cached between existence probes.
|
|
13291
|
+
*
|
|
13292
|
+
* Per plugin instance rather than module-global: two instances would
|
|
13293
|
+
* otherwise answer from each other's provider.
|
|
13294
|
+
*/
|
|
13295
|
+
const knownContexts = { current: void 0 };
|
|
12722
13296
|
/** The own vessel's navigation.state, or undefined when it reports none. */
|
|
12723
13297
|
function getVesselState() {
|
|
12724
13298
|
const s = app.getSelfPath("navigation.state");
|
|
@@ -12736,25 +13310,28 @@ function ThePlugin(app) {
|
|
|
12736
13310
|
}
|
|
12737
13311
|
return {
|
|
12738
13312
|
start: function(config) {
|
|
12739
|
-
const { resolution
|
|
12740
|
-
defaultMaxRadius = toNumber(maxRadius);
|
|
12741
|
-
const source = resolveSource(config);
|
|
13313
|
+
const { resolution } = config;
|
|
12742
13314
|
storeResolution = toNumber(config.resolution) ?? DEFAULT_RESOLUTION;
|
|
12743
13315
|
const segmentGapMinutes = toNumber(config.segmentGapMinutes) ?? DEFAULT_SEGMENT_GAP_MINUTES;
|
|
12744
13316
|
segmentGap = segmentGapMinutes > 0 ? segmentGapMinutes * 60 * 1e3 : 0;
|
|
12745
13317
|
glitchFilter = new GlitchFilter({ maxSpeedKnots: toNumber(config.maxSpeedKnots) ?? 100 });
|
|
12746
13318
|
stateGate = new StateGate(app.selfContext, Array.isArray(config.pauseWhenState) ? config.pauseWhenState : DEFAULT_PAUSE_STATES);
|
|
12747
|
-
const dataDir =
|
|
12748
|
-
if (
|
|
12749
|
-
|
|
12750
|
-
|
|
12751
|
-
|
|
12752
|
-
|
|
12753
|
-
|
|
12754
|
-
|
|
12755
|
-
|
|
12756
|
-
|
|
12757
|
-
|
|
13319
|
+
const dataDir = app.getDataDirPath?.();
|
|
13320
|
+
if (!dataDir) {
|
|
13321
|
+
app.error("This server provides no plugin data directory, so tracks cannot be recorded.");
|
|
13322
|
+
return;
|
|
13323
|
+
}
|
|
13324
|
+
try {
|
|
13325
|
+
tracks = new SqliteTrackStore({
|
|
13326
|
+
file: join(dataDir, "tracks.db"),
|
|
13327
|
+
resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,
|
|
13328
|
+
retention: (toNumber(config.retentionDays) ?? 0) * 24 * 60 * 60 * 1e3,
|
|
13329
|
+
segmentGap
|
|
13330
|
+
}, app.debug);
|
|
13331
|
+
} catch (err) {
|
|
13332
|
+
app.error(`Could not open the track database in ${dataDir}: ${errorDetail(err)}`);
|
|
13333
|
+
return;
|
|
13334
|
+
}
|
|
12758
13335
|
onStop.push(app.streambundle.getBus("navigation.position").onValue((update) => {
|
|
12759
13336
|
if (!update.value || update.value.latitude == null || update.value.longitude == null) return;
|
|
12760
13337
|
sourceWatch.add(update.context, update.$source);
|
|
@@ -12765,8 +13342,10 @@ function ThePlugin(app) {
|
|
|
12765
13342
|
if (!glitchFilter.accept(update.context, position, timestamp ?? Date.now())) return;
|
|
12766
13343
|
tracks?.newPosition(update.context, position, timestamp);
|
|
12767
13344
|
}));
|
|
12768
|
-
const
|
|
12769
|
-
const pruneInterval = setInterval(() =>
|
|
13345
|
+
const aisRetentionMs = (toNumber(config.aisRetentionDays) ?? DEFAULT_AIS_RETENTION_DAYS) * 24 * 60 * 60 * 1e3;
|
|
13346
|
+
const pruneInterval = setInterval(() => {
|
|
13347
|
+
tracks?.prune(aisRetentionMs > 0 ? aisRetentionMs : Infinity, app.selfContext);
|
|
13348
|
+
}, PRUNE_INTERVAL_MS);
|
|
12770
13349
|
onStop.push(() => {
|
|
12771
13350
|
clearInterval(pruneInterval);
|
|
12772
13351
|
});
|
|
@@ -12784,11 +13363,14 @@ function ThePlugin(app) {
|
|
|
12784
13363
|
app.registerTrackApiProvider?.(createTrackProvider({
|
|
12785
13364
|
store: () => tracks,
|
|
12786
13365
|
selfContext: () => app.selfContext,
|
|
12787
|
-
segmentGap: () => segmentGap
|
|
13366
|
+
segmentGap: () => segmentGap,
|
|
13367
|
+
contextName: (context) => contextName(context, (path) => app.getPath?.(path)),
|
|
13368
|
+
reconcileWithHistory: async (context, stored, window, resolutionMs) => {
|
|
13369
|
+
const effective = resolutionMs ?? storeResolution;
|
|
13370
|
+
const history = await historyPositions(app, context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective, app.debug);
|
|
13371
|
+
return history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
|
|
13372
|
+
}
|
|
12788
13373
|
}));
|
|
12789
|
-
if (source === "history") bootstrapSelfTrack(app, tracks, config, glitchFilter).catch((err) => {
|
|
12790
|
-
app.error(`Unexpected error in track bootstrap: ${errorDetail(err)}`);
|
|
12791
|
-
});
|
|
12792
13374
|
},
|
|
12793
13375
|
stop: function() {
|
|
12794
13376
|
onStop.forEach((f) => {
|
|
@@ -12802,13 +13384,44 @@ function ThePlugin(app) {
|
|
|
12802
13384
|
sourceWatch.clear();
|
|
12803
13385
|
glitchFilter.clear();
|
|
12804
13386
|
stateGate.clear();
|
|
13387
|
+
knownContexts.current = void 0;
|
|
12805
13388
|
try {
|
|
12806
13389
|
tracks?.close?.();
|
|
12807
13390
|
} catch (err) {
|
|
12808
13391
|
app.error(err);
|
|
13392
|
+
} finally {
|
|
13393
|
+
tracks = void 0;
|
|
12809
13394
|
}
|
|
12810
13395
|
},
|
|
12811
13396
|
signalKApiRoutes: function(router) {
|
|
13397
|
+
const nameOf = (context) => trackLabel(context, app.selfContext, (path) => app.getPath?.(path));
|
|
13398
|
+
/**
|
|
13399
|
+
* The points of one context, reconciled with history and segmented.
|
|
13400
|
+
*
|
|
13401
|
+
* Shared by the JSON and GPX routes so the two cannot answer the same
|
|
13402
|
+
* question differently: the history reconciliation, the windowless
|
|
13403
|
+
* fallback and the 404-only-for-an-unknown-vessel rule are subtle enough
|
|
13404
|
+
* that a second copy would drift.
|
|
13405
|
+
*
|
|
13406
|
+
* Rejects with `undefined` when neither source knows the vessel at all.
|
|
13407
|
+
*/
|
|
13408
|
+
const readSegments = async (context, query) => {
|
|
13409
|
+
const effectiveResolution = query.resolution ?? storeResolution;
|
|
13410
|
+
const { points: stored, known } = await tracks.getTimed(context, query.window).then((points) => ({
|
|
13411
|
+
points,
|
|
13412
|
+
known: true
|
|
13413
|
+
})).catch(() => ({
|
|
13414
|
+
points: [],
|
|
13415
|
+
known: false
|
|
13416
|
+
}));
|
|
13417
|
+
const history = await historyPositions(app, context, query.window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effectiveResolution, app.debug);
|
|
13418
|
+
const points = history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
|
|
13419
|
+
if (points.length === 0) {
|
|
13420
|
+
if (!(known || await historyKnowsContext(app, context, app.debug, knownContexts))) return;
|
|
13421
|
+
if (history.failed) throw new HistoryUnavailableError(`History provider could not be read for ${context}`);
|
|
13422
|
+
}
|
|
13423
|
+
return segment(thin(points, query.resolution), segmentGap);
|
|
13424
|
+
};
|
|
12812
13425
|
const singleTrackHandler = (contextOf) => (req, res) => {
|
|
12813
13426
|
if (!tracks) {
|
|
12814
13427
|
notAvailable(res);
|
|
@@ -12823,37 +13436,89 @@ function ThePlugin(app) {
|
|
|
12823
13436
|
res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
|
|
12824
13437
|
return;
|
|
12825
13438
|
}
|
|
12826
|
-
|
|
12827
|
-
|
|
12828
|
-
points,
|
|
12829
|
-
known: true
|
|
12830
|
-
})).catch(() => ({
|
|
12831
|
-
points: [],
|
|
12832
|
-
known: false
|
|
12833
|
-
})).then(async ({ points: stored, known }) => {
|
|
12834
|
-
const window = query.window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS);
|
|
12835
|
-
const history = await historyPositions(app, context, window, effectiveResolution, app.debug);
|
|
12836
|
-
const points = history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
|
|
12837
|
-
if (points.length === 0 && !known) {
|
|
13439
|
+
readSegments(context, query).then((segments) => {
|
|
13440
|
+
if (segments === void 0) {
|
|
12838
13441
|
res.status(404);
|
|
12839
13442
|
res.json({ message: `No track available for ${context}` });
|
|
12840
13443
|
return;
|
|
12841
13444
|
}
|
|
12842
|
-
const segments = segment(thin(points, query.resolution), segmentGap);
|
|
12843
13445
|
res.json({
|
|
12844
13446
|
type: "MultiLineString",
|
|
12845
13447
|
coordinates: segments.map((points) => points.map(({ position }) => toLngLat(position))),
|
|
12846
13448
|
...query.times ? { times: segments.map(toIsoTimes) } : {},
|
|
12847
13449
|
context,
|
|
12848
|
-
isSelf: context === app.selfContext
|
|
13450
|
+
isSelf: context === app.selfContext,
|
|
13451
|
+
name: nameOf(context)
|
|
12849
13452
|
});
|
|
12850
|
-
}).catch(() => {
|
|
13453
|
+
}).catch((err) => {
|
|
13454
|
+
if (err instanceof HistoryUnavailableError) {
|
|
13455
|
+
app.error(`${err.message}`);
|
|
13456
|
+
res.status(503);
|
|
13457
|
+
res.json({ message: `Track history is temporarily unavailable for ${context}` });
|
|
13458
|
+
return;
|
|
13459
|
+
}
|
|
12851
13460
|
res.status(404);
|
|
12852
13461
|
res.json({ message: `No track available for ${context}` });
|
|
12853
13462
|
});
|
|
12854
13463
|
};
|
|
13464
|
+
/**
|
|
13465
|
+
* One vessel's track as a GPX file.
|
|
13466
|
+
*
|
|
13467
|
+
* Serialised by `toGpx` rather than assembled here, so the export a user
|
|
13468
|
+
* downloads is the same document the module produces everywhere else.
|
|
13469
|
+
* The webapp cannot reach that module — it ships as static files with no
|
|
13470
|
+
* bundler — which is why the conversion lives behind a route instead of
|
|
13471
|
+
* being duplicated in the page.
|
|
13472
|
+
*/
|
|
13473
|
+
const gpxHandler = (contextOf) => (req, res) => {
|
|
13474
|
+
if (!tracks) {
|
|
13475
|
+
notAvailable(res);
|
|
13476
|
+
return;
|
|
13477
|
+
}
|
|
13478
|
+
const context = contextOf(req);
|
|
13479
|
+
let query;
|
|
13480
|
+
try {
|
|
13481
|
+
query = parseTrackQuery(req.query);
|
|
13482
|
+
} catch (err) {
|
|
13483
|
+
res.status(400);
|
|
13484
|
+
res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
|
|
13485
|
+
return;
|
|
13486
|
+
}
|
|
13487
|
+
readSegments(context, query).then((segments) => {
|
|
13488
|
+
if (segments === void 0) {
|
|
13489
|
+
res.status(404);
|
|
13490
|
+
res.json({ message: `No track available for ${context}` });
|
|
13491
|
+
return;
|
|
13492
|
+
}
|
|
13493
|
+
const label = nameOf(context);
|
|
13494
|
+
const filename = gpxFilename(label);
|
|
13495
|
+
res.type("application/gpx+xml");
|
|
13496
|
+
res.setHeader("Content-Disposition", `attachment; filename="${asciiFilename(filename)}"; filename*=UTF-8''${rfc8187(filename)}`);
|
|
13497
|
+
res.send(toGpx([{
|
|
13498
|
+
name: label,
|
|
13499
|
+
context,
|
|
13500
|
+
segments
|
|
13501
|
+
}]));
|
|
13502
|
+
}).catch((err) => {
|
|
13503
|
+
if (err instanceof HistoryUnavailableError) {
|
|
13504
|
+
app.error(`${err.message}`);
|
|
13505
|
+
if (!res.headersSent) {
|
|
13506
|
+
res.status(503);
|
|
13507
|
+
res.json({ message: `Track history is temporarily unavailable for ${context}` });
|
|
13508
|
+
}
|
|
13509
|
+
return;
|
|
13510
|
+
}
|
|
13511
|
+
app.error(`Could not export GPX for ${context}: ${errorDetail(err)}`);
|
|
13512
|
+
if (!res.headersSent) {
|
|
13513
|
+
res.status(500);
|
|
13514
|
+
res.json({ message: `Could not export the track for ${context}` });
|
|
13515
|
+
}
|
|
13516
|
+
});
|
|
13517
|
+
};
|
|
12855
13518
|
const trackHandler = singleTrackHandler((req) => resolveContext(String(req.params.vesselId), app.selfContext));
|
|
12856
13519
|
router.get("/vessels/:vesselId/track", trackHandler);
|
|
13520
|
+
router.get("/vessels/:vesselId/track.gpx", gpxHandler((req) => resolveContext(String(req.params.vesselId), app.selfContext)));
|
|
13521
|
+
router.get("/self/track.gpx", gpxHandler(() => app.selfContext));
|
|
12857
13522
|
router.get("/self/track", singleTrackHandler(() => app.selfContext));
|
|
12858
13523
|
const allTracksHandler = (req, res) => {
|
|
12859
13524
|
app.debug(req.query);
|
|
@@ -12869,7 +13534,7 @@ function ThePlugin(app) {
|
|
|
12869
13534
|
res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
|
|
12870
13535
|
return;
|
|
12871
13536
|
}
|
|
12872
|
-
const params = validateParameters(req.query,
|
|
13537
|
+
const params = validateParameters(req.query, void 0);
|
|
12873
13538
|
const selfPosition = getVesselPosition();
|
|
12874
13539
|
(query.times ? tracks.getFilteredTimedTracks(params, selfPosition, app.debug, query).then((tc) => Object.entries(tc).reduce((acc, [context, points]) => {
|
|
12875
13540
|
const segments = segment(points, segmentGap);
|
|
@@ -12877,14 +13542,16 @@ function ThePlugin(app) {
|
|
|
12877
13542
|
type: "MultiLineString",
|
|
12878
13543
|
coordinates: segments.map((s) => s.map(({ position }) => toLngLat(position))),
|
|
12879
13544
|
times: segments.map(toIsoTimes),
|
|
12880
|
-
isSelf: context === app.selfContext
|
|
13545
|
+
isSelf: context === app.selfContext,
|
|
13546
|
+
name: nameOf(context)
|
|
12881
13547
|
};
|
|
12882
13548
|
return acc;
|
|
12883
13549
|
}, {})) : tracks.getFilteredTracks(params, selfPosition, app.debug, query).then((tc) => Object.entries(tc).reduce((acc, [context, track]) => {
|
|
12884
13550
|
acc[context] = {
|
|
12885
13551
|
type: "MultiLineString",
|
|
12886
13552
|
coordinates: [track.map(toLngLat)],
|
|
12887
|
-
isSelf: context === app.selfContext
|
|
13553
|
+
isSelf: context === app.selfContext,
|
|
13554
|
+
name: nameOf(context)
|
|
12888
13555
|
};
|
|
12889
13556
|
return acc;
|
|
12890
13557
|
}, {}))).then((trks) => {
|
|
@@ -12901,55 +13568,31 @@ function ThePlugin(app) {
|
|
|
12901
13568
|
getTracks: () => tracks,
|
|
12902
13569
|
id: "tracks",
|
|
12903
13570
|
name: "Tracks",
|
|
12904
|
-
|
|
13571
|
+
enabledByDefault: true,
|
|
13572
|
+
description: "Record vessel tracks to SQLite and serve them through the track API",
|
|
12905
13573
|
schema: {
|
|
12906
13574
|
type: "object",
|
|
12907
13575
|
properties: {
|
|
12908
13576
|
resolution: {
|
|
12909
13577
|
type: "integer",
|
|
13578
|
+
minimum: 0,
|
|
12910
13579
|
title: "Track resolution (milliseconds)",
|
|
12911
13580
|
default: DEFAULT_RESOLUTION
|
|
12912
13581
|
},
|
|
12913
|
-
pointsToKeep: {
|
|
12914
|
-
type: "integer",
|
|
12915
|
-
title: "Points to keep",
|
|
12916
|
-
description: "How many trackpoints to keep for each track",
|
|
12917
|
-
default: DEFAULT_POINTS_TO_KEEP
|
|
12918
|
-
},
|
|
12919
|
-
maxAge: {
|
|
12920
|
-
type: "integer",
|
|
12921
|
-
title: "Maximum idle time (seconds)",
|
|
12922
|
-
description: "Tracks with no updates longer than this are removed",
|
|
12923
|
-
default: DEFAULT_MAX_AGE
|
|
12924
|
-
},
|
|
12925
|
-
maxRadius: {
|
|
12926
|
-
type: "integer",
|
|
12927
|
-
title: "Maximum Radius (meters) ",
|
|
12928
|
-
description: "Include only vessels with position within this range. 0= all vessels",
|
|
12929
|
-
default: DEFAULT_MAX_RADIUS
|
|
12930
|
-
},
|
|
12931
|
-
source: {
|
|
12932
|
-
type: "string",
|
|
12933
|
-
title: "Where tracks come from after a restart",
|
|
12934
|
-
description: "In memory only: start empty and accumulate from live positions. History API: refill on startup from a history provider such as signalk-to-influxdb2 or signalk-questdb. SQLite: record positions to a database file so they survive a restart with no other plugin required.",
|
|
12935
|
-
enum: [
|
|
12936
|
-
"memory",
|
|
12937
|
-
"history",
|
|
12938
|
-
"sqlite"
|
|
12939
|
-
],
|
|
12940
|
-
enumNames: [
|
|
12941
|
-
"In memory only",
|
|
12942
|
-
"Load from the History API on startup",
|
|
12943
|
-
"Record to a SQLite database"
|
|
12944
|
-
],
|
|
12945
|
-
default: "history"
|
|
12946
|
-
},
|
|
12947
13582
|
retentionDays: {
|
|
12948
13583
|
type: "integer",
|
|
12949
|
-
|
|
12950
|
-
|
|
13584
|
+
minimum: 0,
|
|
13585
|
+
title: "Days of the own vessel's track to keep",
|
|
13586
|
+
description: "Positions older than this are deleted. 0, the default, keeps everything — a track is worth more the longer it goes back, and a year of one vessel at the default resolution is a few megabytes.",
|
|
12951
13587
|
default: 0
|
|
12952
13588
|
},
|
|
13589
|
+
aisRetentionDays: {
|
|
13590
|
+
type: "integer",
|
|
13591
|
+
minimum: 0,
|
|
13592
|
+
title: "Days to keep another vessel after its last fix",
|
|
13593
|
+
description: "The own vessel is never removed. Other vessels are: a busy harbour puts hundreds of AIS targets past the receiver in a day, and keeping all of them forever is rarely what anyone wants. 0 keeps every vessel indefinitely.",
|
|
13594
|
+
default: DEFAULT_AIS_RETENTION_DAYS
|
|
13595
|
+
},
|
|
12953
13596
|
segmentGapMinutes: {
|
|
12954
13597
|
type: "integer",
|
|
12955
13598
|
title: "Split a track after this many minutes without a fix",
|
|
@@ -12978,6 +13621,6 @@ function ThePlugin(app) {
|
|
|
12978
13621
|
};
|
|
12979
13622
|
}
|
|
12980
13623
|
//#endregion
|
|
12981
|
-
export { TrackAccumulator, Tracks, ThePlugin as default,
|
|
13624
|
+
export { TrackAccumulator, Tracks, ThePlugin as default, fromGpx, toGpx };
|
|
12982
13625
|
|
|
12983
13626
|
//# sourceMappingURL=index.js.map
|