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

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/index.d.ts CHANGED
@@ -114,8 +114,6 @@ interface Plugin {
114
114
  }
115
115
  interface TracksPluginConfig {
116
116
  resolution?: number;
117
- /** Days of the own vessel's track to keep. 0 keeps everything. */
118
- retentionDays?: number;
119
117
  /** Days another vessel is kept after its last fix. 0 keeps every vessel. */
120
118
  aisRetentionDays?: number;
121
119
  /** Minutes without a fix that start a new track segment. 0 disables. */
package/dist/index.html CHANGED
@@ -4,6 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Tracks</title>
7
+ <link rel="icon" href="./app-icon.svg" type="image/svg+xml" />
7
8
  <link rel="stylesheet" href="./style.css" />
8
9
  </head>
9
10
  <body>
package/dist/index.js CHANGED
@@ -12483,26 +12483,108 @@ function reconcileByExtent(history, stored) {
12483
12483
  * Detection is therefore empirical rather than reading the priority config:
12484
12484
  * seeing several sources here *is* the evidence that no rule is matching,
12485
12485
  * which a config that merely exists would not tell us.
12486
+ *
12487
+ * With one qualification. A priority rule has a failover window — when the
12488
+ * top-ranked source goes quiet the engine promotes the next one — so over a
12489
+ * long run several sources legitimately reach this bus even with a rule in
12490
+ * place, one at a time. Counting every source ever seen would therefore
12491
+ * report a conflict on a correctly configured boat whose GPS drops out
12492
+ * occasionally. What distinguishes competition is *alternation*: unfiltered
12493
+ * sources hand back and forth repeatedly within seconds, while a failover
12494
+ * switches once and stays.
12495
+ */
12496
+ /**
12497
+ * How quickly the source must change again for it to count as alternation.
12498
+ *
12499
+ * Shorter than the server's 15s default failover, so a promotion — one
12500
+ * switch after a silence — never chains into a sequence. Positions are a
12501
+ * rapid update, often 10Hz, so genuinely competing sources alternate far
12502
+ * faster than this.
12503
+ */
12504
+ var ALTERNATION_WINDOW_MS = 1e4;
12505
+ /**
12506
+ * How long a conflict is remembered after it was last observed.
12507
+ *
12508
+ * Longer than the 30s status tick, so a conflict seen just after one tick is
12509
+ * still reported at the next and does not flicker. Finite, so fixing the
12510
+ * priorities clears the warning on its own rather than needing a restart —
12511
+ * which is the whole complaint about a watcher that only ever accumulates.
12512
+ */
12513
+ var CONFLICT_RETENTION_MS = 9e4;
12514
+ /**
12515
+ * How many source changes in a row before it counts as competition.
12516
+ *
12517
+ * One change is a failover, two is a failback — the preferred source
12518
+ * returning and winning immediately, which the server does on purpose. Three
12519
+ * means the sources are trading the path back and forth, which no priority
12520
+ * rule does.
12486
12521
  */
12522
+ var CHANGES_BEFORE_WARNING = 3;
12487
12523
  var SourceWatch = class {
12524
+ /** Last time each source produced a position, per context. */
12488
12525
  seen = /* @__PURE__ */ new Map();
12526
+ /** Sources found competing, per context, with when each was last seen so. */
12527
+ competing = /* @__PURE__ */ new Map();
12528
+ /** The current alternation per context: who is in it, and how long it is. */
12529
+ lastSeen = /* @__PURE__ */ new Map();
12489
12530
  /** Record that `context` produced a position from `$source`. */
12490
- add(context, source) {
12531
+ add(context, source, now = Date.now()) {
12491
12532
  if (!source) return;
12492
12533
  let sources = this.seen.get(context);
12493
12534
  if (!sources) {
12494
- sources = /* @__PURE__ */ new Set();
12535
+ sources = /* @__PURE__ */ new Map();
12495
12536
  this.seen.set(context, sources);
12496
12537
  }
12497
- sources.add(source);
12538
+ const previous = this.lastSeen.get(context);
12539
+ sources.set(source, now);
12540
+ const stale = !previous || now - previous.changedAt > ALTERNATION_WINDOW_MS;
12541
+ if (stale || previous.source === source) this.lastSeen.set(context, {
12542
+ source,
12543
+ at: now,
12544
+ changedAt: stale ? now : previous.changedAt,
12545
+ changes: stale ? 0 : previous.changes,
12546
+ participants: stale ? /* @__PURE__ */ new Set([source]) : previous.participants
12547
+ });
12548
+ else {
12549
+ const changes = previous.changes + 1;
12550
+ const participants = previous.participants.add(source);
12551
+ this.lastSeen.set(context, {
12552
+ source,
12553
+ at: now,
12554
+ changedAt: now,
12555
+ changes,
12556
+ participants
12557
+ });
12558
+ if (changes >= CHANGES_BEFORE_WARNING) {
12559
+ const found = this.competing.get(context) ?? /* @__PURE__ */ new Map();
12560
+ for (const participant of participants) found.set(participant, sources.get(participant) ?? now);
12561
+ this.competing.set(context, found);
12562
+ }
12563
+ }
12498
12564
  }
12499
- /** Sources seen for a context, in the order they first appeared. */
12565
+ /** Every source seen for a context, in the order they first appeared. */
12500
12566
  sourcesFor(context) {
12501
- return [...this.seen.get(context) ?? []];
12567
+ return [...(this.seen.get(context) ?? /* @__PURE__ */ new Map()).keys()];
12568
+ }
12569
+ /**
12570
+ * The sources found live at the same time, which is what a warning names.
12571
+ *
12572
+ * Expired entries are dropped rather than merely hidden, so a conflict that
12573
+ * has stopped recurring leaves nothing behind.
12574
+ */
12575
+ competingFor(context, now = Date.now()) {
12576
+ const found = this.competing.get(context);
12577
+ if (!found) return [];
12578
+ for (const [source, at] of found) if (now - at > CONFLICT_RETENTION_MS) found.delete(source);
12579
+ if (found.size < 2) {
12580
+ this.competing.delete(context);
12581
+ return [];
12582
+ }
12583
+ return [...found.keys()];
12502
12584
  }
12503
- /** Contexts receiving positions from more than one source. */
12504
- conflicted() {
12505
- return [...this.seen.entries()].filter(([, sources]) => sources.size > 1).map(([context]) => context);
12585
+ /** Contexts that have received positions from two sources at once. */
12586
+ conflicted(now = Date.now()) {
12587
+ return [...this.competing.keys()].filter((context) => this.competingFor(context, now).length > 1);
12506
12588
  }
12507
12589
  /**
12508
12590
  * A status line naming the problem, or undefined when nothing is wrong.
@@ -12512,21 +12594,24 @@ var SourceWatch = class {
12512
12594
  * vessels are usually AIS, where multiple receivers are expected and less
12513
12595
  * worth nagging about — they are counted, not named.
12514
12596
  */
12515
- warning(selfContext) {
12516
- const conflicted = this.conflicted();
12597
+ warning(selfContext, now = Date.now()) {
12598
+ const conflicted = this.conflicted(now);
12517
12599
  if (conflicted.length === 0) return;
12518
12600
  const selfConflicted = selfContext !== void 0 && conflicted.includes(selfContext);
12519
12601
  const others = conflicted.length - (selfConflicted ? 1 : 0);
12520
12602
  if (selfConflicted) {
12521
- const sources = this.sourcesFor(selfContext).join(", ");
12603
+ const recent = this.competingFor(selfContext, now);
12604
+ const sources = recent.join(", ");
12522
12605
  const tail = others > 0 ? ` (and ${others} other ${others === 1 ? "vessel" : "vessels"})` : "";
12523
- return `navigation.position for the own vessel is arriving from ${this.sourcesFor(selfContext).length} sources (${sources})${tail}. Set source priority for navigation.position, or the track will zigzag between them.`;
12606
+ return `navigation.position for the own vessel is arriving from ${recent.length} sources (${sources})${tail}. Set source priority for navigation.position, or the track will zigzag between them.`;
12524
12607
  }
12525
12608
  return `navigation.position is arriving from multiple sources for ${others} ${others === 1 ? "vessel" : "vessels"}.`;
12526
12609
  }
12527
12610
  /** Forget everything seen; used when the plugin restarts. */
12528
12611
  clear() {
12529
12612
  this.seen.clear();
12613
+ this.competing.clear();
12614
+ this.lastSeen.clear();
12530
12615
  }
12531
12616
  };
12532
12617
  //#endregion
@@ -13325,7 +13410,7 @@ function ThePlugin(app) {
13325
13410
  tracks = new SqliteTrackStore({
13326
13411
  file: join(dataDir, "tracks.db"),
13327
13412
  resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,
13328
- retention: (toNumber(config.retentionDays) ?? 0) * 24 * 60 * 60 * 1e3,
13413
+ retention: 0,
13329
13414
  segmentGap
13330
13415
  }, app.debug);
13331
13416
  } catch (err) {
@@ -13579,13 +13664,6 @@ function ThePlugin(app) {
13579
13664
  title: "Track resolution (milliseconds)",
13580
13665
  default: DEFAULT_RESOLUTION
13581
13666
  },
13582
- retentionDays: {
13583
- type: "integer",
13584
- minimum: 0,
13585
- title: "Days of the own vessel's track to keep",
13586
- description: "Positions older than this are deleted. 0, the default, keeps everything — a track is worth more the longer it goes back, and a year of one vessel at the default resolution is a few megabytes.",
13587
- default: 0
13588
- },
13589
13667
  aisRetentionDays: {
13590
13668
  type: "integer",
13591
13669
  minimum: 0,