@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/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
- * The in-memory store: a bounded sliding window of positions per context,
329
- * held in RxJS accumulators and lost on restart. `bootstrapSelfTrack()` in
330
- * index.ts is what re-hydrates it from a History provider at startup.
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 Tracks = class {
333
- tracks = {};
334
- debug;
335
- config;
336
- constructor(config, debug) {
337
- if (debug.enabled) debug(JSON.stringify(config));
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
- setInitialTrack(track, timestamps) {
450
- this.initialTrack.next(track.map((position, i) => ({
451
- position,
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 /* @__PURE__ */ new Map();
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
- for (const [context, points] of Object.entries(collection)) {
552
- if (wanted && !wanted.has(context)) continue;
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 result;
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, points] of matched) {
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
- ...query.resolution ? { resolution: query.resolution.toString() } : {},
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 whose last position matches the spatial predicate.
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 tests each
11984
- * track's *last* position, which is what the endpoint actually means by
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("DELETE FROM positions WHERE context IN (SELECT context FROM positions GROUP BY context HAVING MAX(timestamp) < ?)").run(cutoff);
12052
- if (this.retention > 0) this.db.prepare("DELETE FROM positions WHERE timestamp < ?").run(Date.now() - this.retention);
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
- * Off unless a user opts in, per the request and because the states worth
12288
- * pausing on depend on how a boat is used.
12289
- */
12290
- var DEFAULT_PAUSE_STATES = [];
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/index.ts
12340
+ //#region src/tracks.ts
12356
12341
  /**
12357
- * Resolve the effective source, honouring the setting this replaced.
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
- * `bootstrapFromHistory` shipped as a boolean defaulting to true, so an
12360
- * installed plugin has it saved in its config either way. Reading `source`
12361
- * first and only then falling back keeps those installs working: an explicit
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 resolveSource = (config) => {
12366
- if (config.source) return config.source;
12367
- return config.bootstrapFromHistory === false ? "memory" : "history";
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 DEFAULT_POINTS_TO_KEEP = 120;
12372
- var DEFAULT_MAX_AGE = 600;
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, pointsToKeep, maxAge, maxRadius } = config;
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 = source === "sqlite" ? app.getDataDirPath?.() : void 0;
12663
- if (source === "sqlite" && !dataDir) app.error("source is sqlite but this server provides no plugin data directory; using memory instead");
12664
- tracks = dataDir ? new SqliteTrackStore({
12665
- file: join(dataDir, "tracks.db"),
12666
- resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,
12667
- retention: (toNumber(config.retentionDays) ?? 0) * 24 * 60 * 60 * 1e3,
12668
- segmentGap
12669
- }, app.debug) : new Tracks({
12670
- resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,
12671
- pointsToKeep: toNumber(pointsToKeep) ?? DEFAULT_POINTS_TO_KEEP
12672
- }, app.debug);
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 theMaxAge = toNumber(maxAge) ?? DEFAULT_MAX_AGE;
12684
- const pruneInterval = setInterval(() => tracks?.prune(theMaxAge * 1e3), theMaxAge * 1e3 / 2);
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, defaultMaxRadius);
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
- description: "Accumulate tracks in memory for the track API implementation",
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
- title: "Days of track history to keep (SQLite only)",
12865
- description: "Positions older than this are deleted. 0 keeps everything.",
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, resolveSource };
12880
+ export { TrackAccumulator, Tracks, ThePlugin as default };
12897
12881
 
12898
12882
  //# sourceMappingURL=index.js.map