@signalk/tracks-plugin 3.0.0-beta.5 → 3.0.0-beta.7
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 +7 -0
- package/app-icon.svg +42 -0
- package/dist/index.js +123 -11
- package/dist/index.js.map +1 -1
- package/dist/sqliteStore.d.ts +4 -0
- package/dist/store.d.ts +15 -0
- package/dist/timeWindow.d.ts +1 -1
- package/dist/tracks.d.ts +10 -0
- package/dist/utils.d.ts +21 -0
- package/docs/history-and-storage.md +94 -0
- package/docs/screenshots/tracks_1.png +0 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -106,6 +106,13 @@ _`timespan` and `timespanOffset` are also accepted for Freeboard-SK compatibilit
|
|
|
106
106
|
`timespan=23h×panOffset=1` means "23 hours ending an hour ago". They are not part of
|
|
107
107
|
the proposed track API and are expected to be superseded by `from`/`to`._
|
|
108
108
|
|
|
109
|
+
_A parameter these routes do not read is rejected with `400` rather than ignored, so a
|
|
110
|
+
typo such as `?duratoin=6h` fails rather than quietly returning the whole retained track.
|
|
111
|
+
`bbox` and `radius` are **not** accepted here: they select which vessels to return and so
|
|
112
|
+
belong to `/tracks` below. To ask whether one vessel's track passed through a box, use the
|
|
113
|
+
v2 Track API, which matches any position in the window ([being designed in
|
|
114
|
+
SignalK/signalk-server#2504](https://github.com/SignalK/signalk-server/issues/2504))._
|
|
115
|
+
|
|
109
116
|
---
|
|
110
117
|
|
|
111
118
|
**Reduce the number of points returned:**
|
package/app-icon.svg
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Tracks">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#1b6ea8"/>
|
|
5
|
+
<stop offset="1" stop-color="#0b3a5e"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<clipPath id="card">
|
|
8
|
+
<rect x="0" y="0" width="128" height="128" rx="22"/>
|
|
9
|
+
</clipPath>
|
|
10
|
+
</defs>
|
|
11
|
+
|
|
12
|
+
<g clip-path="url(#card)">
|
|
13
|
+
<rect width="128" height="128" fill="url(#sea)"/>
|
|
14
|
+
|
|
15
|
+
<!-- Chart grid, as on the sibling plugins' icons. -->
|
|
16
|
+
<g stroke="#bee3f4" stroke-opacity="0.28" stroke-width="1" fill="none">
|
|
17
|
+
<path d="M0 32 H128 M0 64 H128 M0 96 H128"/>
|
|
18
|
+
<path d="M32 0 V128 M64 0 V128 M96 0 V128"/>
|
|
19
|
+
</g>
|
|
20
|
+
|
|
21
|
+
<!-- The track itself: the recorded path, drawn through its own points. -->
|
|
22
|
+
<path d="M24 102 C 40 94, 42 72, 58 64 S 84 58, 94 44"
|
|
23
|
+
fill="none" stroke="#0b3a5e" stroke-opacity="0.45"
|
|
24
|
+
stroke-width="9" stroke-linecap="round"/>
|
|
25
|
+
<path d="M24 102 C 40 94, 42 72, 58 64 S 84 58, 94 44"
|
|
26
|
+
fill="none" stroke="#ffd76a" stroke-width="5"
|
|
27
|
+
stroke-linecap="round" stroke-linejoin="round"/>
|
|
28
|
+
|
|
29
|
+
<!-- Position fixes. The track is a series of recorded points, not a
|
|
30
|
+
drawn line, and the icon should read that way at 32px too. -->
|
|
31
|
+
<g fill="#fffaf0" stroke="#0b3a5e" stroke-opacity="0.5" stroke-width="1.5">
|
|
32
|
+
<circle cx="24" cy="102" r="4.5"/>
|
|
33
|
+
<circle cx="49" cy="74" r="4"/>
|
|
34
|
+
<circle cx="72" cy="61" r="4"/>
|
|
35
|
+
</g>
|
|
36
|
+
|
|
37
|
+
<!-- The vessel, at the newest fix, heading along the track. -->
|
|
38
|
+
<path d="M94 34 L102 52 L94 47 L86 52 Z" fill="#fffaf0"
|
|
39
|
+
stroke="#0b3a5e" stroke-opacity="0.5" stroke-width="1.5"
|
|
40
|
+
stroke-linejoin="round"/>
|
|
41
|
+
</g>
|
|
42
|
+
</svg>
|
package/dist/index.js
CHANGED
|
@@ -135,6 +135,20 @@ function nearestLongitude(lng, origin) {
|
|
|
135
135
|
* `resolution` is shared by both and expresses the minimum spacing between
|
|
136
136
|
* returned points, thinning the track for wide time ranges.
|
|
137
137
|
*/
|
|
138
|
+
/**
|
|
139
|
+
* The query keys these routes read. Kept beside the schema so the two cannot
|
|
140
|
+
* drift: a key added to one without the other either fails validation or is
|
|
141
|
+
* rejected as unknown.
|
|
142
|
+
*/
|
|
143
|
+
var QUERY_KEYS = [
|
|
144
|
+
"from",
|
|
145
|
+
"to",
|
|
146
|
+
"duration",
|
|
147
|
+
"timespan",
|
|
148
|
+
"timespanOffset",
|
|
149
|
+
"resolution",
|
|
150
|
+
"times"
|
|
151
|
+
];
|
|
138
152
|
var QuerySchema = Type.Object({
|
|
139
153
|
from: Type.Optional(Type.String()),
|
|
140
154
|
to: Type.Optional(Type.String()),
|
|
@@ -200,7 +214,12 @@ function firstString(value) {
|
|
|
200
214
|
* `timespan`/`timespanOffset` pair. Absent all of them, no window is applied
|
|
201
215
|
* and the whole retained track is returned, as before.
|
|
202
216
|
*/
|
|
203
|
-
function parseTrackQuery(query, now = Date.now()) {
|
|
217
|
+
function parseTrackQuery(query, now = Date.now(), alsoAllowed = []) {
|
|
218
|
+
const unknown = Object.keys(query).filter((key) => !QUERY_KEYS.includes(key) && !alsoAllowed.includes(key));
|
|
219
|
+
if (unknown.length > 0) {
|
|
220
|
+
const allowed = [...QUERY_KEYS, ...alsoAllowed].join(", ");
|
|
221
|
+
throw new TimeWindowError(`Unknown query parameter${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")}; accepted: ${allowed}`);
|
|
222
|
+
}
|
|
204
223
|
const raw = {
|
|
205
224
|
from: firstString(query.from),
|
|
206
225
|
to: firstString(query.to),
|
|
@@ -411,21 +430,44 @@ function toFiniteNumber(value) {
|
|
|
411
430
|
const parsed = Number(trimmed);
|
|
412
431
|
return Number.isFinite(parsed) ? parsed : void 0;
|
|
413
432
|
}
|
|
433
|
+
/**
|
|
434
|
+
* A query parameter was present but malformed.
|
|
435
|
+
*
|
|
436
|
+
* Distinct from absent: a filter nobody asked for is not an error, but one
|
|
437
|
+
* that cannot be honoured must not quietly widen the query. Carries its own
|
|
438
|
+
* message so the handler can hand it to the client, the way `TimeWindowError`
|
|
439
|
+
* already does for the time window.
|
|
440
|
+
*/
|
|
441
|
+
var QueryParameterError = class extends Error {};
|
|
442
|
+
/**
|
|
443
|
+
* A well-formed query the server cannot currently answer.
|
|
444
|
+
*
|
|
445
|
+
* `radius` measures from the own vessel, so it needs a self position. Not
|
|
446
|
+
* having one is an outage, not a client mistake -- the same distinction the
|
|
447
|
+
* single-vessel route already draws for a history provider it cannot read.
|
|
448
|
+
*/
|
|
449
|
+
var SelfPositionUnavailableError = class extends Error {};
|
|
414
450
|
function validateParameters(params, defaultMaxRadius) {
|
|
415
451
|
let bbox = null;
|
|
416
452
|
const rawBbox = firstValue(params.bbox);
|
|
417
453
|
if (rawBbox !== void 0) {
|
|
418
454
|
const b = rawBbox.split(",").map(toFiniteNumber);
|
|
419
455
|
const [west, south, east, north] = b;
|
|
420
|
-
if (b.length
|
|
456
|
+
if (b.length !== 4 || west === void 0 || south === void 0 || east === void 0 || north === void 0) throw new QueryParameterError("bbox must be four comma-separated numbers: west,south,east,north");
|
|
457
|
+
if (south > north) throw new QueryParameterError(`bbox south (${south}) must not be greater than north (${north})`);
|
|
458
|
+
bbox = {
|
|
421
459
|
sw: [south, west],
|
|
422
460
|
ne: [north, east]
|
|
423
461
|
};
|
|
424
462
|
}
|
|
425
463
|
let radius = null;
|
|
426
464
|
const rawRadius = firstValue(params.radius);
|
|
427
|
-
if (rawRadius !== void 0)
|
|
428
|
-
|
|
465
|
+
if (rawRadius !== void 0) {
|
|
466
|
+
const parsed = toFiniteNumber(rawRadius);
|
|
467
|
+
if (parsed === void 0) throw new QueryParameterError(`radius must be a number in meters, got '${rawRadius}'`);
|
|
468
|
+
if (parsed < 0) throw new QueryParameterError(`radius must not be negative, got ${parsed}`);
|
|
469
|
+
radius = parsed;
|
|
470
|
+
} else if (defaultMaxRadius) radius = defaultMaxRadius;
|
|
429
471
|
return {
|
|
430
472
|
bbox,
|
|
431
473
|
radius
|
|
@@ -463,7 +505,7 @@ function createMatcher(params, selfPosition, debug) {
|
|
|
463
505
|
const inBounds = createInBounds(params.bbox);
|
|
464
506
|
return params.intersects ? (track) => track.some(inBounds) : (track) => inBounds(lastPoint(track));
|
|
465
507
|
} else if (params.radius !== null) {
|
|
466
|
-
if (!selfPosition) throw new
|
|
508
|
+
if (!selfPosition) throw new SelfPositionUnavailableError("No self position to calculate radius values");
|
|
467
509
|
const radius = params.radius;
|
|
468
510
|
const distanceFromSelf = createDistanceTo(selfPosition, debug);
|
|
469
511
|
return (track) => distanceFromSelf(lastPoint(track)) < radius;
|
|
@@ -12135,6 +12177,8 @@ var DEFAULT_SEGMENT_GAP = 3e5;
|
|
|
12135
12177
|
var SqliteTrackStore = class {
|
|
12136
12178
|
db;
|
|
12137
12179
|
insert;
|
|
12180
|
+
insertName;
|
|
12181
|
+
selectName;
|
|
12138
12182
|
maxCells;
|
|
12139
12183
|
segmentGap;
|
|
12140
12184
|
retention;
|
|
@@ -12160,8 +12204,20 @@ var SqliteTrackStore = class {
|
|
|
12160
12204
|
);
|
|
12161
12205
|
CREATE INDEX IF NOT EXISTS idx_s2cell ON positions(s2cell);
|
|
12162
12206
|
CREATE INDEX IF NOT EXISTS idx_context_timestamp ON positions(context, timestamp);
|
|
12207
|
+
-- A separate table rather than a column on positions: this store has no
|
|
12208
|
+
-- migration mechanism, so a new table appears harmlessly on a database
|
|
12209
|
+
-- written by an older version, where an ALTER TABLE would not.
|
|
12210
|
+
CREATE TABLE IF NOT EXISTS names (
|
|
12211
|
+
context TEXT PRIMARY KEY,
|
|
12212
|
+
name TEXT NOT NULL,
|
|
12213
|
+
timestamp INTEGER NOT NULL
|
|
12214
|
+
);
|
|
12163
12215
|
`);
|
|
12164
12216
|
this.insert = this.db.prepare("INSERT INTO positions (context, timestamp, lat, lon, s2cell) VALUES (?, ?, ?, ?, ?)");
|
|
12217
|
+
this.insertName = this.db.prepare(`INSERT INTO names (context, name, timestamp) VALUES (?, ?, ?)
|
|
12218
|
+
ON CONFLICT(context) DO UPDATE SET name = excluded.name, timestamp = excluded.timestamp
|
|
12219
|
+
WHERE excluded.timestamp >= names.timestamp`);
|
|
12220
|
+
this.selectName = this.db.prepare("SELECT name FROM names WHERE context = ?");
|
|
12165
12221
|
}
|
|
12166
12222
|
newPosition(context, position, timestamp = Date.now()) {
|
|
12167
12223
|
if (this.resolution > 0) {
|
|
@@ -12288,10 +12344,19 @@ var SqliteTrackStore = class {
|
|
|
12288
12344
|
if (current.length > 0) result.push(current);
|
|
12289
12345
|
return result;
|
|
12290
12346
|
}
|
|
12347
|
+
recordName(context, name, timestamp = Date.now()) {
|
|
12348
|
+
const trimmed = name.trim();
|
|
12349
|
+
if (trimmed === "") return;
|
|
12350
|
+
this.insertName.run(context, trimmed, timestamp);
|
|
12351
|
+
}
|
|
12352
|
+
nameFor(context) {
|
|
12353
|
+
return this.selectName.get(context)?.name;
|
|
12354
|
+
}
|
|
12291
12355
|
prune(maxAge, keep) {
|
|
12292
12356
|
const cutoff = Date.now() - maxAge;
|
|
12293
12357
|
const dropped = this.db.prepare("SELECT context FROM positions GROUP BY context HAVING MAX(timestamp) < ? AND context IS NOT ?").all(cutoff, keep ?? null);
|
|
12294
12358
|
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);
|
|
12359
|
+
for (const { context } of dropped) this.db.prepare("DELETE FROM names WHERE context = ?").run(context);
|
|
12295
12360
|
for (const { context } of dropped) this.lastStored.delete(context);
|
|
12296
12361
|
if (this.retention > 0) {
|
|
12297
12362
|
const oldest = Date.now() - this.retention;
|
|
@@ -13043,12 +13108,34 @@ var Tracks = class {
|
|
|
13043
13108
|
return acc;
|
|
13044
13109
|
}, {});
|
|
13045
13110
|
}
|
|
13111
|
+
/**
|
|
13112
|
+
* Remembered names, by context.
|
|
13113
|
+
*
|
|
13114
|
+
* In memory only: this store is the client-side accumulator, so there is no
|
|
13115
|
+
* restart for a stored name to survive. It implements the same contract so
|
|
13116
|
+
* the two stores stay interchangeable and the plugin needs no special case.
|
|
13117
|
+
*/
|
|
13118
|
+
names = /* @__PURE__ */ new Map();
|
|
13119
|
+
recordName(context, name, timestamp = Date.now()) {
|
|
13120
|
+
const trimmed = name.trim();
|
|
13121
|
+
if (trimmed === "") return;
|
|
13122
|
+
const seen = this.names.get(context);
|
|
13123
|
+
if (seen && seen.timestamp > timestamp) return;
|
|
13124
|
+
this.names.set(context, {
|
|
13125
|
+
name: trimmed,
|
|
13126
|
+
timestamp
|
|
13127
|
+
});
|
|
13128
|
+
}
|
|
13129
|
+
nameFor(context) {
|
|
13130
|
+
return this.names.get(context)?.name;
|
|
13131
|
+
}
|
|
13046
13132
|
prune(maxAge, keep) {
|
|
13047
13133
|
const cutoff = Date.now() - maxAge;
|
|
13048
13134
|
const deleted = [];
|
|
13049
13135
|
Object.entries(this.tracks).forEach(([key, value]) => {
|
|
13050
13136
|
if (key !== keep && value.latestLatLngTuple < cutoff) {
|
|
13051
13137
|
delete this.tracks[key];
|
|
13138
|
+
this.names.delete(key);
|
|
13052
13139
|
deleted.push(key);
|
|
13053
13140
|
}
|
|
13054
13141
|
});
|
|
@@ -13357,6 +13444,21 @@ var historyRowTimestamp = (row) => {
|
|
|
13357
13444
|
return Number.isNaN(parsed) ? 0 : parsed;
|
|
13358
13445
|
};
|
|
13359
13446
|
function ThePlugin(app) {
|
|
13447
|
+
/**
|
|
13448
|
+
* The server's own lookup, falling back to what the store remembers.
|
|
13449
|
+
*
|
|
13450
|
+
* Live wins whenever the data model has an answer, which keeps the rule the
|
|
13451
|
+
* resolvers were written for: an AIS static report arriving late must be
|
|
13452
|
+
* able to correct or replace what was known. The stored value only covers
|
|
13453
|
+
* the window where the model knows nothing -- after a restart, or for a
|
|
13454
|
+
* vessel that has since sailed out of range.
|
|
13455
|
+
*/
|
|
13456
|
+
const remembering = (store) => (path) => {
|
|
13457
|
+
const live = app.getPath?.(path);
|
|
13458
|
+
if (live !== void 0) return live;
|
|
13459
|
+
const match = /^(.*)\.name$/.exec(path);
|
|
13460
|
+
return match ? store()?.nameFor(match[1]) : void 0;
|
|
13461
|
+
};
|
|
13360
13462
|
let onStop = [];
|
|
13361
13463
|
let tracks = void 0;
|
|
13362
13464
|
let segmentGap = 0;
|
|
@@ -13420,6 +13522,8 @@ function ThePlugin(app) {
|
|
|
13420
13522
|
onStop.push(app.streambundle.getBus("navigation.position").onValue((update) => {
|
|
13421
13523
|
if (!update.value || update.value.latitude == null || update.value.longitude == null) return;
|
|
13422
13524
|
sourceWatch.add(update.context, update.$source);
|
|
13525
|
+
const live = unwrapString(app.getPath?.(`${update.context}.name`));
|
|
13526
|
+
if (live !== void 0) tracks?.recordName(update.context, live);
|
|
13423
13527
|
const recorded = update.timestamp === void 0 ? void 0 : Date.parse(update.timestamp);
|
|
13424
13528
|
const timestamp = recorded !== void 0 && !Number.isNaN(recorded) ? recorded : void 0;
|
|
13425
13529
|
const position = [update.value.latitude, update.value.longitude];
|
|
@@ -13449,7 +13553,7 @@ function ThePlugin(app) {
|
|
|
13449
13553
|
store: () => tracks,
|
|
13450
13554
|
selfContext: () => app.selfContext,
|
|
13451
13555
|
segmentGap: () => segmentGap,
|
|
13452
|
-
contextName: (context) => contextName(context, (
|
|
13556
|
+
contextName: (context) => contextName(context, remembering(() => tracks)),
|
|
13453
13557
|
reconcileWithHistory: async (context, stored, window, resolutionMs) => {
|
|
13454
13558
|
const effective = resolutionMs ?? storeResolution;
|
|
13455
13559
|
const history = await historyPositions(app, context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective, app.debug);
|
|
@@ -13479,7 +13583,7 @@ function ThePlugin(app) {
|
|
|
13479
13583
|
}
|
|
13480
13584
|
},
|
|
13481
13585
|
signalKApiRoutes: function(router) {
|
|
13482
|
-
const nameOf = (context) => trackLabel(context, app.selfContext, (
|
|
13586
|
+
const nameOf = (context) => trackLabel(context, app.selfContext, remembering(() => tracks));
|
|
13483
13587
|
/**
|
|
13484
13588
|
* The points of one context, reconciled with history and segmented.
|
|
13485
13589
|
*
|
|
@@ -13605,6 +13709,8 @@ function ThePlugin(app) {
|
|
|
13605
13709
|
router.get("/vessels/:vesselId/track.gpx", gpxHandler((req) => resolveContext(String(req.params.vesselId), app.selfContext)));
|
|
13606
13710
|
router.get("/self/track.gpx", gpxHandler(() => app.selfContext));
|
|
13607
13711
|
router.get("/self/track", singleTrackHandler(() => app.selfContext));
|
|
13712
|
+
/** Only the collection route filters spatially; see AGENTS.md on v1 vs v2. */
|
|
13713
|
+
const SPATIAL_PARAMS = ["bbox", "radius"];
|
|
13608
13714
|
const allTracksHandler = (req, res) => {
|
|
13609
13715
|
app.debug(req.query);
|
|
13610
13716
|
if (!tracks) {
|
|
@@ -13612,14 +13718,15 @@ function ThePlugin(app) {
|
|
|
13612
13718
|
return;
|
|
13613
13719
|
}
|
|
13614
13720
|
let query;
|
|
13721
|
+
let params;
|
|
13615
13722
|
try {
|
|
13616
|
-
query = parseTrackQuery(req.query);
|
|
13723
|
+
query = parseTrackQuery(req.query, Date.now(), SPATIAL_PARAMS);
|
|
13724
|
+
params = validateParameters(req.query, void 0);
|
|
13617
13725
|
} catch (err) {
|
|
13618
13726
|
res.status(400);
|
|
13619
|
-
res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
|
|
13727
|
+
res.json({ message: err instanceof TimeWindowError || err instanceof QueryParameterError ? err.message : "Invalid query parameters" });
|
|
13620
13728
|
return;
|
|
13621
13729
|
}
|
|
13622
|
-
const params = validateParameters(req.query, void 0);
|
|
13623
13730
|
const selfPosition = getVesselPosition();
|
|
13624
13731
|
(query.times ? tracks.getFilteredTimedTracks(params, selfPosition, app.debug, query).then((tc) => Object.entries(tc).reduce((acc, [context, points]) => {
|
|
13625
13732
|
const segments = segment(points, segmentGap);
|
|
@@ -13641,7 +13748,12 @@ function ThePlugin(app) {
|
|
|
13641
13748
|
return acc;
|
|
13642
13749
|
}, {}))).then((trks) => {
|
|
13643
13750
|
res.json(trks);
|
|
13644
|
-
}).catch(() => {
|
|
13751
|
+
}).catch((err) => {
|
|
13752
|
+
if (err instanceof SelfPositionUnavailableError) {
|
|
13753
|
+
res.status(503);
|
|
13754
|
+
res.json({ message: "No position for the own vessel, so radius cannot be measured" });
|
|
13755
|
+
return;
|
|
13756
|
+
}
|
|
13645
13757
|
res.status(404);
|
|
13646
13758
|
res.json({ message: `No track available for vessels.` });
|
|
13647
13759
|
});
|