@signalk/tracks-plugin 3.0.0-beta.5 → 3.0.0-beta.6

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/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
@@ -12135,6 +12135,8 @@ var DEFAULT_SEGMENT_GAP = 3e5;
12135
12135
  var SqliteTrackStore = class {
12136
12136
  db;
12137
12137
  insert;
12138
+ insertName;
12139
+ selectName;
12138
12140
  maxCells;
12139
12141
  segmentGap;
12140
12142
  retention;
@@ -12160,8 +12162,20 @@ var SqliteTrackStore = class {
12160
12162
  );
12161
12163
  CREATE INDEX IF NOT EXISTS idx_s2cell ON positions(s2cell);
12162
12164
  CREATE INDEX IF NOT EXISTS idx_context_timestamp ON positions(context, timestamp);
12165
+ -- A separate table rather than a column on positions: this store has no
12166
+ -- migration mechanism, so a new table appears harmlessly on a database
12167
+ -- written by an older version, where an ALTER TABLE would not.
12168
+ CREATE TABLE IF NOT EXISTS names (
12169
+ context TEXT PRIMARY KEY,
12170
+ name TEXT NOT NULL,
12171
+ timestamp INTEGER NOT NULL
12172
+ );
12163
12173
  `);
12164
12174
  this.insert = this.db.prepare("INSERT INTO positions (context, timestamp, lat, lon, s2cell) VALUES (?, ?, ?, ?, ?)");
12175
+ this.insertName = this.db.prepare(`INSERT INTO names (context, name, timestamp) VALUES (?, ?, ?)
12176
+ ON CONFLICT(context) DO UPDATE SET name = excluded.name, timestamp = excluded.timestamp
12177
+ WHERE excluded.timestamp >= names.timestamp`);
12178
+ this.selectName = this.db.prepare("SELECT name FROM names WHERE context = ?");
12165
12179
  }
12166
12180
  newPosition(context, position, timestamp = Date.now()) {
12167
12181
  if (this.resolution > 0) {
@@ -12288,10 +12302,19 @@ var SqliteTrackStore = class {
12288
12302
  if (current.length > 0) result.push(current);
12289
12303
  return result;
12290
12304
  }
12305
+ recordName(context, name, timestamp = Date.now()) {
12306
+ const trimmed = name.trim();
12307
+ if (trimmed === "") return;
12308
+ this.insertName.run(context, trimmed, timestamp);
12309
+ }
12310
+ nameFor(context) {
12311
+ return this.selectName.get(context)?.name;
12312
+ }
12291
12313
  prune(maxAge, keep) {
12292
12314
  const cutoff = Date.now() - maxAge;
12293
12315
  const dropped = this.db.prepare("SELECT context FROM positions GROUP BY context HAVING MAX(timestamp) < ? AND context IS NOT ?").all(cutoff, keep ?? null);
12294
12316
  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);
12317
+ for (const { context } of dropped) this.db.prepare("DELETE FROM names WHERE context = ?").run(context);
12295
12318
  for (const { context } of dropped) this.lastStored.delete(context);
12296
12319
  if (this.retention > 0) {
12297
12320
  const oldest = Date.now() - this.retention;
@@ -13043,12 +13066,34 @@ var Tracks = class {
13043
13066
  return acc;
13044
13067
  }, {});
13045
13068
  }
13069
+ /**
13070
+ * Remembered names, by context.
13071
+ *
13072
+ * In memory only: this store is the client-side accumulator, so there is no
13073
+ * restart for a stored name to survive. It implements the same contract so
13074
+ * the two stores stay interchangeable and the plugin needs no special case.
13075
+ */
13076
+ names = /* @__PURE__ */ new Map();
13077
+ recordName(context, name, timestamp = Date.now()) {
13078
+ const trimmed = name.trim();
13079
+ if (trimmed === "") return;
13080
+ const seen = this.names.get(context);
13081
+ if (seen && seen.timestamp > timestamp) return;
13082
+ this.names.set(context, {
13083
+ name: trimmed,
13084
+ timestamp
13085
+ });
13086
+ }
13087
+ nameFor(context) {
13088
+ return this.names.get(context)?.name;
13089
+ }
13046
13090
  prune(maxAge, keep) {
13047
13091
  const cutoff = Date.now() - maxAge;
13048
13092
  const deleted = [];
13049
13093
  Object.entries(this.tracks).forEach(([key, value]) => {
13050
13094
  if (key !== keep && value.latestLatLngTuple < cutoff) {
13051
13095
  delete this.tracks[key];
13096
+ this.names.delete(key);
13052
13097
  deleted.push(key);
13053
13098
  }
13054
13099
  });
@@ -13357,6 +13402,21 @@ var historyRowTimestamp = (row) => {
13357
13402
  return Number.isNaN(parsed) ? 0 : parsed;
13358
13403
  };
13359
13404
  function ThePlugin(app) {
13405
+ /**
13406
+ * The server's own lookup, falling back to what the store remembers.
13407
+ *
13408
+ * Live wins whenever the data model has an answer, which keeps the rule the
13409
+ * resolvers were written for: an AIS static report arriving late must be
13410
+ * able to correct or replace what was known. The stored value only covers
13411
+ * the window where the model knows nothing -- after a restart, or for a
13412
+ * vessel that has since sailed out of range.
13413
+ */
13414
+ const remembering = (store) => (path) => {
13415
+ const live = app.getPath?.(path);
13416
+ if (live !== void 0) return live;
13417
+ const match = /^(.*)\.name$/.exec(path);
13418
+ return match ? store()?.nameFor(match[1]) : void 0;
13419
+ };
13360
13420
  let onStop = [];
13361
13421
  let tracks = void 0;
13362
13422
  let segmentGap = 0;
@@ -13420,6 +13480,8 @@ function ThePlugin(app) {
13420
13480
  onStop.push(app.streambundle.getBus("navigation.position").onValue((update) => {
13421
13481
  if (!update.value || update.value.latitude == null || update.value.longitude == null) return;
13422
13482
  sourceWatch.add(update.context, update.$source);
13483
+ const live = unwrapString(app.getPath?.(`${update.context}.name`));
13484
+ if (live !== void 0) tracks?.recordName(update.context, live);
13423
13485
  const recorded = update.timestamp === void 0 ? void 0 : Date.parse(update.timestamp);
13424
13486
  const timestamp = recorded !== void 0 && !Number.isNaN(recorded) ? recorded : void 0;
13425
13487
  const position = [update.value.latitude, update.value.longitude];
@@ -13449,7 +13511,7 @@ function ThePlugin(app) {
13449
13511
  store: () => tracks,
13450
13512
  selfContext: () => app.selfContext,
13451
13513
  segmentGap: () => segmentGap,
13452
- contextName: (context) => contextName(context, (path) => app.getPath?.(path)),
13514
+ contextName: (context) => contextName(context, remembering(() => tracks)),
13453
13515
  reconcileWithHistory: async (context, stored, window, resolutionMs) => {
13454
13516
  const effective = resolutionMs ?? storeResolution;
13455
13517
  const history = await historyPositions(app, context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective, app.debug);
@@ -13479,7 +13541,7 @@ function ThePlugin(app) {
13479
13541
  }
13480
13542
  },
13481
13543
  signalKApiRoutes: function(router) {
13482
- const nameOf = (context) => trackLabel(context, app.selfContext, (path) => app.getPath?.(path));
13544
+ const nameOf = (context) => trackLabel(context, app.selfContext, remembering(() => tracks));
13483
13545
  /**
13484
13546
  * The points of one context, reconciled with history and segmented.
13485
13547
  *