@signalk/tracks-plugin 3.0.0-beta.0 → 3.0.0-beta.2
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 +27 -26
- package/app-icon.svg +42 -0
- package/dist/e2e.test-utils.d.ts +3 -3
- package/dist/harness.test-utils.d.ts +8 -1
- package/dist/index.d.ts +5 -29
- package/dist/index.js +326 -342
- package/dist/index.js.map +1 -1
- package/dist/sqliteStore.d.ts +9 -5
- package/dist/stateGate.d.ts +0 -4
- package/dist/store.d.ts +28 -9
- package/dist/timeWindow.d.ts +18 -0
- package/dist/trackApi.d.ts +9 -7
- package/dist/trackProvider.d.ts +15 -0
- package/dist/tracks.d.ts +7 -4
- package/dist/types.d.ts +10 -0
- package/dist/utils.d.ts +9 -0
- package/package.json +8 -2
- package/dist/source.test.d.ts +0 -1
- /package/dist/{bootstrap.test.d.ts → historyRow.test.d.ts} +0 -0
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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 { BehaviorSubject, ReplaySubject, Subject, combineLatest, connectable, firstValueFrom } from "rxjs";
|
|
7
|
+
import { map, scan, startWith, throttleTime } from "rxjs/operators";
|
|
8
8
|
//#region \0rolldown/runtime.js
|
|
9
9
|
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
10
10
|
//#endregion
|
|
@@ -161,6 +161,42 @@ function thin(points, resolution) {
|
|
|
161
161
|
return result;
|
|
162
162
|
}
|
|
163
163
|
/**
|
|
164
|
+
* Thin to at most `maxPoints`, widening the spacing until the budget fits.
|
|
165
|
+
*
|
|
166
|
+
* A budget, not a fidelity contract: the caller has said how many points it can
|
|
167
|
+
* afford to transfer and draw, whatever shape the track is. Returns the
|
|
168
|
+
* resolution actually applied so the response can report it — a client that is
|
|
169
|
+
* given 29 points instead of the 546 it would otherwise have received should be
|
|
170
|
+
* able to see the spacing that produced them.
|
|
171
|
+
*
|
|
172
|
+
* Widening by doubling from the span-derived estimate rather than binary
|
|
173
|
+
* searching: `thin` keeps the first point and the last unconditionally, so the
|
|
174
|
+
* count is not a clean function of the spacing and an exact solve would be
|
|
175
|
+
* false precision. Two or three passes settle it in practice.
|
|
176
|
+
*/
|
|
177
|
+
function thinToBudget(points, maxPoints, resolution) {
|
|
178
|
+
const start = thin(points, resolution);
|
|
179
|
+
if (start.length <= maxPoints) return {
|
|
180
|
+
points: start,
|
|
181
|
+
resolution
|
|
182
|
+
};
|
|
183
|
+
if (maxPoints < 2) return {
|
|
184
|
+
points: start.slice(0, Math.max(0, maxPoints)),
|
|
185
|
+
resolution
|
|
186
|
+
};
|
|
187
|
+
const span = start[start.length - 1].timestamp - start[0].timestamp;
|
|
188
|
+
let spacing = Math.max(1, Math.ceil(span / (maxPoints - 1)), resolution ?? 0);
|
|
189
|
+
let result = thin(points, spacing);
|
|
190
|
+
while (result.length > maxPoints && spacing <= span) {
|
|
191
|
+
spacing *= 2;
|
|
192
|
+
result = thin(points, spacing);
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
points: result,
|
|
196
|
+
resolution: spacing
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
164
200
|
* Split points into segments wherever recording stopped for longer than `gap`.
|
|
165
201
|
*
|
|
166
202
|
* A track is not one continuous line: a vessel that stops for the night, or
|
|
@@ -313,7 +349,7 @@ function resolveContext(vesselId, selfContext) {
|
|
|
313
349
|
function createMatcher(params, selfPosition, debug) {
|
|
314
350
|
if (params.bbox) {
|
|
315
351
|
const inBounds = createInBounds(params.bbox);
|
|
316
|
-
return (track) => inBounds(lastPoint(track));
|
|
352
|
+
return params.intersects ? (track) => track.some(inBounds) : (track) => inBounds(lastPoint(track));
|
|
317
353
|
} else if (params.radius !== null) {
|
|
318
354
|
if (!selfPosition) throw new Error("No self position to calculate radius values");
|
|
319
355
|
const radius = params.radius;
|
|
@@ -322,142 +358,26 @@ function createMatcher(params, selfPosition, debug) {
|
|
|
322
358
|
}
|
|
323
359
|
return () => true;
|
|
324
360
|
}
|
|
325
|
-
//#endregion
|
|
326
|
-
//#region src/tracks.ts
|
|
327
361
|
/**
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
362
|
+
* A history row is `[timestamp, position]`. Both encodings are legal: the
|
|
363
|
+
* History API types a value as `unknown` and its own comment names "objects
|
|
364
|
+
* (like navigation.position)" alongside primitives, so signalk-questdb returns
|
|
365
|
+
* `{latitude, longitude}` and signalk-to-influxdb2 returns `[lon, lat]`.
|
|
366
|
+
* Accept both — neither is an error, so rejecting one silently yields an empty
|
|
367
|
+
* track rather than a failure anybody can see.
|
|
331
368
|
*/
|
|
332
|
-
var
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
this.config = config;
|
|
339
|
-
this.debug = debug;
|
|
340
|
-
}
|
|
341
|
-
newPosition(context, position, timestamp) {
|
|
342
|
-
this.getAccumulator(context)?.nextLatLngTuple(position, timestamp);
|
|
343
|
-
}
|
|
344
|
-
initialTrack(context, track, timestamps) {
|
|
345
|
-
this.getAccumulator(context)?.setInitialTrack(track, timestamps);
|
|
346
|
-
}
|
|
347
|
-
getAccumulator(context, createIfMissing = true) {
|
|
348
|
-
if (context.indexOf("vessels.") === -1 && context.indexOf("aircraft.") === -1) return;
|
|
349
|
-
let result = this.tracks[context];
|
|
350
|
-
if (!result && createIfMissing) {
|
|
351
|
-
const accParams = this.config.fetchInitialTrack ? {
|
|
352
|
-
...this.config,
|
|
353
|
-
fetchTrackFor: context
|
|
354
|
-
} : { ...this.config };
|
|
355
|
-
result = this.tracks[context] = new TrackAccumulator(accParams);
|
|
356
|
-
}
|
|
357
|
-
return result;
|
|
358
|
-
}
|
|
359
|
-
get(context, window) {
|
|
360
|
-
return this.getTimed(context, window).then((points) => points.map(({ position }) => position));
|
|
361
|
-
}
|
|
362
|
-
/**
|
|
363
|
-
* Track points with their timestamps, optionally narrowed to a time window.
|
|
364
|
-
*
|
|
365
|
-
* Rejects with EmptyError if the stream completes without emitting; the route
|
|
366
|
-
* handlers turn that into the same 404 as an unknown context.
|
|
367
|
-
*/
|
|
368
|
-
getTimed(context, window) {
|
|
369
|
-
const accumulator = this.getAccumulator(context, false);
|
|
370
|
-
if (!accumulator) return Promise.reject(/* @__PURE__ */ new Error(`No track accumulator for ${context}`));
|
|
371
|
-
return firstValueFrom(accumulator.timedTrack).then((points) => window ? points.filter(({ timestamp }) => timestamp >= window.from && (window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to)) : points);
|
|
372
|
-
}
|
|
373
|
-
getAllTracks(query) {
|
|
374
|
-
return Promise.all(Object.keys(this.tracks).map((context) => this.getTimed(context, query?.window).then((points) => ({
|
|
375
|
-
context,
|
|
376
|
-
track: thin(points, query?.resolution).map(({ position }) => position)
|
|
377
|
-
}))));
|
|
378
|
-
}
|
|
379
|
-
async getFilteredTracks(params, selfPosition, debug, query) {
|
|
380
|
-
const timed = await this.getFilteredTimedTracks(params, selfPosition, debug, query);
|
|
381
|
-
return Object.fromEntries(Object.entries(timed).map(([context, points]) => [context, points.map(({ position }) => position)]));
|
|
382
|
-
}
|
|
383
|
-
/**
|
|
384
|
-
* The same filtering as `getFilteredTracks`, keeping each point's timestamp.
|
|
385
|
-
*
|
|
386
|
-
* The untimed form is derived from this one so the two cannot disagree about
|
|
387
|
-
* which tracks match — the filter runs once, here.
|
|
388
|
-
*/
|
|
389
|
-
async getFilteredTimedTracks(params, selfPosition, debug, query) {
|
|
390
|
-
this.debug(params);
|
|
391
|
-
this.debug("Self position", selfPosition);
|
|
392
|
-
const matcher = createMatcher(params, selfPosition, debug);
|
|
393
|
-
const contexts = Object.keys(this.tracks);
|
|
394
|
-
return (await Promise.all(contexts.map((context) => this.getTimed(context, query?.window).then((points) => ({
|
|
395
|
-
context,
|
|
396
|
-
points: thin(points, query?.resolution)
|
|
397
|
-
}))))).reduce((acc, { context, points }) => {
|
|
398
|
-
if (matcher(points.map(({ position }) => position))) acc[context] = points;
|
|
399
|
-
return acc;
|
|
400
|
-
}, {});
|
|
401
|
-
}
|
|
402
|
-
prune(maxAge) {
|
|
403
|
-
const cutoff = Date.now() - maxAge;
|
|
404
|
-
const deleted = [];
|
|
405
|
-
Object.entries(this.tracks).forEach(([key, value]) => {
|
|
406
|
-
if (value.latestLatLngTuple < cutoff) {
|
|
407
|
-
delete this.tracks[key];
|
|
408
|
-
deleted.push(key);
|
|
409
|
-
}
|
|
410
|
-
});
|
|
411
|
-
if (this.debug.enabled) this.debug(`deleted tracks for ${deleted.join(", ")}`);
|
|
412
|
-
}
|
|
413
|
-
};
|
|
414
|
-
var TrackAccumulator = class {
|
|
415
|
-
initialTrack = new BehaviorSubject([]);
|
|
416
|
-
input = new Subject();
|
|
417
|
-
latestLatLngTuple = 0;
|
|
418
|
-
accumulatedTrack;
|
|
419
|
-
/** Timestamped points, used to answer time-window queries. */
|
|
420
|
-
timedTrack;
|
|
421
|
-
/** Positions only — the long-standing public shape. */
|
|
422
|
-
track;
|
|
423
|
-
constructor({ resolution, pointsToKeep, fetchTrackFor }) {
|
|
424
|
-
this.accumulatedTrack = connectable(this.input.pipe(throttleTime(resolution), scan((acc, point) => {
|
|
425
|
-
acc.push(point);
|
|
426
|
-
return acc.slice(Math.max(0, acc.length - pointsToKeep));
|
|
427
|
-
}, []), startWith([])), {
|
|
428
|
-
connector: () => new ReplaySubject(1),
|
|
429
|
-
resetOnDisconnect: false
|
|
430
|
-
});
|
|
431
|
-
this.timedTrack = combineLatest([this.initialTrack, this.accumulatedTrack]).pipe(map(([initialTrack, accumulatedTrack]) => [...initialTrack, ...accumulatedTrack]));
|
|
432
|
-
this.track = this.timedTrack.pipe(map((points) => points.map(({ position }) => position)));
|
|
433
|
-
this.accumulatedTrack.connect();
|
|
434
|
-
if (fetchTrackFor) fetchTrack(fetchTrackFor).then((trackGEOJson) => {
|
|
435
|
-
const coordinates = trackGEOJson?.coordinates?.[0];
|
|
436
|
-
if (coordinates) this.initialTrack.next(coordinates.map((position) => ({
|
|
437
|
-
position,
|
|
438
|
-
timestamp: 0
|
|
439
|
-
})));
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
nextLatLngTuple(position, timestamp = Date.now()) {
|
|
443
|
-
this.input.next({
|
|
444
|
-
position,
|
|
445
|
-
timestamp
|
|
446
|
-
});
|
|
447
|
-
this.latestLatLngTuple = timestamp;
|
|
369
|
+
var historyRowPosition = (row) => {
|
|
370
|
+
if (!Array.isArray(row) || row.length < 2) return;
|
|
371
|
+
const value = row[1];
|
|
372
|
+
if (Array.isArray(value) && value.length === 2 && Number.isFinite(value[0]) && Number.isFinite(value[1])) {
|
|
373
|
+
const [longitude, latitude] = value;
|
|
374
|
+
return [latitude, longitude];
|
|
448
375
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
timestamp: timestamps?.[i] ?? 0
|
|
453
|
-
})));
|
|
376
|
+
if (value && typeof value === "object" && "latitude" in value && "longitude" in value) {
|
|
377
|
+
const { latitude, longitude } = value;
|
|
378
|
+
if (Number.isFinite(latitude) && Number.isFinite(longitude)) return [latitude, longitude];
|
|
454
379
|
}
|
|
455
380
|
};
|
|
456
|
-
var fetchTrack = (context) => {
|
|
457
|
-
const contextParts = context.split(".");
|
|
458
|
-
if (contextParts[0] !== "vessels") return Promise.resolve({});
|
|
459
|
-
return fetch(`/signalk/v1/api/vessels/${contextParts[1]}/track`, { credentials: "include" }).then((r) => r.status === 200 ? r.json() : Promise.resolve({}));
|
|
460
|
-
};
|
|
461
381
|
//#endregion
|
|
462
382
|
//#region src/trackProvider.ts
|
|
463
383
|
/** v2 sends `[west, south, east, north]`; a bounds here is `[lat, lng]` corners. */
|
|
@@ -511,21 +431,33 @@ var totalMilliseconds = (duration) => duration.total({
|
|
|
511
431
|
unit: "milliseconds",
|
|
512
432
|
relativeTo: CALENDAR_REFERENCE
|
|
513
433
|
});
|
|
434
|
+
/**
|
|
435
|
+
* Milliseconds back to a Duration, for reporting the spacing applied.
|
|
436
|
+
*
|
|
437
|
+
* Exact, not rounded to something tidier. A budget-derived spacing lands on
|
|
438
|
+
* values like 24470ms, and `PT24.47S` does read as false precision — but the
|
|
439
|
+
* field says what was applied, and a client re-querying with a rounded `PT24S`
|
|
440
|
+
* gets 51 points where the budget it asked for was 50. Reproducibility beats
|
|
441
|
+
* tidiness.
|
|
442
|
+
*/
|
|
443
|
+
var msToDuration = (ms) => Temporal.Duration.from({
|
|
444
|
+
milliseconds: Math.trunc(ms),
|
|
445
|
+
nanoseconds: Math.round((ms - Math.trunc(ms)) * 1e6)
|
|
446
|
+
}).round({ largestUnit: "hour" });
|
|
514
447
|
var toLngLat$1 = ([lat, lng]) => [lng, lat];
|
|
515
448
|
/** Bounding box of the returned geometry, in GeoJSON order. */
|
|
516
449
|
var boundsOf = (points) => {
|
|
517
450
|
if (points.length === 0) return;
|
|
518
|
-
let west = points[0].position[1];
|
|
519
|
-
let east = west;
|
|
520
451
|
let south = points[0].position[0];
|
|
521
452
|
let north = south;
|
|
453
|
+
const longitudes = [];
|
|
522
454
|
for (const { position } of points) {
|
|
523
455
|
const [lat, lng] = position;
|
|
524
|
-
if (lng < west) west = lng;
|
|
525
|
-
if (lng > east) east = lng;
|
|
526
456
|
if (lat < south) south = lat;
|
|
527
457
|
if (lat > north) north = lat;
|
|
458
|
+
longitudes.push(lng);
|
|
528
459
|
}
|
|
460
|
+
const [west, east] = longitudeSpan(longitudes);
|
|
529
461
|
return [
|
|
530
462
|
west,
|
|
531
463
|
south,
|
|
@@ -533,35 +465,78 @@ var boundsOf = (points) => {
|
|
|
533
465
|
north
|
|
534
466
|
];
|
|
535
467
|
};
|
|
468
|
+
/**
|
|
469
|
+
* The narrower of the two longitude intervals covering these points.
|
|
470
|
+
*
|
|
471
|
+
* A plain min/max describes a track spanning the antimeridian as almost the
|
|
472
|
+
* whole globe: two fixes at 179 and -179 are two degrees apart, but min/max
|
|
473
|
+
* reports 358. RFC 7946 says a box crossing the antimeridian is written with
|
|
474
|
+
* west greater than east, so the wrap-around interval is expressible — pick it
|
|
475
|
+
* whenever it is the shorter of the two.
|
|
476
|
+
*
|
|
477
|
+
* The widest gap between adjacent longitudes is the part of the globe the track
|
|
478
|
+
* does not cover, so the complement of that gap is the tightest interval that
|
|
479
|
+
* does.
|
|
480
|
+
*/
|
|
481
|
+
var longitudeSpan = (longitudes) => {
|
|
482
|
+
const sorted = [...longitudes].sort((a, b) => a - b);
|
|
483
|
+
const min = sorted[0];
|
|
484
|
+
const max = sorted[sorted.length - 1];
|
|
485
|
+
let widestGap = 360 - (max - min);
|
|
486
|
+
let west = min;
|
|
487
|
+
let east = max;
|
|
488
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
489
|
+
const gap = sorted[i] - sorted[i - 1];
|
|
490
|
+
if (gap > widestGap) {
|
|
491
|
+
widestGap = gap;
|
|
492
|
+
west = sorted[i];
|
|
493
|
+
east = sorted[i - 1];
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return [west, east];
|
|
497
|
+
};
|
|
536
498
|
function createTrackProvider(deps) {
|
|
537
499
|
const matching = async (query) => {
|
|
538
500
|
const store = deps.store();
|
|
539
|
-
if (!store) return
|
|
501
|
+
if (!store) return {
|
|
502
|
+
tracks: /* @__PURE__ */ new Map(),
|
|
503
|
+
resolution: void 0
|
|
504
|
+
};
|
|
540
505
|
const window = toTimeWindow(query);
|
|
541
506
|
const resolution = query.resolution ? totalMilliseconds(query.resolution) : void 0;
|
|
542
507
|
const wanted = query.contexts?.length ? new Set(query.contexts.map(resolveSelf(deps.selfContext()))) : void 0;
|
|
543
508
|
const collection = await store.getFilteredTimedTracks({
|
|
544
509
|
bbox: toGeoBounds(query.bbox),
|
|
545
|
-
radius: null
|
|
510
|
+
radius: null,
|
|
511
|
+
intersects: true
|
|
546
512
|
}, void 0, void 0, {
|
|
547
513
|
...window ? { window } : {},
|
|
548
514
|
...resolution === void 0 ? {} : { resolution }
|
|
549
515
|
});
|
|
550
516
|
const result = /* @__PURE__ */ new Map();
|
|
551
|
-
|
|
552
|
-
|
|
517
|
+
const contexts = Object.entries(collection).filter(([context]) => !wanted || wanted.has(context));
|
|
518
|
+
const reconciled = await Promise.all(contexts.map(async ([context, stored]) => [context, await deps.reconcileWithHistory(context, stored, window, resolution)]));
|
|
519
|
+
for (const [context, points] of reconciled) {
|
|
553
520
|
if (points.length === 0) continue;
|
|
554
521
|
result.set(context, points);
|
|
555
522
|
}
|
|
556
|
-
return
|
|
523
|
+
return {
|
|
524
|
+
tracks: result,
|
|
525
|
+
resolution
|
|
526
|
+
};
|
|
557
527
|
};
|
|
558
528
|
return {
|
|
559
529
|
async getTracks(query) {
|
|
560
|
-
const matched = await matching(query);
|
|
530
|
+
const { tracks: matched, resolution: requested } = await matching(query);
|
|
561
531
|
const gap = deps.segmentGap();
|
|
562
532
|
const selfContext = deps.selfContext();
|
|
563
533
|
const features = [];
|
|
564
|
-
for (const [context,
|
|
534
|
+
for (const [context, all] of matched) {
|
|
535
|
+
const { points, resolution: appliedMs } = query.maxPoints === void 0 ? {
|
|
536
|
+
points: all,
|
|
537
|
+
resolution: requested
|
|
538
|
+
} : thinToBudget(all, query.maxPoints, requested);
|
|
539
|
+
if (points.length === 0) continue;
|
|
565
540
|
const segments = segment(points, gap);
|
|
566
541
|
const bbox = boundsOf(points);
|
|
567
542
|
features.push({
|
|
@@ -577,7 +552,7 @@ function createTrackProvider(deps) {
|
|
|
577
552
|
to: new Date(points[points.length - 1].timestamp).toISOString(),
|
|
578
553
|
...bbox ? { bbox } : {},
|
|
579
554
|
pointCount: points.length,
|
|
580
|
-
...
|
|
555
|
+
...appliedMs === void 0 ? {} : { resolution: msToDuration(appliedMs).toString() },
|
|
581
556
|
...query.times ? { coordTimes: segments.map(toIsoTimes) } : {}
|
|
582
557
|
}
|
|
583
558
|
});
|
|
@@ -588,7 +563,7 @@ function createTrackProvider(deps) {
|
|
|
588
563
|
};
|
|
589
564
|
},
|
|
590
565
|
async getTrackContexts(query) {
|
|
591
|
-
return [...(await matching(query)).keys()];
|
|
566
|
+
return [...(await matching(query)).tracks.keys()];
|
|
592
567
|
}
|
|
593
568
|
};
|
|
594
569
|
}
|
|
@@ -11973,16 +11948,20 @@ var SqliteTrackStore = class {
|
|
|
11973
11948
|
}))));
|
|
11974
11949
|
}
|
|
11975
11950
|
/**
|
|
11976
|
-
* Tracks
|
|
11951
|
+
* Tracks matching the spatial predicate, which depends on who is asking.
|
|
11952
|
+
*
|
|
11953
|
+
* `intersects: false` — the v1 routes — matches a track's *last* position:
|
|
11954
|
+
* "which vessels are near me now". `intersects: true` — the v2 Track API —
|
|
11955
|
+
* matches a track that passed through the box at any point in the window,
|
|
11956
|
+
* including one that has since left.
|
|
11977
11957
|
*
|
|
11978
11958
|
* Two filters run, and they are not redundant despite both testing bounds:
|
|
11979
11959
|
*
|
|
11980
11960
|
* - `contextsInBounds` asks the cell index which contexts have *any* position
|
|
11981
11961
|
* near the box. It is a cheap prefilter that keeps the query off every row
|
|
11982
11962
|
* in the table, and it decides nothing on its own.
|
|
11983
|
-
* - `matcher` — the same predicate the in-memory store uses — then
|
|
11984
|
-
*
|
|
11985
|
-
* "in this box". That test is authoritative.
|
|
11963
|
+
* - `matcher` — the same predicate the in-memory store uses — then applies
|
|
11964
|
+
* whichever rule the caller asked for. That test is authoritative.
|
|
11986
11965
|
*
|
|
11987
11966
|
* Because `matcher` has the final say, dropping the exact-bounds check inside
|
|
11988
11967
|
* `contextsInBounds` does not change any result; it only widens the candidate
|
|
@@ -12046,10 +12025,16 @@ var SqliteTrackStore = class {
|
|
|
12046
12025
|
if (current.length > 0) result.push(current);
|
|
12047
12026
|
return result;
|
|
12048
12027
|
}
|
|
12049
|
-
prune(maxAge) {
|
|
12028
|
+
prune(maxAge, keep) {
|
|
12050
12029
|
const cutoff = Date.now() - maxAge;
|
|
12051
|
-
this.db.prepare("
|
|
12052
|
-
|
|
12030
|
+
const dropped = this.db.prepare("SELECT context FROM positions GROUP BY context HAVING MAX(timestamp) < ? AND context IS NOT ?").all(cutoff, keep ?? null);
|
|
12031
|
+
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);
|
|
12032
|
+
for (const { context } of dropped) this.lastStored.delete(context);
|
|
12033
|
+
if (this.retention > 0) {
|
|
12034
|
+
const oldest = Date.now() - this.retention;
|
|
12035
|
+
if (keep === void 0) this.db.prepare("DELETE FROM positions WHERE timestamp < ?").run(oldest);
|
|
12036
|
+
else this.db.prepare("DELETE FROM positions WHERE timestamp < ? AND context IS ?").run(oldest, keep);
|
|
12037
|
+
}
|
|
12053
12038
|
}
|
|
12054
12039
|
close() {
|
|
12055
12040
|
this.db.close();
|
|
@@ -12283,11 +12268,11 @@ var SourceWatch = class {
|
|
|
12283
12268
|
};
|
|
12284
12269
|
//#endregion
|
|
12285
12270
|
//#region src/stateGate.ts
|
|
12286
|
-
|
|
12287
|
-
|
|
12288
|
-
|
|
12289
|
-
|
|
12290
|
-
|
|
12271
|
+
var DEFAULT_PAUSE_STATES = [
|
|
12272
|
+
"moored",
|
|
12273
|
+
"not-under-way",
|
|
12274
|
+
"aground"
|
|
12275
|
+
];
|
|
12291
12276
|
/**
|
|
12292
12277
|
* The states a boat is plausibly parked in, offered in the plugin UI.
|
|
12293
12278
|
*
|
|
@@ -12352,25 +12337,150 @@ var StateGate = class {
|
|
|
12352
12337
|
}
|
|
12353
12338
|
};
|
|
12354
12339
|
//#endregion
|
|
12355
|
-
//#region src/
|
|
12340
|
+
//#region src/tracks.ts
|
|
12356
12341
|
/**
|
|
12357
|
-
*
|
|
12342
|
+
* The in-memory store: a bounded sliding window of positions per context, held
|
|
12343
|
+
* in RxJS accumulators and lost on restart.
|
|
12358
12344
|
*
|
|
12359
|
-
*
|
|
12360
|
-
*
|
|
12361
|
-
*
|
|
12362
|
-
* `false` still means "do not touch the History API", and everything else
|
|
12363
|
-
* lands on the old default of `history`.
|
|
12345
|
+
* No longer used by the plugin, which always records to SQLite. It remains as
|
|
12346
|
+
* the client-side `TrackAccumulator` this package exports, where a browser
|
|
12347
|
+
* holding one vessel's recent track in memory is exactly the right shape.
|
|
12364
12348
|
*/
|
|
12365
|
-
var
|
|
12366
|
-
|
|
12367
|
-
|
|
12349
|
+
var Tracks = class {
|
|
12350
|
+
tracks = {};
|
|
12351
|
+
debug;
|
|
12352
|
+
config;
|
|
12353
|
+
constructor(config, debug) {
|
|
12354
|
+
if (debug.enabled) debug(JSON.stringify(config));
|
|
12355
|
+
this.config = config;
|
|
12356
|
+
this.debug = debug;
|
|
12357
|
+
}
|
|
12358
|
+
newPosition(context, position, timestamp) {
|
|
12359
|
+
this.getAccumulator(context)?.nextLatLngTuple(position, timestamp);
|
|
12360
|
+
}
|
|
12361
|
+
initialTrack(context, track, timestamps) {
|
|
12362
|
+
this.getAccumulator(context)?.setInitialTrack(track, timestamps);
|
|
12363
|
+
}
|
|
12364
|
+
getAccumulator(context, createIfMissing = true) {
|
|
12365
|
+
if (context.indexOf("vessels.") === -1 && context.indexOf("aircraft.") === -1) return;
|
|
12366
|
+
let result = this.tracks[context];
|
|
12367
|
+
if (!result && createIfMissing) {
|
|
12368
|
+
const accParams = this.config.fetchInitialTrack ? {
|
|
12369
|
+
...this.config,
|
|
12370
|
+
fetchTrackFor: context
|
|
12371
|
+
} : { ...this.config };
|
|
12372
|
+
result = this.tracks[context] = new TrackAccumulator(accParams);
|
|
12373
|
+
}
|
|
12374
|
+
return result;
|
|
12375
|
+
}
|
|
12376
|
+
get(context, window) {
|
|
12377
|
+
return this.getTimed(context, window).then((points) => points.map(({ position }) => position));
|
|
12378
|
+
}
|
|
12379
|
+
/**
|
|
12380
|
+
* Track points with their timestamps, optionally narrowed to a time window.
|
|
12381
|
+
*
|
|
12382
|
+
* Rejects with EmptyError if the stream completes without emitting; the route
|
|
12383
|
+
* handlers turn that into the same 404 as an unknown context.
|
|
12384
|
+
*/
|
|
12385
|
+
getTimed(context, window) {
|
|
12386
|
+
const accumulator = this.getAccumulator(context, false);
|
|
12387
|
+
if (!accumulator) return Promise.reject(/* @__PURE__ */ new Error(`No track accumulator for ${context}`));
|
|
12388
|
+
return firstValueFrom(accumulator.timedTrack).then((points) => window ? points.filter(({ timestamp }) => timestamp >= window.from && (window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to)) : points);
|
|
12389
|
+
}
|
|
12390
|
+
getAllTracks(query) {
|
|
12391
|
+
return Promise.all(Object.keys(this.tracks).map((context) => this.getTimed(context, query?.window).then((points) => ({
|
|
12392
|
+
context,
|
|
12393
|
+
track: thin(points, query?.resolution).map(({ position }) => position)
|
|
12394
|
+
}))));
|
|
12395
|
+
}
|
|
12396
|
+
async getFilteredTracks(params, selfPosition, debug, query) {
|
|
12397
|
+
const timed = await this.getFilteredTimedTracks(params, selfPosition, debug, query);
|
|
12398
|
+
return Object.fromEntries(Object.entries(timed).map(([context, points]) => [context, points.map(({ position }) => position)]));
|
|
12399
|
+
}
|
|
12400
|
+
/**
|
|
12401
|
+
* The same filtering as `getFilteredTracks`, keeping each point's timestamp.
|
|
12402
|
+
*
|
|
12403
|
+
* The untimed form is derived from this one so the two cannot disagree about
|
|
12404
|
+
* which tracks match — the filter runs once, here.
|
|
12405
|
+
*/
|
|
12406
|
+
async getFilteredTimedTracks(params, selfPosition, debug, query) {
|
|
12407
|
+
this.debug(params);
|
|
12408
|
+
this.debug("Self position", selfPosition);
|
|
12409
|
+
const matcher = createMatcher(params, selfPosition, debug);
|
|
12410
|
+
const contexts = Object.keys(this.tracks);
|
|
12411
|
+
return (await Promise.all(contexts.map((context) => this.getTimed(context, query?.window).then((points) => ({
|
|
12412
|
+
context,
|
|
12413
|
+
points: thin(points, query?.resolution)
|
|
12414
|
+
}))))).reduce((acc, { context, points }) => {
|
|
12415
|
+
if (matcher(points.map(({ position }) => position))) acc[context] = points;
|
|
12416
|
+
return acc;
|
|
12417
|
+
}, {});
|
|
12418
|
+
}
|
|
12419
|
+
prune(maxAge, keep) {
|
|
12420
|
+
const cutoff = Date.now() - maxAge;
|
|
12421
|
+
const deleted = [];
|
|
12422
|
+
Object.entries(this.tracks).forEach(([key, value]) => {
|
|
12423
|
+
if (key !== keep && value.latestLatLngTuple < cutoff) {
|
|
12424
|
+
delete this.tracks[key];
|
|
12425
|
+
deleted.push(key);
|
|
12426
|
+
}
|
|
12427
|
+
});
|
|
12428
|
+
if (this.debug.enabled) this.debug(`deleted tracks for ${deleted.join(", ")}`);
|
|
12429
|
+
}
|
|
12368
12430
|
};
|
|
12431
|
+
var TrackAccumulator = class {
|
|
12432
|
+
initialTrack = new BehaviorSubject([]);
|
|
12433
|
+
input = new Subject();
|
|
12434
|
+
latestLatLngTuple = 0;
|
|
12435
|
+
accumulatedTrack;
|
|
12436
|
+
/** Timestamped points, used to answer time-window queries. */
|
|
12437
|
+
timedTrack;
|
|
12438
|
+
/** Positions only — the long-standing public shape. */
|
|
12439
|
+
track;
|
|
12440
|
+
constructor({ resolution, pointsToKeep, fetchTrackFor }) {
|
|
12441
|
+
this.accumulatedTrack = connectable(this.input.pipe(throttleTime(resolution), scan((acc, point) => {
|
|
12442
|
+
acc.push(point);
|
|
12443
|
+
return acc.slice(Math.max(0, acc.length - pointsToKeep));
|
|
12444
|
+
}, []), startWith([])), {
|
|
12445
|
+
connector: () => new ReplaySubject(1),
|
|
12446
|
+
resetOnDisconnect: false
|
|
12447
|
+
});
|
|
12448
|
+
this.timedTrack = combineLatest([this.initialTrack, this.accumulatedTrack]).pipe(map(([initialTrack, accumulatedTrack]) => [...initialTrack, ...accumulatedTrack]));
|
|
12449
|
+
this.track = this.timedTrack.pipe(map((points) => points.map(({ position }) => position)));
|
|
12450
|
+
this.accumulatedTrack.connect();
|
|
12451
|
+
if (fetchTrackFor) fetchTrack(fetchTrackFor).then((trackGEOJson) => {
|
|
12452
|
+
const coordinates = trackGEOJson?.coordinates?.[0];
|
|
12453
|
+
if (coordinates) this.initialTrack.next(coordinates.map((position) => ({
|
|
12454
|
+
position,
|
|
12455
|
+
timestamp: 0
|
|
12456
|
+
})));
|
|
12457
|
+
});
|
|
12458
|
+
}
|
|
12459
|
+
nextLatLngTuple(position, timestamp = Date.now()) {
|
|
12460
|
+
this.input.next({
|
|
12461
|
+
position,
|
|
12462
|
+
timestamp
|
|
12463
|
+
});
|
|
12464
|
+
this.latestLatLngTuple = timestamp;
|
|
12465
|
+
}
|
|
12466
|
+
setInitialTrack(track, timestamps) {
|
|
12467
|
+
this.initialTrack.next(track.map((position, i) => ({
|
|
12468
|
+
position,
|
|
12469
|
+
timestamp: timestamps?.[i] ?? 0
|
|
12470
|
+
})));
|
|
12471
|
+
}
|
|
12472
|
+
};
|
|
12473
|
+
var fetchTrack = (context) => {
|
|
12474
|
+
const contextParts = context.split(".");
|
|
12475
|
+
if (contextParts[0] !== "vessels") return Promise.resolve({});
|
|
12476
|
+
return fetch(`/signalk/v1/api/vessels/${contextParts[1]}/track`, { credentials: "include" }).then((r) => r.status === 200 ? r.json() : Promise.resolve({}));
|
|
12477
|
+
};
|
|
12478
|
+
//#endregion
|
|
12479
|
+
//#region src/index.ts
|
|
12369
12480
|
var toLngLat = ([lat, lng]) => [lng, lat];
|
|
12370
12481
|
var DEFAULT_RESOLUTION = 6e4;
|
|
12371
|
-
var
|
|
12372
|
-
var
|
|
12373
|
-
var DEFAULT_MAX_RADIUS = 5e4;
|
|
12482
|
+
var DEFAULT_AIS_RETENTION_DAYS = 30;
|
|
12483
|
+
var PRUNE_INTERVAL_MS = 36e5;
|
|
12374
12484
|
var DEFAULT_SEGMENT_GAP_MINUTES = 0;
|
|
12375
12485
|
var SOURCE_STATUS_INTERVAL_MS = 3e4;
|
|
12376
12486
|
/**
|
|
@@ -12392,10 +12502,6 @@ var HISTORY_QUERY_TIMEOUT_MS = 5e3;
|
|
|
12392
12502
|
* `duration` or `timespan` reaches further.
|
|
12393
12503
|
*/
|
|
12394
12504
|
var WINDOWLESS_HISTORY_SPAN_MS = 864e5;
|
|
12395
|
-
var BOOTSTRAP_INITIAL_DELAY = 5e3;
|
|
12396
|
-
var BOOTSTRAP_RETRY_DELAY = 15e3;
|
|
12397
|
-
var BOOTSTRAP_MAX_ATTEMPTS = 18;
|
|
12398
|
-
var BOOTSTRAP_MAX_NO_PROVIDER = 3;
|
|
12399
12505
|
/**
|
|
12400
12506
|
* Config values arrive from the plugin UI as numbers, but a hand-edited
|
|
12401
12507
|
* settings file can supply strings. Accept both, reject anything non-finite so
|
|
@@ -12412,27 +12518,7 @@ var notAvailable = (res) => {
|
|
|
12412
12518
|
res.status(404);
|
|
12413
12519
|
res.json({ message: "Tracks API not available because tracks plugin is not enabled" });
|
|
12414
12520
|
};
|
|
12415
|
-
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
12416
12521
|
var errorDetail = (err) => err instanceof Error && err.stack ? err.stack : String(err);
|
|
12417
|
-
var isNoProviderError = (err) => {
|
|
12418
|
-
const text = String(err);
|
|
12419
|
-
return text.includes("No history") && text.includes("provider");
|
|
12420
|
-
};
|
|
12421
|
-
/**
|
|
12422
|
-
* A history row is `[timestamp, position]`. Providers disagree on how the
|
|
12423
|
-
* position is encoded: signalk-questdb returns `{latitude, longitude}`, while
|
|
12424
|
-
* a `[lon, lat]` pair is the shape the History API's own docs describe. Accept
|
|
12425
|
-
* both — rejecting either silently bootstraps an empty track.
|
|
12426
|
-
*/
|
|
12427
|
-
var historyRowPosition = (row) => {
|
|
12428
|
-
if (!Array.isArray(row) || row.length < 2) return;
|
|
12429
|
-
const value = row[1];
|
|
12430
|
-
if (Array.isArray(value) && value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number") return [value[1], value[0]];
|
|
12431
|
-
if (value && typeof value === "object" && "latitude" in value && "longitude" in value) {
|
|
12432
|
-
const { latitude, longitude } = value;
|
|
12433
|
-
if (typeof latitude === "number" && typeof longitude === "number") return [latitude, longitude];
|
|
12434
|
-
}
|
|
12435
|
-
};
|
|
12436
12522
|
/**
|
|
12437
12523
|
* A window covering everything the store holds, up to now.
|
|
12438
12524
|
*
|
|
@@ -12532,92 +12618,6 @@ var historyRowTimestamp = (row) => {
|
|
|
12532
12618
|
const parsed = Date.parse(raw);
|
|
12533
12619
|
return Number.isNaN(parsed) ? 0 : parsed;
|
|
12534
12620
|
};
|
|
12535
|
-
async function bootstrapSelfTrack(app, tracks, config, glitchFilter) {
|
|
12536
|
-
const { debug } = app;
|
|
12537
|
-
const getHistoryApi = app.getHistoryApi;
|
|
12538
|
-
if (!getHistoryApi) {
|
|
12539
|
-
debug("getHistoryApi not available on server, skipping track bootstrap");
|
|
12540
|
-
return;
|
|
12541
|
-
}
|
|
12542
|
-
if (!app.selfContext) {
|
|
12543
|
-
debug("selfContext not available, skipping track bootstrap");
|
|
12544
|
-
return;
|
|
12545
|
-
}
|
|
12546
|
-
const configuredProvider = app.config?.settings?.historyApi?.defaultProvider;
|
|
12547
|
-
const resolution = toNumber(config.resolution) ?? DEFAULT_RESOLUTION;
|
|
12548
|
-
const timespanMs = resolution * (toNumber(config.pointsToKeep) ?? DEFAULT_POINTS_TO_KEEP);
|
|
12549
|
-
const resolutionSecs = Math.max(1, Math.round(resolution / 1e3));
|
|
12550
|
-
const timespanMinutes = Math.round(timespanMs / 1e3 / 60);
|
|
12551
|
-
debug(`Track bootstrap: requesting ${timespanMinutes} minutes of history at ${resolutionSecs}s resolution (max ${BOOTSTRAP_MAX_ATTEMPTS} attempts)`);
|
|
12552
|
-
let noProviderCount = 0;
|
|
12553
|
-
for (let attempt = 1; attempt <= BOOTSTRAP_MAX_ATTEMPTS; attempt++) {
|
|
12554
|
-
const delay = attempt === 1 ? BOOTSTRAP_INITIAL_DELAY : BOOTSTRAP_RETRY_DELAY;
|
|
12555
|
-
debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}, waiting ${delay / 1e3}s...`);
|
|
12556
|
-
await sleep(delay);
|
|
12557
|
-
try {
|
|
12558
|
-
const historyApi = await getHistoryApi(configuredProvider);
|
|
12559
|
-
noProviderCount = 0;
|
|
12560
|
-
const to = /* @__PURE__ */ new Date();
|
|
12561
|
-
const from = new Date(to.getTime() - timespanMs);
|
|
12562
|
-
const response = await historyApi.getValues({
|
|
12563
|
-
context: app.selfContext,
|
|
12564
|
-
from: Temporal.Instant.from(from.toISOString()),
|
|
12565
|
-
to: Temporal.Instant.from(to.toISOString()),
|
|
12566
|
-
pathSpecs: [{
|
|
12567
|
-
path: "navigation.position",
|
|
12568
|
-
aggregate: "first"
|
|
12569
|
-
}],
|
|
12570
|
-
resolution: resolutionSecs
|
|
12571
|
-
});
|
|
12572
|
-
if (response?.data && response.data.length > 0) {
|
|
12573
|
-
const positions = [];
|
|
12574
|
-
const timestamps = [];
|
|
12575
|
-
let dropped = 0;
|
|
12576
|
-
for (const row of response.data) {
|
|
12577
|
-
const position = historyRowPosition(row);
|
|
12578
|
-
if (position) {
|
|
12579
|
-
const timestamp = historyRowTimestamp(row);
|
|
12580
|
-
if (timestamp === 0) {
|
|
12581
|
-
dropped++;
|
|
12582
|
-
continue;
|
|
12583
|
-
}
|
|
12584
|
-
if (!glitchFilter.accept(app.selfContext, position, timestamp)) {
|
|
12585
|
-
dropped++;
|
|
12586
|
-
continue;
|
|
12587
|
-
}
|
|
12588
|
-
positions.push(position);
|
|
12589
|
-
timestamps.push(timestamp);
|
|
12590
|
-
}
|
|
12591
|
-
}
|
|
12592
|
-
if (dropped > 0) debug(`Track bootstrap discarded ${dropped} position(s) as glitches`);
|
|
12593
|
-
if (positions.length > 0) {
|
|
12594
|
-
tracks.initialTrack(app.selfContext, positions, timestamps);
|
|
12595
|
-
debug(`Track bootstrap complete: loaded ${positions.length} positions for self (${timespanMinutes} min window) on attempt ${attempt}`);
|
|
12596
|
-
return;
|
|
12597
|
-
}
|
|
12598
|
-
}
|
|
12599
|
-
debug(`History API returned no position data on attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}; the configured provider may not have registered yet`);
|
|
12600
|
-
if (attempt === BOOTSTRAP_MAX_ATTEMPTS) {
|
|
12601
|
-
debug("History API returned no position data for bootstrap");
|
|
12602
|
-
return;
|
|
12603
|
-
}
|
|
12604
|
-
continue;
|
|
12605
|
-
} catch (err) {
|
|
12606
|
-
if (isNoProviderError(err)) {
|
|
12607
|
-
noProviderCount++;
|
|
12608
|
-
debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}: no history provider registered yet (${noProviderCount}/${BOOTSTRAP_MAX_NO_PROVIDER})`);
|
|
12609
|
-
if (noProviderCount >= BOOTSTRAP_MAX_NO_PROVIDER) {
|
|
12610
|
-
debug(`No history provider registered after ${BOOTSTRAP_MAX_NO_PROVIDER} consecutive checks — no provider plugin appears to be installed. Giving up.`);
|
|
12611
|
-
return;
|
|
12612
|
-
}
|
|
12613
|
-
} else {
|
|
12614
|
-
noProviderCount = 0;
|
|
12615
|
-
debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS} failed: ${errorDetail(err)}`);
|
|
12616
|
-
}
|
|
12617
|
-
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.`);
|
|
12618
|
-
}
|
|
12619
|
-
}
|
|
12620
|
-
}
|
|
12621
12621
|
function ThePlugin(app) {
|
|
12622
12622
|
let onStop = [];
|
|
12623
12623
|
let tracks = void 0;
|
|
@@ -12630,7 +12630,6 @@ function ThePlugin(app) {
|
|
|
12630
12630
|
* own.
|
|
12631
12631
|
*/
|
|
12632
12632
|
let storeResolution = DEFAULT_RESOLUTION;
|
|
12633
|
-
let defaultMaxRadius = void 0;
|
|
12634
12633
|
const sourceWatch = new SourceWatch();
|
|
12635
12634
|
let glitchFilter = new GlitchFilter({ maxSpeedKnots: 100 });
|
|
12636
12635
|
let stateGate = new StateGate(app.selfContext, DEFAULT_PAUSE_STATES);
|
|
@@ -12651,25 +12650,28 @@ function ThePlugin(app) {
|
|
|
12651
12650
|
}
|
|
12652
12651
|
return {
|
|
12653
12652
|
start: function(config) {
|
|
12654
|
-
const { resolution
|
|
12655
|
-
defaultMaxRadius = toNumber(maxRadius);
|
|
12656
|
-
const source = resolveSource(config);
|
|
12653
|
+
const { resolution } = config;
|
|
12657
12654
|
storeResolution = toNumber(config.resolution) ?? DEFAULT_RESOLUTION;
|
|
12658
12655
|
const segmentGapMinutes = toNumber(config.segmentGapMinutes) ?? DEFAULT_SEGMENT_GAP_MINUTES;
|
|
12659
12656
|
segmentGap = segmentGapMinutes > 0 ? segmentGapMinutes * 60 * 1e3 : 0;
|
|
12660
12657
|
glitchFilter = new GlitchFilter({ maxSpeedKnots: toNumber(config.maxSpeedKnots) ?? 100 });
|
|
12661
12658
|
stateGate = new StateGate(app.selfContext, Array.isArray(config.pauseWhenState) ? config.pauseWhenState : DEFAULT_PAUSE_STATES);
|
|
12662
|
-
const dataDir =
|
|
12663
|
-
if (
|
|
12664
|
-
|
|
12665
|
-
|
|
12666
|
-
|
|
12667
|
-
|
|
12668
|
-
|
|
12669
|
-
|
|
12670
|
-
|
|
12671
|
-
|
|
12672
|
-
|
|
12659
|
+
const dataDir = app.getDataDirPath?.();
|
|
12660
|
+
if (!dataDir) {
|
|
12661
|
+
app.error("This server provides no plugin data directory, so tracks cannot be recorded.");
|
|
12662
|
+
return;
|
|
12663
|
+
}
|
|
12664
|
+
try {
|
|
12665
|
+
tracks = new SqliteTrackStore({
|
|
12666
|
+
file: join(dataDir, "tracks.db"),
|
|
12667
|
+
resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,
|
|
12668
|
+
retention: (toNumber(config.retentionDays) ?? 0) * 24 * 60 * 60 * 1e3,
|
|
12669
|
+
segmentGap
|
|
12670
|
+
}, app.debug);
|
|
12671
|
+
} catch (err) {
|
|
12672
|
+
app.error(`Could not open the track database in ${dataDir}: ${errorDetail(err)}`);
|
|
12673
|
+
return;
|
|
12674
|
+
}
|
|
12673
12675
|
onStop.push(app.streambundle.getBus("navigation.position").onValue((update) => {
|
|
12674
12676
|
if (!update.value || update.value.latitude == null || update.value.longitude == null) return;
|
|
12675
12677
|
sourceWatch.add(update.context, update.$source);
|
|
@@ -12680,8 +12682,10 @@ function ThePlugin(app) {
|
|
|
12680
12682
|
if (!glitchFilter.accept(update.context, position, timestamp ?? Date.now())) return;
|
|
12681
12683
|
tracks?.newPosition(update.context, position, timestamp);
|
|
12682
12684
|
}));
|
|
12683
|
-
const
|
|
12684
|
-
const pruneInterval = setInterval(() =>
|
|
12685
|
+
const aisRetentionMs = (toNumber(config.aisRetentionDays) ?? DEFAULT_AIS_RETENTION_DAYS) * 24 * 60 * 60 * 1e3;
|
|
12686
|
+
const pruneInterval = setInterval(() => {
|
|
12687
|
+
tracks?.prune(aisRetentionMs > 0 ? aisRetentionMs : Infinity, app.selfContext);
|
|
12688
|
+
}, PRUNE_INTERVAL_MS);
|
|
12685
12689
|
onStop.push(() => {
|
|
12686
12690
|
clearInterval(pruneInterval);
|
|
12687
12691
|
});
|
|
@@ -12699,11 +12703,13 @@ function ThePlugin(app) {
|
|
|
12699
12703
|
app.registerTrackApiProvider?.(createTrackProvider({
|
|
12700
12704
|
store: () => tracks,
|
|
12701
12705
|
selfContext: () => app.selfContext,
|
|
12702
|
-
segmentGap: () => segmentGap
|
|
12706
|
+
segmentGap: () => segmentGap,
|
|
12707
|
+
reconcileWithHistory: async (context, stored, window, resolutionMs) => {
|
|
12708
|
+
const effective = resolutionMs ?? storeResolution;
|
|
12709
|
+
const history = await historyPositions(app, context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective, app.debug);
|
|
12710
|
+
return history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
|
|
12711
|
+
}
|
|
12703
12712
|
}));
|
|
12704
|
-
if (source === "history") bootstrapSelfTrack(app, tracks, config, glitchFilter).catch((err) => {
|
|
12705
|
-
app.error(`Unexpected error in track bootstrap: ${errorDetail(err)}`);
|
|
12706
|
-
});
|
|
12707
12713
|
},
|
|
12708
12714
|
stop: function() {
|
|
12709
12715
|
onStop.forEach((f) => {
|
|
@@ -12721,6 +12727,8 @@ function ThePlugin(app) {
|
|
|
12721
12727
|
tracks?.close?.();
|
|
12722
12728
|
} catch (err) {
|
|
12723
12729
|
app.error(err);
|
|
12730
|
+
} finally {
|
|
12731
|
+
tracks = void 0;
|
|
12724
12732
|
}
|
|
12725
12733
|
},
|
|
12726
12734
|
signalKApiRoutes: function(router) {
|
|
@@ -12784,7 +12792,7 @@ function ThePlugin(app) {
|
|
|
12784
12792
|
res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
|
|
12785
12793
|
return;
|
|
12786
12794
|
}
|
|
12787
|
-
const params = validateParameters(req.query,
|
|
12795
|
+
const params = validateParameters(req.query, void 0);
|
|
12788
12796
|
const selfPosition = getVesselPosition();
|
|
12789
12797
|
(query.times ? tracks.getFilteredTimedTracks(params, selfPosition, app.debug, query).then((tc) => Object.entries(tc).reduce((acc, [context, points]) => {
|
|
12790
12798
|
const segments = segment(points, segmentGap);
|
|
@@ -12816,55 +12824,31 @@ function ThePlugin(app) {
|
|
|
12816
12824
|
getTracks: () => tracks,
|
|
12817
12825
|
id: "tracks",
|
|
12818
12826
|
name: "Tracks",
|
|
12819
|
-
|
|
12827
|
+
enabledByDefault: true,
|
|
12828
|
+
description: "Record vessel tracks to SQLite and serve them through the track API",
|
|
12820
12829
|
schema: {
|
|
12821
12830
|
type: "object",
|
|
12822
12831
|
properties: {
|
|
12823
12832
|
resolution: {
|
|
12824
12833
|
type: "integer",
|
|
12834
|
+
minimum: 0,
|
|
12825
12835
|
title: "Track resolution (milliseconds)",
|
|
12826
12836
|
default: DEFAULT_RESOLUTION
|
|
12827
12837
|
},
|
|
12828
|
-
pointsToKeep: {
|
|
12829
|
-
type: "integer",
|
|
12830
|
-
title: "Points to keep",
|
|
12831
|
-
description: "How many trackpoints to keep for each track",
|
|
12832
|
-
default: DEFAULT_POINTS_TO_KEEP
|
|
12833
|
-
},
|
|
12834
|
-
maxAge: {
|
|
12835
|
-
type: "integer",
|
|
12836
|
-
title: "Maximum idle time (seconds)",
|
|
12837
|
-
description: "Tracks with no updates longer than this are removed",
|
|
12838
|
-
default: DEFAULT_MAX_AGE
|
|
12839
|
-
},
|
|
12840
|
-
maxRadius: {
|
|
12841
|
-
type: "integer",
|
|
12842
|
-
title: "Maximum Radius (meters) ",
|
|
12843
|
-
description: "Include only vessels with position within this range. 0= all vessels",
|
|
12844
|
-
default: DEFAULT_MAX_RADIUS
|
|
12845
|
-
},
|
|
12846
|
-
source: {
|
|
12847
|
-
type: "string",
|
|
12848
|
-
title: "Where tracks come from after a restart",
|
|
12849
|
-
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.",
|
|
12850
|
-
enum: [
|
|
12851
|
-
"memory",
|
|
12852
|
-
"history",
|
|
12853
|
-
"sqlite"
|
|
12854
|
-
],
|
|
12855
|
-
enumNames: [
|
|
12856
|
-
"In memory only",
|
|
12857
|
-
"Load from the History API on startup",
|
|
12858
|
-
"Record to a SQLite database"
|
|
12859
|
-
],
|
|
12860
|
-
default: "history"
|
|
12861
|
-
},
|
|
12862
12838
|
retentionDays: {
|
|
12863
12839
|
type: "integer",
|
|
12864
|
-
|
|
12865
|
-
|
|
12840
|
+
minimum: 0,
|
|
12841
|
+
title: "Days of the own vessel's track to keep",
|
|
12842
|
+
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.",
|
|
12866
12843
|
default: 0
|
|
12867
12844
|
},
|
|
12845
|
+
aisRetentionDays: {
|
|
12846
|
+
type: "integer",
|
|
12847
|
+
minimum: 0,
|
|
12848
|
+
title: "Days to keep another vessel after its last fix",
|
|
12849
|
+
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.",
|
|
12850
|
+
default: DEFAULT_AIS_RETENTION_DAYS
|
|
12851
|
+
},
|
|
12868
12852
|
segmentGapMinutes: {
|
|
12869
12853
|
type: "integer",
|
|
12870
12854
|
title: "Split a track after this many minutes without a fix",
|
|
@@ -12893,6 +12877,6 @@ function ThePlugin(app) {
|
|
|
12893
12877
|
};
|
|
12894
12878
|
}
|
|
12895
12879
|
//#endregion
|
|
12896
|
-
export { TrackAccumulator, Tracks, ThePlugin as default
|
|
12880
|
+
export { TrackAccumulator, Tracks, ThePlugin as default };
|
|
12897
12881
|
|
|
12898
12882
|
//# sourceMappingURL=index.js.map
|