@signalk/tracks-plugin 3.0.3 → 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.
- package/dist/harness.test-utils.d.ts +5 -0
- package/dist/index.js +59 -11
- package/dist/index.js.map +1 -1
- package/dist/sqliteStore.d.ts +18 -0
- package/dist/timeWindow.d.ts +5 -1
- package/dist/trackApi.d.ts +6 -0
- package/dist/trackProvider.d.ts +9 -0
- package/dist/trackStoreWorker.js +99 -13
- package/dist/trackStoreWorker.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/{utils-DOEyuPRy.js → utils-CGIGp4Xf.js} +62 -5
- package/dist/utils-CGIGp4Xf.js.map +1 -0
- package/dist/utils.d.ts +13 -0
- package/package.json +1 -1
- package/dist/utils-DOEyuPRy.js.map +0 -1
package/dist/sqliteStore.d.ts
CHANGED
|
@@ -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
|
package/dist/timeWindow.d.ts
CHANGED
|
@@ -85,7 +85,11 @@ export declare function thinToBudget(points: TimedPosition[], maxPoints: number,
|
|
|
85
85
|
* thinned track, or one reconciled with history buckets, segment exactly as
|
|
86
86
|
* the recording would.
|
|
87
87
|
*
|
|
88
|
-
*
|
|
88
|
+
* A point marked `breakBefore` starts a new segment whatever the gap: a
|
|
89
|
+
* clipped track leaves the box there.
|
|
90
|
+
*
|
|
91
|
+
* `gap` of 0 or undefined otherwise returns a single segment, preserving the
|
|
92
|
+
* old shape.
|
|
89
93
|
* An empty input returns no segments rather than one empty one, so a caller can
|
|
90
94
|
* distinguish "no track" from "a track with no points".
|
|
91
95
|
*/
|
package/dist/trackApi.d.ts
CHANGED
|
@@ -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;
|
package/dist/trackProvider.d.ts
CHANGED
|
@@ -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;
|
package/dist/trackStoreWorker.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
11443
|
-
|
|
11444
|
-
|
|
11445
|
-
|
|
11446
|
-
|
|
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
|
-
|
|
11455
|
-
|
|
11456
|
-
const where
|
|
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 ${
|
|
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.
|