@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 CHANGED
@@ -106,6 +106,13 @@ _`timespan` and `timespanOffset` are also accepted for Freeboard-SK compatibilit
106
106
  `timespan=23h&timespanOffset=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 === 4 && west !== void 0 && south !== void 0 && east !== void 0 && north !== void 0) bbox = {
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) radius = toFiniteNumber(rawRadius) ?? null;
428
- else if (defaultMaxRadius) radius = defaultMaxRadius;
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 Error("No self position to calculate radius values");
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, (path) => app.getPath?.(path)),
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, (path) => app.getPath?.(path));
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
  });