@signalk/tracks-plugin 3.0.2 → 3.1.0

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.
@@ -42,6 +42,8 @@ export declare class SqliteTrackStore implements TrackStore {
42
42
  private readonly segmentGap;
43
43
  private readonly retention;
44
44
  private readonly resolution;
45
+ /** See CLIP_STRETCH_GAP; widened so a coarse write resolution does not split every fix. */
46
+ private readonly stretchGap;
45
47
  private readonly debug;
46
48
  /** Timestamp of the last position stored per context, for throttling. */
47
49
  private readonly lastStored;
@@ -94,8 +96,24 @@ export declare class SqliteTrackStore implements TrackStore {
94
96
  * last-position test run once, here.
95
97
  */
96
98
  getFilteredTimedTracks(params: TrackParams, selfPosition?: LatLngTuple, debug?: Debug, query?: TrackQuery): Promise<TimedTrackCollection>;
99
+ /** The cell index condition for rows near `bounds`, or none for an empty covering. */
100
+ private coveringClause;
97
101
  /** Contexts with at least one position inside `bounds`, via the cell index. */
98
102
  private contextsInBounds;
103
+ /**
104
+ * Per context, the positions a track clipped to `bounds` is made of, and
105
+ * only those: every position inside the box within the window, plus the one
106
+ * either side of each stretch inside, which carries the line to the edge.
107
+ *
108
+ * The cell index finds the times a context was inside the box. Times closer
109
+ * together than `stretchGap` are read as one stretch, including any
110
+ * excursion outside it, since the clip that follows cuts those anyway;
111
+ * further apart they are read separately and the time between is skipped.
112
+ * Nothing between two stretches is inside the box, so skipping it loses
113
+ * nothing the clip would keep: how they are grouped decides only how many
114
+ * rows are read, never the result.
115
+ */
116
+ private rowsNearBounds;
99
117
  /**
100
118
  * Split a context's positions into segments, breaking where the recording
101
119
  * stopped for longer than `segmentGap` — an overnight stop should not draw a
@@ -25,9 +25,32 @@ export interface TrackQuery {
25
25
  * and the whole retained track is returned, as before.
26
26
  */
27
27
  export declare function parseTrackQuery(query: Record<string, unknown>, now?: number, alsoAllowed?: readonly string[]): TrackQuery;
28
+ /**
29
+ * The time between two points that no history bucket either side can account
30
+ * for.
31
+ *
32
+ * Providers disagree on whether a bucket's timestamp marks its start or its
33
+ * end, so a bucket may cover up to its width on either side of it. Two
34
+ * buckets, however, cover the time between their timestamps once between
35
+ * them, so one width is taken off, the wider of the two: exact between
36
+ * buckets under either convention, and between a fix and a bucket never more
37
+ * than the pause could be.
38
+ */
39
+ export declare function uncoveredBetween(previous: TimedPosition, point: TimedPosition): number;
40
+ /**
41
+ * The pause in the recording between two consecutive points: where thinning
42
+ * dropped the points between them, the longest pause among those it recorded,
43
+ * and otherwise whatever of the time between them no bucket accounts for.
44
+ */
45
+ export declare function pauseBetween(previous: TimedPosition, point: TimedPosition): number;
28
46
  /**
29
47
  * Drop points closer together than `resolution` milliseconds, always keeping
30
48
  * the first and last so the track keeps its extent.
49
+ *
50
+ * A point kept after dropped ones records the longest pause among them as
51
+ * `pauseBefore`: the kept points are further apart than anything recorded
52
+ * between them, so without it `segment()` would read the spacing thinning
53
+ * made as a stop in the recording.
31
54
  */
32
55
  export declare function thin(points: TimedPosition[], resolution: number | undefined): TimedPosition[];
33
56
  /**
@@ -57,11 +80,16 @@ export declare function thinToBudget(points: TimedPosition[], maxPoints: number,
57
80
  * over an anchorage it actually sat still in.
58
81
  *
59
82
  * Operates on timestamped points rather than inside a store, so both the
60
- * in-memory and sqlite stores segment identically, and so it composes with
61
- * `thin()` — thinning first, then segmenting, keeps a thinned-away gap from
62
- * being invented as a join.
83
+ * in-memory and sqlite stores segment identically. A gap is a pause in the
84
+ * recording, not in the points given: see `pauseBetween`, which lets a
85
+ * thinned track, or one reconciled with history buckets, segment exactly as
86
+ * the recording would.
87
+ *
88
+ * A point marked `breakBefore` starts a new segment whatever the gap: a
89
+ * clipped track leaves the box there.
63
90
  *
64
- * `gap` of 0 or undefined returns a single segment, preserving the old shape.
91
+ * `gap` of 0 or undefined otherwise returns a single segment, preserving the
92
+ * old shape.
65
93
  * An empty input returns no segments rather than one empty one, so a caller can
66
94
  * distinguish "no track" from "a track with no points".
67
95
  */
@@ -32,6 +32,12 @@ export interface TracksRequest {
32
32
  to?: Temporal.Instant;
33
33
  duration?: Temporal.Duration;
34
34
  bbox?: TrackBoundingBox;
35
+ /**
36
+ * Cut each track to `bbox`. The server resolves the default, so a provider
37
+ * sees an explicit value; absent means a server that predates the
38
+ * parameter, which returned whole tracks.
39
+ */
40
+ clip?: boolean;
35
41
  resolution?: Temporal.Duration;
36
42
  maxPoints?: number;
37
43
  simplify?: boolean;
@@ -40,6 +40,15 @@ export interface TrackProviderDeps {
40
40
  * Resolves to the store's own points unchanged when no provider is
41
41
  * installed, which is the common case.
42
42
  */
43
+ /**
44
+ * Contexts a history provider holds data for within the window, empty when
45
+ * there is no provider.
46
+ *
47
+ * The store can be far younger than the provider, so it alone cannot say
48
+ * which vessels exist or which passed through a box: a vessel recorded
49
+ * only before this plugin was installed is known to history alone.
50
+ */
51
+ historyContexts: (window: TimeWindow | undefined) => Promise<ReadonlySet<string>>;
43
52
  reconcileWithHistory: (context: string, stored: TimedPosition[], window: TimeWindow | undefined, resolutionMs: number | undefined) => Promise<TimedPosition[]>;
44
53
  }
45
54
  export declare function createTrackProvider(deps: TrackProviderDeps): TrackApi;
@@ -1,4 +1,4 @@
1
- import { b as thin, f as splitAtAntimeridian, n as SelfPositionUnavailableError, o as createInBounds, s as createMatcher } from "./utils-OGjP1B0D.js";
1
+ import { c as createMatcher, n as SelfPositionUnavailableError, p as splitAtAntimeridian, s as createInBounds, x as thin } from "./utils-CGIGp4Xf.js";
2
2
  import { parentPort, workerData } from "node:worker_threads";
3
3
  import { DatabaseSync } from "node:sqlite";
4
4
  //#region \0rolldown/runtime.js
@@ -11234,6 +11234,20 @@ var _index$5 = {
11234
11234
  //#endregion
11235
11235
  //#region src/sqliteStore.ts
11236
11236
  var TransactionStateError = class extends Error {};
11237
+ var toTimed = ({ lat, lon, timestamp }) => ({
11238
+ position: [lat, lon],
11239
+ timestamp
11240
+ });
11241
+ /** Sorted times grouped into `[first, last]` stretches, split where two lie more than `gap` apart. */
11242
+ var stretchesOf = (times, gap) => {
11243
+ const stretches = [];
11244
+ for (const time of times) {
11245
+ const current = stretches[stretches.length - 1];
11246
+ if (current && time - current[1] <= gap) current[1] = time;
11247
+ else stretches.push([time, time]);
11248
+ }
11249
+ return stretches;
11250
+ };
11237
11251
  /**
11238
11252
  * S2 cell ids are unsigned 64-bit; SQLite's INTEGER is signed 64-bit, and
11239
11253
  * node:sqlite refuses to bind a bigint above INT64_MAX outright:
@@ -11294,6 +11308,13 @@ var coveringFor = (bounds, maxCells) => {
11294
11308
  var DEFAULT_MAX_CELLS = 8;
11295
11309
  var DEFAULT_SEGMENT_GAP = 3e5;
11296
11310
  /**
11311
+ * How far apart two fixes inside a clipping box may be and still be read as
11312
+ * one stretch. A vessel cannot get far between fixes this close, so reading
11313
+ * what lies between them costs less than the two extra queries a separate
11314
+ * stretch takes. Any value gives the same clipped track.
11315
+ */
11316
+ var CLIP_STRETCH_GAP = 6e5;
11317
+ /**
11297
11318
  * A position store backed by SQLite, so tracks survive a restart.
11298
11319
  *
11299
11320
  * Uses node:sqlite rather than better-sqlite3: it is built into Node >= 22, so
@@ -11313,6 +11334,8 @@ var SqliteTrackStore = class {
11313
11334
  segmentGap;
11314
11335
  retention;
11315
11336
  resolution;
11337
+ /** See CLIP_STRETCH_GAP; widened so a coarse write resolution does not split every fix. */
11338
+ stretchGap;
11316
11339
  debug;
11317
11340
  /** Timestamp of the last position stored per context, for throttling. */
11318
11341
  lastStored = /* @__PURE__ */ new Map();
@@ -11327,6 +11350,7 @@ var SqliteTrackStore = class {
11327
11350
  this.segmentGap = config.segmentGap ?? DEFAULT_SEGMENT_GAP;
11328
11351
  this.retention = config.retention ?? 0;
11329
11352
  this.resolution = config.resolution ?? 0;
11353
+ this.stretchGap = Math.max(CLIP_STRETCH_GAP, 2 * this.resolution);
11330
11354
  this.debug = debug;
11331
11355
  this.db = new DatabaseSync(config.file);
11332
11356
  this.db.exec("PRAGMA journal_mode = WAL");
@@ -11437,31 +11461,93 @@ var SqliteTrackStore = class {
11437
11461
  * last-position test run once, here.
11438
11462
  */
11439
11463
  async getFilteredTimedTracks(params, selfPosition, debug, query) {
11440
- const candidates = params.bbox ? this.contextsInBounds(params.bbox) : void 0;
11464
+ const near = params.bbox && params.clip ? this.rowsNearBounds(params.bbox, query?.window) : void 0;
11465
+ const candidates = near ? new Set(near.keys()) : params.bbox ? this.contextsInBounds(params.bbox) : void 0;
11441
11466
  const matcher = createMatcher(params, selfPosition, debug);
11442
- return (await Promise.all(this.contexts().map((context) => this.getTimed(context, query?.window).then((points) => ({
11443
- context,
11444
- points: thin(points, query?.resolution)
11445
- }))))).reduce((acc, { context, points }) => {
11446
- if ((!candidates || candidates.has(context)) && matcher(points.map(({ position }) => position))) acc[context] = points;
11467
+ const requested = params.contexts ? new Set(params.contexts) : void 0;
11468
+ const contexts = this.contexts().filter((context) => (!candidates || candidates.has(context)) && (!requested || requested.has(context)));
11469
+ return (await Promise.all(contexts.map((context) => {
11470
+ const rows = near?.get(context);
11471
+ return (rows ? Promise.resolve(rows.map(toTimed)) : this.getTimed(context, query?.window)).then((points) => ({
11472
+ context,
11473
+ points: thin(points, query?.resolution)
11474
+ }));
11475
+ }))).reduce((acc, { context, points }) => {
11476
+ if (matcher(points.map(({ position }) => position))) acc[context] = points;
11447
11477
  return acc;
11448
11478
  }, {});
11449
11479
  }
11480
+ /** The cell index condition for rows near `bounds`, or none for an empty covering. */
11481
+ coveringClause(bounds) {
11482
+ const covering = coveringFor(bounds, this.maxCells);
11483
+ if (covering.length === 0) return;
11484
+ const ranges = covering.map(cellRange);
11485
+ return {
11486
+ where: ranges.map(() => "(s2cell BETWEEN ? AND ?)").join(" OR "),
11487
+ params: ranges.flatMap(({ start, end }) => [toSigned(start), toSigned(end)]),
11488
+ cells: covering.length
11489
+ };
11490
+ }
11450
11491
  /** Contexts with at least one position inside `bounds`, via the cell index. */
11451
11492
  contextsInBounds(bounds) {
11452
- const covering = coveringFor(bounds, this.maxCells);
11453
11493
  const found = /* @__PURE__ */ new Set();
11454
- if (covering.length === 0) return found;
11455
- const ranges = covering.map(cellRange);
11456
- const where = ranges.map(() => "(s2cell BETWEEN ? AND ?)").join(" OR ");
11457
- const params = ranges.flatMap(({ start, end }) => [toSigned(start), toSigned(end)]);
11494
+ const clause = this.coveringClause(bounds);
11495
+ if (!clause) return found;
11496
+ const { where, params, cells } = clause;
11458
11497
  const rows = this.db.prepare(`SELECT DISTINCT context, lat, lon FROM positions WHERE ${where}`).all(...params);
11459
11498
  const inBounds = createInBounds(bounds);
11460
11499
  for (const { context, lat, lon } of rows) if (inBounds([lat, lon])) found.add(context);
11461
- if (this.debug.enabled) this.debug(`bbox covering ${covering.length} cells -> ${rows.length} rows -> ${found.size} contexts`);
11500
+ if (this.debug.enabled) this.debug(`bbox covering ${cells} cells -> ${rows.length} rows -> ${found.size} contexts`);
11462
11501
  return found;
11463
11502
  }
11464
11503
  /**
11504
+ * Per context, the positions a track clipped to `bounds` is made of, and
11505
+ * only those: every position inside the box within the window, plus the one
11506
+ * either side of each stretch inside, which carries the line to the edge.
11507
+ *
11508
+ * The cell index finds the times a context was inside the box. Times closer
11509
+ * together than `stretchGap` are read as one stretch, including any
11510
+ * excursion outside it, since the clip that follows cuts those anyway;
11511
+ * further apart they are read separately and the time between is skipped.
11512
+ * Nothing between two stretches is inside the box, so skipping it loses
11513
+ * nothing the clip would keep: how they are grouped decides only how many
11514
+ * rows are read, never the result.
11515
+ */
11516
+ rowsNearBounds(bounds, window) {
11517
+ const near = /* @__PURE__ */ new Map();
11518
+ const clause = this.coveringClause(bounds);
11519
+ if (!clause) return near;
11520
+ const timeClauses = window ? ["timestamp >= ?", window.inclusiveEnd ? "timestamp <= ?" : "timestamp < ?"] : [];
11521
+ const timeParams = window ? [window.from, window.to] : [];
11522
+ const inWindow = timeClauses.map((c) => ` AND ${c}`).join("");
11523
+ const inside = this.db.prepare(`SELECT context, timestamp, lat, lon FROM positions WHERE (${clause.where})${inWindow} ORDER BY context, timestamp`).all(...clause.params, ...timeParams);
11524
+ const inBounds = createInBounds(bounds);
11525
+ const times = /* @__PURE__ */ new Map();
11526
+ for (const { context, timestamp, lat, lon } of inside) if (inBounds([lat, lon])) {
11527
+ const list = times.get(context);
11528
+ if (list) list.push(timestamp);
11529
+ else times.set(context, [timestamp]);
11530
+ }
11531
+ const columns = "SELECT rowid AS id, context, timestamp, lat, lon FROM positions WHERE context = ?";
11532
+ const between = this.db.prepare(`${columns} AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp, rowid`);
11533
+ const before = this.db.prepare(`${columns} AND timestamp < ?${inWindow} ORDER BY timestamp DESC, rowid DESC LIMIT 1`);
11534
+ const after = this.db.prepare(`${columns} AND timestamp > ?${inWindow} ORDER BY timestamp, rowid LIMIT 1`);
11535
+ for (const [context, list] of times) {
11536
+ const rows = /* @__PURE__ */ new Map();
11537
+ for (const [start, end] of stretchesOf(list, this.stretchGap)) {
11538
+ const read = [
11539
+ ...before.all(context, start, ...timeParams),
11540
+ ...between.all(context, start, end),
11541
+ ...after.all(context, end, ...timeParams)
11542
+ ];
11543
+ for (const row of read) rows.set(row.id, row);
11544
+ }
11545
+ near.set(context, [...rows.values()].sort((a, b) => a.timestamp - b.timestamp || a.id - b.id));
11546
+ }
11547
+ if (this.debug.enabled) this.debug(`clip covering ${clause.cells} cells -> ${inside.length} rows -> ${near.size} contexts`);
11548
+ return near;
11549
+ }
11550
+ /**
11465
11551
  * Split a context's positions into segments, breaking where the recording
11466
11552
  * stopped for longer than `segmentGap` — an overnight stop should not draw a
11467
11553
  * straight line across the anchorage.