@signalk/tracks-plugin 3.1.0 → 3.1.1

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.
@@ -117,6 +117,8 @@ export interface HarnessOptions {
117
117
  * only the earliest of `rows` in each bucket of the asked resolution.
118
118
  */
119
119
  aggregatesFirst?: boolean;
120
+ /** Called with every `getValues` query, so a test can see what was asked. */
121
+ onValues?: (query: HistoryValuesQuery) => void;
120
122
  /**
121
123
  * Reject `getHistoryApi` itself, as the server does when no provider is
122
124
  * registered — the default install, and not an outage.
@@ -136,4 +138,13 @@ export declare function deferred(): {
136
138
  release: () => void;
137
139
  wait: Promise<void>;
138
140
  };
141
+ export interface HistoryValuesQuery {
142
+ from: {
143
+ toString: () => string;
144
+ };
145
+ to: {
146
+ toString: () => string;
147
+ };
148
+ resolution?: number;
149
+ }
139
150
  export declare function createHarness(options?: HarnessOptions): TestHarness;
package/dist/index.js CHANGED
@@ -810,6 +810,25 @@ function reconcile(history, stored, resolution) {
810
810
  };
811
811
  }
812
812
  /**
813
+ * Merge history that is coarser than the store into it, the store first.
814
+ *
815
+ * The rule above holds while history is the finer source. A history read
816
+ * widened to stay within its bucket budget is not: one of its buckets spans
817
+ * several stored fixes, and letting it win would replace them all with one
818
+ * point. So here the store keeps every bucket it has a point in, and history
819
+ * fills only the buckets the store has nothing for — where the provider
820
+ * recorded before this plugin was installed, or while it was off.
821
+ */
822
+ function fillFromHistory(history, stored, resolution) {
823
+ const covered = new Set(stored.map(({ timestamp }) => bucketOf(timestamp, resolution)));
824
+ const filling = history.filter(({ timestamp }) => !covered.has(bucketOf(timestamp, resolution)));
825
+ return {
826
+ positions: measuredFromHistory([...stored, ...filling].sort((a, b) => a.timestamp - b.timestamp), filling),
827
+ fromHistory: filling.length,
828
+ fromStore: stored.length
829
+ };
830
+ }
831
+ /**
813
832
  * A stored point's `pauseBefore` was measured over the stored points thinning
814
833
  * dropped before it. Where a history point now comes right before it, history
815
834
  * covered part of that time, so the pause is at most the time since that
@@ -1561,6 +1580,30 @@ var WINDOWLESS_HISTORY_SPAN_MS = 864e5;
1561
1580
  */
1562
1581
  var EXISTENCE_PROBE_FROM_MS = 0;
1563
1582
  /**
1583
+ * The most buckets one history read may ask a provider for.
1584
+ *
1585
+ * A provider has no spatial filter, so a box query reads every position in
1586
+ * its window, and the cost of that read grows with the window's span over the
1587
+ * resolution, not with what is drawn. Providers fabricate a row for every
1588
+ * bucket in the range — questdb fills empty ones with nulls — and refuse a
1589
+ * range past their own cap, which one-minute buckets over two years already
1590
+ * exceed. A month of one-minute buckets keeps a Pi-class server responsive;
1591
+ * a longer window is read at a wider resolution instead.
1592
+ */
1593
+ var HISTORY_BUCKET_BUDGET = 43200;
1594
+ /**
1595
+ * How many buckets the read that finds where a provider's data begins asks
1596
+ * for.
1597
+ *
1598
+ * A window with no start would otherwise reach back to the Unix epoch, and
1599
+ * spread over fifty-odd years the bucket budget leaves buckets weeks wide.
1600
+ * One coarse read first finds the earliest bucket holding a position, so the
1601
+ * real read starts there. A thousand buckets over the whole epoch are about
1602
+ * three weeks each, which only sets how much empty time the real read starts
1603
+ * with.
1604
+ */
1605
+ var START_PROBE_BUCKETS = 1e3;
1606
+ /**
1564
1607
  * How long a provider's context list is reused before asking again.
1565
1608
  *
1566
1609
  * The probe runs only for a vessel about to 404, which is precisely the
@@ -1741,65 +1784,80 @@ async function historyContextsIn(app, window, debug, cache) {
1741
1784
  * provider is the finer source where it reaches, not a required one.
1742
1785
  */
1743
1786
  async function historyPositions(app, context, window, resolutionMs, debug) {
1744
- const providerSeconds = Math.max(1, Math.round(resolutionMs / 1e3));
1745
- const applied = providerSeconds * 1e3;
1746
- const getHistoryApi = app.getHistoryApi;
1747
- if (!getHistoryApi) return {
1787
+ const askedSeconds = Math.max(1, Math.round(resolutionMs / 1e3));
1788
+ const none = (failed) => ({
1748
1789
  points: [],
1749
- resolutionMs: applied,
1750
- failed: false
1751
- };
1790
+ resolutionMs: askedSeconds * 1e3,
1791
+ coarsened: false,
1792
+ failed
1793
+ });
1794
+ const getHistoryApi = app.getHistoryApi;
1795
+ if (!getHistoryApi) return none(false);
1752
1796
  let historyApi;
1753
1797
  try {
1754
1798
  historyApi = await withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider), HISTORY_QUERY_TIMEOUT_MS);
1755
1799
  } catch (err) {
1756
1800
  const timedOut = err instanceof HistoryTimeoutError;
1757
1801
  if (debug.enabled) debug(`${timedOut ? "History provider timed out" : "No history provider"} for ${context}: ${errorDetail(err)}`);
1758
- return {
1759
- points: [],
1760
- resolutionMs: applied,
1761
- failed: timedOut
1762
- };
1802
+ return none(timedOut);
1763
1803
  }
1764
- try {
1804
+ const read = async (from, seconds, throughEnd = false) => {
1765
1805
  const response = await withTimeout(historyApi.getValues({
1766
1806
  context,
1767
- from: Temporal.Instant.from(new Date(Math.max(window.from, EXISTENCE_PROBE_FROM_MS)).toISOString()),
1807
+ from: Temporal.Instant.from(new Date(from).toISOString()),
1768
1808
  to: Temporal.Instant.from(new Date(window.to).toISOString()),
1769
1809
  pathSpecs: [{
1770
1810
  path: "navigation.position",
1771
1811
  aggregate: "first"
1772
1812
  }],
1773
- resolution: providerSeconds
1813
+ resolution: seconds
1774
1814
  }), HISTORY_QUERY_TIMEOUT_MS);
1775
1815
  const points = [];
1776
1816
  for (const row of response?.data ?? []) {
1777
1817
  const position = historyRowPosition(row);
1778
1818
  if (position) {
1779
1819
  const timestamp = historyRowTimestamp(row);
1780
- const withinEnd = window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to;
1820
+ const withinEnd = window.inclusiveEnd || throughEnd ? timestamp <= window.to : timestamp < window.to;
1781
1821
  if (timestamp >= window.from && withinEnd) points.push({
1782
1822
  position,
1783
1823
  timestamp,
1784
- span: applied
1824
+ span: seconds * 1e3
1785
1825
  });
1786
1826
  }
1787
1827
  }
1788
- if (debug.enabled) debug(`History supplied ${points.length} position(s) for ${context}`);
1828
+ return points;
1829
+ };
1830
+ try {
1831
+ let from = Math.max(window.from, EXISTENCE_PROBE_FROM_MS);
1832
+ if (!Number.isFinite(window.from)) {
1833
+ const probeSeconds = Math.max(askedSeconds, Math.ceil((window.to - from) / 1e3 / START_PROBE_BUCKETS));
1834
+ const probed = await read(from, probeSeconds, true);
1835
+ if (probed.length === 0) return none(false);
1836
+ const earliest = Math.min(...probed.map(({ timestamp }) => timestamp));
1837
+ from = Math.max(from, earliest - probeSeconds * 1e3);
1838
+ }
1839
+ const providerSeconds = Math.max(askedSeconds, Math.ceil((window.to - from) / 1e3 / HISTORY_BUCKET_BUDGET));
1840
+ const points = await read(from, providerSeconds);
1841
+ if (debug.enabled) debug(`History supplied ${points.length} position(s) for ${context} at ${providerSeconds}s`);
1789
1842
  return {
1790
1843
  points,
1791
- resolutionMs: applied,
1844
+ resolutionMs: providerSeconds * 1e3,
1845
+ coarsened: providerSeconds > askedSeconds,
1792
1846
  failed: false
1793
1847
  };
1794
1848
  } catch (err) {
1795
1849
  if (debug.enabled) debug(`History unavailable for ${context}: ${errorDetail(err)}`);
1796
- return {
1797
- points: [],
1798
- resolutionMs: applied,
1799
- failed: true
1800
- };
1850
+ return none(true);
1801
1851
  }
1802
1852
  }
1853
+ /**
1854
+ * A track from the store and what history holds for it.
1855
+ *
1856
+ * History is the finer source where it reaches, so it wins its buckets —
1857
+ * unless the read was widened to stay within its budget, when it is the
1858
+ * coarser one and only fills what the store lacks.
1859
+ */
1860
+ var withHistory = (history, stored) => history.points.length === 0 ? stored : (history.coarsened ? fillFromHistory : reconcile)(history.points, stored, history.resolutionMs).positions;
1803
1861
  /** Epoch milliseconds for a history row, or 0 when the timestamp is unusable. */
1804
1862
  var historyRowTimestamp = (row) => {
1805
1863
  const raw = Array.isArray(row) ? row[0] : void 0;
@@ -1935,8 +1993,7 @@ function ThePlugin(app) {
1935
1993
  historyContexts: (window) => historyContextsIn(app, window, app.debug, knownContexts),
1936
1994
  reconcileWithHistory: async (context, stored, window, resolutionMs) => {
1937
1995
  const effective = resolutionMs ?? storeResolution;
1938
- const history = await historyPositions(app, context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective, app.debug);
1939
- return history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
1996
+ return withHistory(await historyPositions(app, context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective, app.debug), stored);
1940
1997
  }
1941
1998
  }));
1942
1999
  return starting.ready.catch(async () => {
@@ -1987,7 +2044,7 @@ function ThePlugin(app) {
1987
2044
  known: false
1988
2045
  }));
1989
2046
  const history = await historyPositions(app, context, query.window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effectiveResolution, app.debug);
1990
- const points = history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
2047
+ const points = withHistory(history, stored);
1991
2048
  if (points.length === 0) {
1992
2049
  if (!(known || await historyKnowsContext(app, context, app.debug, knownContexts))) return;
1993
2050
  if (history.failed) throw new HistoryUnavailableError(`History provider could not be read for ${context}`);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["LAT","LNG","toLngLat"],"sources":["../src/simplify.ts","../src/trackProvider.ts","../src/asyncTrackStore.ts","../src/glitchFilter.ts","../src/reconcile.ts","../src/sourceWatch.ts","../src/stateGate.ts","../src/gpx.ts","../src/tracks.ts","../src/index.ts"],"sourcesContent":["import type { TimedPosition } from './types.js'\n\nconst LAT = 0\nconst LNG = 1\n\n/**\n * Metres per degree of latitude. Good to ~0.5% over the WGS84 ellipsoid.\n *\n * Exported because the provider derives its automatic tolerance from the same\n * figure: \"one part in a thousand of the diagonal\" only holds while both use\n * one metres-per-degree value.\n */\nexport const M_PER_DEG = 111_320\n\n/**\n * Span, in scaled degrees, beyond which the flat projection is not trusted.\n *\n * Ten degrees is ~1,100 km, where the projection still agrees with a spherical\n * cross-track calculation to 0.1%. No leg between two recorded positions comes\n * close; a segment that does is a data artefact rather than a passage.\n */\nconst SPAN_LIMIT = 10\n\n/**\n * Douglas-Peucker simplification of a recorded track.\n *\n * Distinct from `thin()`, and the difference is the whole point: thinning\n * decimates by *time*, so it keeps a point every N seconds whether the boat was\n * turning or holding a course, and a corner falling between two samples is\n * simply lost. Simplification decimates by *shape* — it keeps the points that\n * carry the geometry and drops the ones a straight line already describes.\n *\n * That is what makes this the piece a route conversion needs. 5,550 points at\n * 1.2 s spacing is a recording, not a route; thinning it to 30 points gives 30\n * evenly spaced positions that miss every turn, while simplifying it to 30\n * gives the turns and nothing else.\n *\n * `epsilon` is a tolerance in **metres**: no point of the original track is\n * further than this from the simplified line.\n */\nexport function simplify(points: TimedPosition[], epsilon: number): TimedPosition[] {\n if (!(epsilon > 0) || points.length <= 2) {\n return points\n }\n const keep = new Uint8Array(points.length)\n keep[0] = 1\n keep[points.length - 1] = 1\n // Iterative rather than recursive: a long track recursing per segment can\n // exhaust the stack, and a track is exactly the kind of input that arrives\n // with tens of thousands of points.\n const stack: [number, number][] = [[0, points.length - 1]]\n while (stack.length > 0) {\n const [first, last] = stack.pop()!\n if (last <= first + 1) {\n continue\n }\n let worst = 0\n let worstAt = -1\n for (let i = first + 1; i < last; i++) {\n const d = perpendicularDistance(points[i]!, points[first]!, points[last]!)\n if (d > worst) {\n worst = d\n worstAt = i\n }\n }\n if (worstAt >= 0 && worst > epsilon) {\n keep[worstAt] = 1\n stack.push([first, worstAt], [worstAt, last])\n }\n }\n return points.filter((_, i) => keep[i] === 1)\n}\n\n/**\n * Distance in metres from `p` to the segment `a`-`b`.\n *\n * Positions are projected to a local plane with longitude scaled by\n * cos(latitude) at the segment's midpoint. Measured against a spherical\n * cross-track reference the error is 0.1% for segments spanning up to ten\n * degrees of latitude — some 1,100 km, longer than any leg between two\n * recorded fixes — which is far below the metre-scale tolerances this runs\n * with, and it avoids trigonometry per point per recursion level (2.9 ms for\n * 5,550 points, and `simplifyToBudget` calls this ~40 times).\n *\n * One flat scale cannot hold across a segment spanning most of a hemisphere:\n * an 80N-to-equator segment reads 85 km where the truth is 111 km. Such a\n * segment means two consecutive fixes an ocean and a continent apart, which\n * recorded data does not contain — but the tolerance is documented in metres,\n * so `SPAN_LIMIT` refuses to answer rather than answer wrongly, in either\n * axis.\n */\nfunction perpendicularDistance(p: TimedPosition, a: TimedPosition, b: TimedPosition): number {\n // Scale longitudes at the latitude midway along the segment, so a track near\n // the poles is not simplified as though a degree of longitude were 111 km\n // wide. Taking the *midpoint* rather than the start matters once a segment\n // spans latitudes: scaling an 80N-to-equator segment at 80N shrinks every\n // longitude by cos(80) and reports a point 111 km off the line as 19 km.\n const cos = Math.cos((((a.position[LAT] + b.position[LAT]) / 2) * Math.PI) / 180)\n const originLng = a.position[LNG]\n // Beyond this the flat projection stops being trustworthy, so the point is\n // kept rather than measured: keeping a point that could have gone costs a\n // little geometry, dropping one that should have stayed loses the shape.\n //\n // Both spans are checked, longitude after scaling, so the limit means the\n // same ground distance either way. Near the equator that scale is ~1, and\n // two fixes a quarter of the globe apart would otherwise be measured on the\n // flat plane and read as far closer than they are.\n if (\n Math.abs(a.position[LAT] - b.position[LAT]) > SPAN_LIMIT ||\n Math.abs(nearestLongitude(b.position[LNG], originLng) - originLng) * cos > SPAN_LIMIT\n ) {\n return Infinity\n }\n const ax = originLng * cos\n const ay = a.position[LAT]\n const bx = nearestLongitude(b.position[LNG], originLng) * cos\n const by = b.position[LAT]\n const px = nearestLongitude(p.position[LNG], originLng) * cos\n const py = p.position[LAT]\n\n const dx = bx - ax\n const dy = by - ay\n let t = 0\n const lenSq = dx * dx + dy * dy\n if (lenSq > 0) {\n // Clamped, so a point beyond either end measures to the endpoint rather\n // than to the infinite line — otherwise a track that doubles back on\n // itself reports a distance far smaller than the real deviation.\n t = Math.min(1, Math.max(0, ((px - ax) * dx + (py - ay) * dy) / lenSq))\n }\n const cx = ax + t * dx\n const cy = ay + t * dy\n return Math.hypot(px - cx, py - cy) * M_PER_DEG\n}\n\n/**\n * The smallest epsilon that simplifies `points` to at most `target` of them.\n *\n * Epsilon is meaningful in the physical world but not in the thing a user\n * cares about: \"accurate to 10 m\" gives 8 waypoints across an open bay and 400\n * through an archipelago, so a client asking for a usable route cannot pick a\n * number in advance. A point budget is predictable; the tolerance that\n * achieved it is what gets reported back.\n *\n *\n * Not reachable through the v2 API, which takes a tolerance rather than a\n * budget. It is here for the track-management webapp's \"convert to route\",\n * where the user asks for a usable number of waypoints and is told the\n * accuracy that produced them.\n */\nexport function simplifyToBudget(\n points: TimedPosition[],\n target: number,\n): { points: TimedPosition[]; epsilon: number } {\n // Two endpoints always survive, so a smaller budget is unmeetable: the\n // search would grow its bound to the 1e9 ceiling and return the endpoints\n // anyway, having reported an absurd tolerance for doing so.\n const budget = Math.max(2, target)\n if (points.length <= budget) {\n return { points, epsilon: 0 }\n }\n const found = searchTolerance(points, budget)\n // A budget the tolerance cannot reach: some segment spans more than the\n // projection is trusted across, so no epsilon drops another point. Search\n // again for the smallest tolerance yielding what was actually achievable,\n // so the reported epsilon still reproduces the returned track -- the\n // ceiling the first search stopped at would claim a billion metres was\n // applied, and zero would name a tolerance returning the original.\n return found.points.length > budget ? searchTolerance(points, found.points.length) : found\n}\n\n/**\n * The smallest tolerance whose result is within `bound` points.\n *\n * Shared by both callers so the invariant that makes a reported epsilon\n * reproducible -- the tolerance returned is one that yields exactly the points\n * returned -- lives in one place.\n *\n * Binary search rather than an exact solve: Douglas-Peucker is monotonic in\n * epsilon, so this converges, and it is bounded by iteration count rather than\n * by reaching an exact hit that may not exist.\n */\nfunction searchTolerance(points: TimedPosition[], bound: number): { points: TimedPosition[]; epsilon: number } {\n let lo = 0\n let hi = 1\n // Grow the upper bound until it is loose enough. A track can span an ocean,\n // so a fixed ceiling would either fail on large tracks or waste iterations\n // on small ones.\n let best = simplify(points, hi)\n while (best.length > bound && hi < 1e9) {\n hi *= 4\n best = simplify(points, hi)\n }\n let bestEpsilon = hi\n for (let i = 0; i < 40 && hi - lo > 0.01; i++) {\n const mid = (lo + hi) / 2\n const candidate = simplify(points, mid)\n if (candidate.length > bound) {\n lo = mid\n } else {\n hi = mid\n best = candidate\n bestEpsilon = mid\n }\n }\n return { points: best, epsilon: bestEpsilon }\n}\n\n/**\n * `lng` shifted by whole turns to sit within 180 degrees of `origin`.\n *\n * A track crossing the antimeridian is stored as 179.9 then -179.9, a tenth of\n * a degree apart on the ground but 359.8 apart numerically. Without this, a\n * straight line across it reads as a segment spanning almost the whole globe,\n * and points that lie exactly on that line measure kilometres away from it —\n * so simplification keeps every one of them.\n */\nfunction nearestLongitude(lng: number, origin: number): number {\n return lng - 360 * Math.round((lng - origin) / 360)\n}\n","import { Temporal } from '@js-temporal/polyfill'\nimport { M_PER_DEG, simplify } from './simplify.js'\nimport { segment, thin, thinToBudget } from './timeWindow.js'\nimport type { TrackStore } from './store.js'\nimport type { TrackApi, TrackBoundingBox, TrackFeature, TracksRequest, TracksResponse } from './trackApi.js'\nimport type { GeoBounds, LatLngTuple, TimedPosition, TimeWindow } from './types.js'\nimport { clipToBounds, createInBounds, toIsoTimes } from './utils.js'\n\n/**\n * Serves the v2 Track API from this plugin's store.\n *\n * The store answers the hard parts — spatial filtering, time windows, thinning\n * — so what is left here is mostly translation: v2's GeoJSON coordinate order\n * and Temporal values in, GeoJSON Features out. Segmentation is done here,\n * by `segment()`, because it is what turns a thinned track into the\n * MultiLineString the wire format wants.\n *\n * Registered alongside the v1 routes rather than replacing them, so\n * Freeboard-SK keeps working until it moves.\n */\n\nexport interface TrackProviderDeps {\n store: () => TrackStore | undefined\n selfContext: () => string\n /** Gap in ms that starts a new segment; 0 leaves the track as one line. */\n segmentGap: () => number\n /**\n * The vessel's name for `contextName`, or undefined when unknown.\n *\n * Injected like the rest: resolving it needs the server's data model, which\n * the provider has no handle on. Called per query rather than cached,\n * because an AIS target's static report routinely lands well after its\n * first position.\n */\n contextName: (context: string) => string | undefined\n /**\n * Positions a history provider holds for a context and window, reconciled\n * with the store's own.\n *\n * Injected rather than reached for directly: the reconciliation belongs to\n * the plugin, and the v1 routes have done it since #73 — a query answered\n * through the v2 provider has to give the same answer as the same query\n * answered through v1, or the plugin contradicts itself depending on which\n * route a client happens to use.\n *\n * Resolves to the store's own points unchanged when no provider is\n * installed, which is the common case.\n */\n /**\n * Contexts a history provider holds data for within the window, empty when\n * there is no provider.\n *\n * The store can be far younger than the provider, so it alone cannot say\n * which vessels exist or which passed through a box: a vessel recorded\n * only before this plugin was installed is known to history alone.\n */\n historyContexts: (window: TimeWindow | undefined) => Promise<ReadonlySet<string>>\n reconcileWithHistory: (\n context: string,\n stored: TimedPosition[],\n window: TimeWindow | undefined,\n resolutionMs: number | undefined,\n ) => Promise<TimedPosition[]>\n}\n\n/** v2 sends `[west, south, east, north]`; a bounds here is `[lat, lng]` corners. */\nconst toGeoBounds = (bbox: TracksRequest['bbox']): GeoBounds | null => {\n if (!bbox) {\n return null\n }\n const [west, south, east, north] = bbox\n return { sw: [south, west], ne: [north, east] }\n}\n\n/**\n * The window a request describes, in epoch milliseconds.\n *\n * The server resolves `duration` into `from`/`to` before a provider sees it,\n * so `duration` is handled only as a fallback for a caller that reaches this\n * directly.\n */\nconst toTimeWindow = (query: TracksRequest): TimeWindow | undefined => {\n const to = query.to ? query.to.epochMilliseconds : undefined\n const from = query.from ? query.from.epochMilliseconds : undefined\n if (from === undefined && to === undefined && !query.duration) {\n return undefined\n }\n const end = to ?? Date.now()\n const start =\n from ??\n (query.duration\n ? Temporal.Instant.fromEpochMilliseconds(end).toZonedDateTimeISO('UTC').subtract(query.duration).toInstant()\n .epochMilliseconds\n : Number.NEGATIVE_INFINITY)\n // Half-open when the caller named an end, closed when it defaulted to now.\n // A client walking adjacent windows — [T0,T1) then [T1,T2) — would otherwise\n // get the point at exactly T1 in both and draw it twice. When `to` was not\n // given the window ends at now, has no neighbour to overlap, and excluding\n // the newest point would just hide the latest fix. The v1 routes resolve it\n // the same way; see parseTrackQuery.\n return { from: start, to: end, inclusiveEnd: to === undefined }\n}\n\n/**\n * A duration in milliseconds, including the calendar units.\n *\n * `total()` refuses weeks, months and years without a starting point, because\n * their length depends on where you start counting — and the API validates\n * `resolution` as any positive ISO 8601 duration, so `?resolution=P1W` reaches\n * here and would otherwise throw a RangeError out as a 500.\n *\n * Resolved against a fixed reference rather than the queried window: a\n * resolution is a spacing, not a position, so the same query has to thin to the\n * same grid wherever in the calendar it lands. Against the window instead, two\n * identical requests would thin differently depending on when they were asked.\n *\n * The reference makes `P1M` 31 days and `P1Y` 366 — January's length, and a\n * leap year. Which month it lands on is arbitrary either way; what matters is\n * that it is the *same* arbitrary choice every time, and at these scales a\n * day's difference in the spacing is not something a caller asking for\n * month-apart points is relying on.\n */\nconst CALENDAR_REFERENCE = Temporal.PlainDate.from('2000-01-01')\n\nconst totalMilliseconds = (duration: Temporal.Duration): number =>\n duration.total({ unit: 'milliseconds', relativeTo: CALENDAR_REFERENCE })\n\n/**\n * Milliseconds back to a Duration, for reporting the spacing applied.\n *\n * Exact, not rounded to something tidier. A budget-derived spacing lands on\n * values like 24470ms, and `PT24.47S` does read as false precision — but the\n * field says what was applied, and a client re-querying with a rounded `PT24S`\n * gets 51 points where the budget it asked for was 50. Reproducibility beats\n * tidiness.\n */\nconst msToDuration = (ms: number): Temporal.Duration =>\n // Split into whole milliseconds and a nanosecond remainder: `Duration.from`\n // rejects a fractional value in any unit, and a resolution below a\n // millisecond reaches here intact — the API accepts `PT0.0005S`, which is\n // 0.5ms. Rounding it away would report a spacing that was not applied.\n Temporal.Duration.from({\n milliseconds: Math.trunc(ms),\n nanoseconds: Math.round((ms - Math.trunc(ms)) * 1e6),\n }).round({\n largestUnit: 'hour',\n })\n\nconst toLngLat = ([lat, lng]: LatLngTuple): [number, number] => [lng, lat]\n\n/** Bounding box of the returned geometry, in GeoJSON order. */\nconst boundsOf = (points: TimedPosition[]): TracksRequest['bbox'] => {\n if (points.length === 0) {\n return undefined\n }\n let south = points[0]!.position[0]\n let north = south\n const longitudes: number[] = []\n for (const { position } of points) {\n const [lat, lng] = position\n if (lat < south) south = lat\n if (lat > north) north = lat\n longitudes.push(lng)\n }\n const [west, east] = longitudeSpan(longitudes)\n return [west, south, east, north]\n}\n\n/**\n * The narrower of the two longitude intervals covering these points.\n *\n * A plain min/max describes a track spanning the antimeridian as almost the\n * whole globe: two fixes at 179 and -179 are two degrees apart, but min/max\n * reports 358. RFC 7946 says a box crossing the antimeridian is written with\n * west greater than east, so the wrap-around interval is expressible — pick it\n * whenever it is the shorter of the two.\n *\n * The widest gap between adjacent longitudes is the part of the globe the track\n * does not cover, so the complement of that gap is the tightest interval that\n * does.\n */\nconst longitudeSpan = (longitudes: number[]): [number, number] => {\n const sorted = [...longitudes].sort((a, b) => a - b)\n const min = sorted[0]!\n const max = sorted[sorted.length - 1]!\n let widestGap = 360 - (max - min)\n let west = min\n let east = max\n for (let i = 1; i < sorted.length; i++) {\n const gap = sorted[i]! - sorted[i - 1]!\n if (gap > widestGap) {\n widestGap = gap\n west = sorted[i]!\n east = sorted[i - 1]!\n }\n }\n return [west, east]\n}\n\nexport function createTrackProvider(deps: TrackProviderDeps): TrackApi {\n const matching = async (\n query: TracksRequest,\n ): Promise<{ tracks: Map<string, TimedPosition[]>; resolution: number | undefined }> => {\n const store = deps.store()\n if (!store) {\n return { tracks: new Map(), resolution: undefined }\n }\n const window = toTimeWindow(query)\n const resolution = query.resolution ? totalMilliseconds(query.resolution) : undefined\n const wanted = query.contexts?.length\n ? [...new Set(query.contexts.map(resolveSelf(deps.selfContext())))]\n : undefined\n const bounds = toGeoBounds(query.bbox)\n // Without a box there is nothing to clip to, whatever `clip` says.\n const clipTo = query.clip === true ? bounds : null\n\n // Thinning is handed to the store rather than applied to the result: it is\n // the same `thin()` either way, and asking twice is both wasted work and a\n // second place for the two to disagree about what a resolution means.\n //\n // The bbox path goes through the store's spatial filter, which the sqlite\n // store answers from its cell index rather than by reading every track,\n // and the requested contexts go with it so the store reads only those.\n const collection = await store.getFilteredTimedTracks(\n // intersects: the v2 contract matches a track on any position within the\n // window, not on where the vessel ended up — \"a vessel that crossed the\n // box an hour ago and has since left still matches\". The v1 routes keep\n // the last-position rule, which is the right answer to their own\n // question.\n {\n bbox: bounds,\n radius: null,\n intersects: true,\n ...(wanted ? { contexts: wanted } : {}),\n ...(clipTo ? { clip: true } : {}),\n },\n undefined,\n undefined,\n {\n ...(window ? { window } : {}),\n // A track matched against a box is thinned after matching, below, so\n // thinning cannot drop the point inside it, and a clipped one keeps\n // the points that carry it to the box edge.\n ...(resolution === undefined || bounds ? {} : { resolution }),\n },\n )\n\n // Contexts the store did not return but history holds data for: a vessel\n // the store has never seen, or one whose crossing of the box only history\n // recorded. The store's own points, if any, still take part in the\n // reconciliation; whether such a context matches the box is decided on\n // the reconciled track, since the store could not decide it.\n const fromHistory = [...(await deps.historyContexts(window))].filter(\n (context) => !(context in collection) && (!wanted || wanted.includes(context)),\n )\n const inBounds = bounds ? createInBounds(bounds) : undefined\n const storedFor = async (context: string): Promise<TimedPosition[]> => {\n const points = await store.getTimed(context, window).catch(() => [])\n return bounds ? points : thin(points, resolution)\n }\n\n // A provider aggregates to one position per bucket of the resolution it is\n // asked for, so a box query asks at the recording resolution: at the\n // requested one, a crossing shorter than a bucket would be aggregated away\n // before the match could see it.\n const historyResolution = bounds ? undefined : resolution\n\n const result = new Map<string, TimedPosition[]>()\n // Reconciled per context rather than in one pass: a history provider is\n // asked per context, and the store's own points are what a provider-less\n // install returns unchanged.\n const reconciled = await Promise.all([\n ...Object.entries(collection).map(\n async ([context, stored]) =>\n [context, await deps.reconcileWithHistory(context, stored, window, historyResolution)] as const,\n ),\n ...fromHistory.map(\n async (context) =>\n [\n context,\n await deps.reconcileWithHistory(context, await storedFor(context), window, historyResolution),\n ] as const,\n ),\n ])\n for (const [context, reconciledPoints] of reconciled) {\n // History can replace the stored point that put a context in the box,\n // so the match is decided on the reconciled track for every context.\n if (inBounds && !reconciledPoints.some(({ position }) => inBounds(position))) {\n continue\n }\n // Clipped after reconciling, because history positions come for the\n // whole window and the box has to cut those too.\n const points = clipTo\n ? thin(clipToBounds(reconciledPoints, clipTo), resolution)\n : bounds\n ? thin(reconciledPoints, resolution)\n : reconciledPoints\n // Dropped here rather than in getTracks, so both entry points agree on\n // what matched. A store filters on the *last* position, so a context can\n // match spatially and still have no point inside the time window;\n // listing it while getTracks returns nothing for it sends a client to\n // fetch a track that is not there.\n if (points.length === 0) {\n continue\n }\n result.set(context, points)\n }\n return { tracks: result, resolution }\n }\n\n return {\n async getTracks(query: TracksRequest): Promise<TracksResponse> {\n const { tracks: matched, resolution: requested } = await matching(query)\n const gap = deps.segmentGap()\n const selfContext = deps.selfContext()\n const features: TrackFeature[] = []\n for (const [context, all] of matched) {\n // The budget is applied per track, after the store's own thinning: a\n // client that named both is asking for this spacing *and* no more than\n // this many points, and the wider of the two wins.\n const { points, resolution: appliedMs } =\n query.maxPoints === undefined\n ? { points: all, resolution: requested }\n : thinToBudget(all, query.maxPoints, requested)\n if (points.length === 0) {\n continue\n }\n // Simplification runs after thinning, not before: thinning is what a\n // client asked for explicitly, and simplifying first would spend the\n // shape budget on points that are about to be dropped anyway.\n //\n // `epsilon` implies `simplify`, per the spec. With `simplify` and no\n // epsilon the tolerance is sized to the query's box, or to the track\n // itself when there is none; see chooseEpsilon.\n //\n // Segmenting comes first and each segment is simplified on its own.\n // The simplifier has no notion of a time gap, so given the whole track\n // it can drop the points either side of one — two collinear legs\n // three hours apart reduce to a single line, and segmenting that\n // afterwards yields one-point segments, which are not drawable\n // geometry. Splitting first keeps every leg's own endpoints.\n const chosenEpsilon = chooseEpsilon(points, query)\n const segments = segment(points, gap).map((s) => (chosenEpsilon === undefined ? s : simplify(s, chosenEpsilon)))\n const shaped = segments.flat()\n const bbox = boundsOf(shaped)\n const name = deps.contextName(context)\n features.push({\n type: 'Feature',\n // geometry=false asks for the metadata only, so a client can list\n // what exists before paying for the coordinates.\n geometry:\n query.geometry === false\n ? null\n : {\n type: 'MultiLineString',\n coordinates: segments.map((s) => s.map(({ position }) => toLngLat(position))),\n },\n properties: {\n context,\n isSelf: context === selfContext,\n // Omitted rather than empty: the spec says \"where known\".\n ...(name === undefined ? {} : { contextName: name }),\n from: new Date(shaped[0]!.timestamp).toISOString(),\n to: new Date(shaped[shaped.length - 1]!.timestamp).toISOString(),\n ...(bbox ? { bbox } : {}),\n pointCount: shaped.length,\n // The spacing actually applied, which is not always the one asked\n // for: a maxPoints budget widens it. Reported so a client can tell\n // a thinned track from a full one, and see what produced it.\n ...(appliedMs === undefined ? {} : { resolution: msToDuration(appliedMs).toString() }),\n // The tolerance simplification ran with, absent when it did not\n // run at all. Note a tolerance can legitimately change nothing —\n // a track with no point further than epsilon from its own line is\n // already as simple as it gets — so this reports what was applied\n // rather than implying the geometry differs from the stored one.\n ...(chosenEpsilon === undefined ? {} : { epsilon: chosenEpsilon }),\n ...(query.times ? { coordTimes: segments.map(toIsoTimes) } : {}),\n },\n })\n }\n return { type: 'FeatureCollection', features }\n },\n\n async getTrackContexts(query: TracksRequest): Promise<string[]> {\n return [...(await matching(query)).tracks.keys()]\n },\n }\n}\n\n/**\n * The tolerance to simplify with, or undefined to leave the geometry alone.\n *\n * `epsilon` implies `simplify=true`, so an explicit tolerance is honoured\n * whether or not the flag came with it. `simplify` alone leaves the tolerance\n * to the provider, and the spec asks for one \"suited to the size of the box\":\n * with a `bbox` it is sized to that box, which for a chart viewer is its view,\n * so detail follows the zoom. With no box the choice is the provider's, and\n * the extent of the track *as thinning left it* stands in -- not the\n * simplified extent, since that is the thing being computed. A `bbox` without\n * `simplify` simplifies nothing.\n *\n * Chosen once per track rather than per segment, so every leg of one track is\n * simplified to the same tolerance and the single reported `epsilon` describes\n * all of them.\n *\n * A non-positive `epsilon` is not reachable through the API — the schema\n * declares `exclusiveMinimum: 0`, so the server rejects it — and is treated\n * here as no tolerance given, falling through to `simplify` if that was asked\n * for. Simplifying by zero would be a no-op reported as if it had done\n * something.\n */\nfunction chooseEpsilon(points: TimedPosition[], query: TracksRequest): number | undefined {\n const explicit = query.epsilon\n if (explicit !== undefined && explicit > 0) {\n return explicit\n }\n if (query.simplify !== true) {\n return undefined\n }\n if (query.bbox) {\n return extentEpsilon(query.bbox)\n }\n const bounds = boundsOf(points)\n return bounds ? extentEpsilon(bounds) : undefined\n}\n\n/**\n * How much of an extent -- the query box, or the track without one -- the\n * automatic tolerance may deviate by.\n *\n * One part in a thousand: across a view that is roughly one screen pixel.\n * Provisional while the v2 API is designed in SignalK/signalk-server#2504: it\n * is only a provider-chosen default for `simplify` without an explicit\n * epsilon, and a client that calibrates against the exact ratio rather than\n * reading the reported `epsilon` back would make it expensive to change.\n */\nconst AUTO_EPSILON_DIVISOR = 1000\n\n/**\n * Floor, in metres, under the automatic tolerance.\n *\n * Below the noise of any GPS fix, so it collapses the thousands of near\n * identical points a vessel records at anchor without altering a track that\n * actually moved.\n */\nconst MIN_AUTO_EPSILON = 1\n\n/**\n * A tolerance scaled to how much ground an extent covers.\n *\n * One part in a thousand of its diagonal. For a track that drops the jitter of\n * a boat holding station while keeping every turn a passage is made of; for a\n * view it keeps what a pixel can show. A fixed metre value cannot do either —\n * 10 m erases nothing on an ocean crossing and erases a marina approach\n * entirely.\n */\nfunction extentEpsilon([west, south, east, north]: TrackBoundingBox): number {\n const midLat = (south + north) / 2\n const height = (north - south) * M_PER_DEG\n // A box crossing the antimeridian is written west > east (RFC 7946), so a\n // plain subtraction turns a tenth of a degree into -359.8 and the derived\n // tolerance into something that would erase the whole track.\n const lngSpan = east >= west ? east - west : east + 360 - west\n const width = lngSpan * M_PER_DEG * Math.cos((midLat * Math.PI) / 180)\n const diagonal = Math.hypot(width, height)\n // A floor, not a fallback for a zero extent. A vessel swinging at anchor\n // covers tens of metres, so the proportional tolerance works out at\n // centimetres and drops nothing -- leaving the track most worth collapsing\n // untouched. One metre is below any GPS's own noise, so applying it as a\n // minimum cannot alter a track that actually went somewhere.\n return Math.max(diagonal / AUTO_EPSILON_DIVISOR, MIN_AUTO_EPSILON)\n}\n\n/** v2 accepts the `self` alias; the store keys on the qualified context. */\nconst resolveSelf =\n (selfContext: string) =>\n (context: string): string =>\n context === 'self' || context === 'vessels.self' ? selfContext : context\n","import { SelfPositionUnavailableError } from './utils.js'\nimport { Worker } from 'node:worker_threads'\nimport type { TrackStore } from './store.js'\nimport type { SqliteTrackStore, SqliteStoreConfig } from './sqliteStore.js'\nimport type { Debug, Context, LatLngTuple, TimeWindow, TrackParams } from './types.js'\nimport type { TrackQuery } from './timeWindow.js'\nimport type { Operation, StoreMethod, WorkerMessage } from './trackStoreProtocol.js'\ninterface Pending {\n operation: Operation\n bytes: number\n resolve?: (value: unknown) => void\n reject?: (error: Error) => void\n}\ninterface Limits {\n maxItems?: number\n maxBytes?: number\n}\n\n/** One database owner; the server never waits synchronously for storage. */\nexport class AsyncTrackStore implements TrackStore {\n private queue: Pending[]\n private active: Pending[]\n private names: Map<string, string>\n private sequence: number\n private pendingBytes: number\n private maxItems: number\n private maxBytes: number\n private onError: (error: Error) => void\n private debug: Debug\n private initialized: boolean\n private closing: boolean\n private failed: boolean\n private statusError: string | undefined\n private overloaded = false\n private dropped = 0\n private gapStarted = 0\n private writeFailed = false\n private readonly admitted = new Map<Context, number>()\n private readonly resolution: number\n private readonly onResume: (message: string) => void\n readonly ready: Promise<void>\n private resolveReady!: () => void\n private rejectReady!: (error: Error) => void\n private worker: Worker\n private exited: Promise<void>\n private closeAcknowledged = false\n private scheduled = false\n private closePromise: Promise<void> | undefined\n\n constructor(\n config: SqliteStoreConfig,\n debug: Debug,\n onError: (error: Error) => void = () => {},\n limits: Limits = {},\n onResume: (message: string) => void = () => {},\n ) {\n this.resolution = config.resolution ?? 0\n this.onResume = onResume\n this.queue = []\n this.active = []\n this.names = new Map()\n this.sequence = 0\n this.pendingBytes = 0\n this.maxItems = limits.maxItems ?? 10000\n this.maxBytes = limits.maxBytes ?? 8 * 1024 * 1024\n this.onError = onError\n this.debug = debug\n this.initialized = false\n this.closing = false\n this.failed = false\n this.statusError = undefined\n this.ready = new Promise((resolve, reject) => {\n this.resolveReady = resolve\n this.rejectReady = reject\n })\n // A startup failure must be reported even if nobody is awaiting a query.\n this.ready.catch(() => {})\n // Only the unit suite runs this module from source, and it builds the worker\n // into .vitest-worker before every run, watch reruns included. Decided by\n // this module's own location, not the environment: a server started from\n // a test run inherits VITEST, yet loads the published bundle.\n const fromSource = import.meta.url.endsWith('.ts')\n const workerUrl = new URL(\n /* @vite-ignore */ fromSource ? '../.vitest-worker/trackStoreWorker.js' : '../dist/trackStoreWorker.js',\n import.meta.url,\n )\n this.worker = new Worker(workerUrl, {\n workerData: { config, debugEnabled: Boolean(debug?.enabled) },\n })\n this.worker.on('message', (message: WorkerMessage) => this.receive(message))\n this.worker.on('error', (error: Error) => this.fail(error))\n this.exited = new Promise((resolve) =>\n this.worker.on('exit', (code) => {\n if (!this.closeAcknowledged && !this.failed)\n this.fail(new Error('Track worker exited before draining (code ' + code + ')'))\n resolve()\n }),\n )\n }\n\n private report(error: Error): void {\n if (!this.statusError) {\n this.statusError = 'Track storage error: ' + error.message\n this.onError(error)\n }\n }\n\n private resumeIfDrained(): void {\n if (!this.overloaded || this.failed || this.writeFailed || this.closing) return\n if (this.queue.length + this.active.length > this.maxItems / 2 || this.pendingBytes > this.maxBytes / 2) return\n this.overloaded = false\n this.statusError = undefined\n const message = `Track recording resumed; ${this.dropped} operations dropped during capacity gap starting ${new Date(this.gapStarted).toISOString()}`\n this.dropped = 0\n this.onResume(message)\n }\n\n private fail(error: Error): void {\n if (this.failed) return\n this.failed = true\n this.statusError = undefined\n this.report(error)\n this.rejectReady(error)\n for (const item of [...this.active, ...this.queue]) item.reject?.(error)\n this.active = []\n this.queue = []\n this.pendingBytes = 0\n }\n\n private enqueue<K extends StoreMethod>(\n method: K,\n args: Parameters<SqliteTrackStore[K]>,\n write = false,\n ): Promise<Awaited<ReturnType<SqliteTrackStore[K]>>> {\n this.resumeIfDrained()\n if (this.failed || (this.closing && method !== 'close') || (write && this.statusError)) {\n if (write && this.overloaded) this.dropped++\n const error = new Error(this.statusError ?? 'Track store is closing')\n return write ? Promise.resolve(undefined as Awaited<ReturnType<SqliteTrackStore[K]>>) : Promise.reject(error)\n }\n // Copy at admission: a caller must not mutate an accepted position later.\n let copy: Parameters<SqliteTrackStore[K]>, bytes: number\n try {\n copy = structuredClone(args)\n bytes = Buffer.byteLength(JSON.stringify(copy))\n } catch (error) {\n const failure = error instanceof Error ? error : new Error(String(error))\n if (write) {\n this.report(failure)\n return Promise.resolve(undefined as Awaited<ReturnType<SqliteTrackStore[K]>>)\n }\n return Promise.reject(failure)\n }\n if (\n method !== 'close' &&\n (this.queue.length + this.active.length >= this.maxItems || this.pendingBytes + bytes > this.maxBytes)\n ) {\n const error = new Error(\n 'Track queue capacity exceeded; recording paused until accepted work drains below half capacity.',\n )\n if (write) {\n this.overloaded = true\n this.gapStarted = Date.now()\n this.dropped++\n this.report(error)\n }\n return write ? Promise.resolve(undefined as Awaited<ReturnType<SqliteTrackStore[K]>>) : Promise.reject(error)\n }\n const item: Pending = { operation: { id: ++this.sequence, method, args: copy } as Operation, bytes }\n this.pendingBytes += bytes\n const result = write\n ? Promise.resolve(undefined)\n : new Promise<unknown>((resolve, reject) => Object.assign(item, { resolve, reject }))\n this.queue.push(item)\n this.schedule()\n return result as Promise<Awaited<ReturnType<SqliteTrackStore[K]>>>\n }\n\n private schedule(): void {\n if (this.scheduled || !this.initialized || this.active.length || this.failed) return\n this.scheduled = true\n queueMicrotask(() => {\n this.scheduled = false\n this.pump()\n })\n }\n\n private pump(): void {\n if (this.failed || this.active.length || !this.queue.length) return\n // Admission already bounds the run to maxBytes/maxItems. Drain the entire\n // adjacent write run in one commit, preserving query barriers and FIFO.\n const isWrite = (item: Pending) =>\n ['newPosition', 'recordName', 'initialTrack', 'prune'].includes(item.operation.method)\n let count = 1\n if (isWrite(this.queue[0]!)) while (count < this.queue.length && isWrite(this.queue[count]!)) count++\n this.active = this.queue.splice(0, count)\n try {\n this.worker.postMessage({ operations: this.active.map(({ operation }) => operation) })\n } catch (error) {\n this.fail(error instanceof Error ? error : new Error(String(error)))\n void this.worker.terminate()\n }\n }\n\n private receive(message: WorkerMessage): void {\n if (message.kind === 'debug') {\n this.debug?.(message.text)\n return\n }\n if (message.kind === 'fatal') {\n this.fail(new Error(message.error))\n return\n }\n if (message.kind === 'ready') {\n for (const { context, name } of message.names) this.names.set(context, name)\n this.initialized = true\n this.resolveReady()\n this.schedule()\n return\n }\n if (message.kind !== 'result' || this.failed) return\n const items = this.active\n if (\n message.results.length !== items.length ||\n items.some((item, index) => message.results[index]?.id !== item.operation.id)\n ) {\n this.fail(new Error('Invalid track worker response'))\n void this.worker.terminate()\n return\n }\n this.active = []\n for (let i = 0; i < items.length; i++) {\n const item = items[i]!,\n result = message.results[i]\n this.pendingBytes -= item.bytes\n if (!result) continue\n if (result.error) {\n if (result.writeFailed && !this.writeFailed) {\n this.writeFailed = true\n this.statusError = undefined\n this.report(new Error(result.error))\n }\n item.reject?.(\n result.selfPositionUnavailable ? new SelfPositionUnavailableError(result.error) : new Error(result.error),\n )\n } else {\n if (item.operation.method === 'prune') {\n const remaining = new Set(result.names ?? [])\n for (const context of this.names.keys()) if (!remaining.has(context)) this.names.delete(context)\n }\n if (result.name) {\n if (result.name.value === undefined) this.names.delete(result.name.context)\n else this.names.set(result.name.context, result.name.value)\n }\n if (item.operation.method === 'close') this.closeAcknowledged = true\n item.resolve?.(result.value)\n }\n }\n this.resumeIfDrained()\n this.schedule()\n }\n\n newPosition(context: Context, position: LatLngTuple, timestamp = Date.now()): void {\n const previous = this.admitted.get(context)\n if (this.resolution > 0 && previous !== undefined && timestamp - previous < this.resolution) return\n const sequence = this.sequence\n void this.enqueue('newPosition', [context, position, timestamp], true)\n // Dropped positions must not suppress the first fix after recovery.\n if (this.sequence !== sequence && this.resolution > 0) this.admitted.set(context, timestamp)\n }\n recordName(context: Context, name: string, timestamp = Date.now()): void {\n void this.enqueue('recordName', [context, name, timestamp], true)\n }\n nameFor(context: Context): string | undefined {\n return this.names.get(context)\n }\n initialTrack(context: Context, track: LatLngTuple[], timestamps?: number[]): void {\n const sequence = this.sequence\n void this.enqueue('initialTrack', [context, track, timestamps], true)\n if (this.sequence !== sequence) this.admitted.delete(context)\n }\n prune(maxAge: number, keep?: Context): void {\n const now = Date.now()\n const sequence = this.sequence\n void this.enqueue('prune', [maxAge, keep, now], true)\n if (this.sequence !== sequence) {\n for (const [context, timestamp] of this.admitted) {\n if (context !== keep && timestamp < now - maxAge) this.admitted.delete(context)\n }\n }\n }\n get(context: Context, window?: TimeWindow) {\n return this.enqueue('get', [context, window])\n }\n getTimed(context: Context, window?: TimeWindow) {\n return this.enqueue('getTimed', [context, window])\n }\n getAllTracks(query?: TrackQuery) {\n return this.enqueue('getAllTracks', [query])\n }\n getFilteredTracks(params: TrackParams, selfPosition?: LatLngTuple, _debug?: Debug, query?: TrackQuery) {\n return this.enqueue('getFilteredTracks', [params, selfPosition, undefined, query])\n }\n getFilteredTimedTracks(params: TrackParams, selfPosition?: LatLngTuple, _debug?: Debug, query?: TrackQuery) {\n return this.enqueue('getFilteredTimedTracks', [params, selfPosition, undefined, query])\n }\n close(): Promise<void> {\n if (!this.closePromise) {\n this.closing = true\n this.closePromise = this.enqueue('close', []).then(\n () => this.exited,\n async (error: unknown) => {\n await this.exited\n throw error\n },\n )\n this.closePromise.catch(() => {})\n }\n return this.closePromise\n }\n}\n","import { createDistanceTo } from './utils.js'\nimport type { Context, LatLngTuple } from './types.js'\n\n/**\n * Drops positions that imply an impossible speed since the last accepted one.\n *\n * A GPS occasionally emits a fix hundreds of miles away — a bad almanac, a\n * multipath reflection, a receiver resetting. On a live map it flickers past.\n * In a stored track it is permanent: one spike stretches the bounding box\n * across an ocean, inflates the distance travelled, and drags the line across\n * the chart every time the track is drawn.\n *\n * Speed, not distance, is the test. A vessel can legitimately jump a long way\n * between two fixes if the gap between them is long enough — after a passage\n * with the plugin stopped, or when a slow-reporting AIS target reappears. What\n * cannot happen is covering that distance faster than a vessel moves.\n *\n * Requested in https://github.com/SignalK/tracks/issues/48\n */\n\n/**\n * Default ceiling in knots.\n *\n * Above any displacement vessel and most planing craft, and well above the\n * ~50kn an AIS Class A target reports at the top of its scale, so a real fix is\n * never dropped. Glitches are typically wrong by whole degrees of latitude, so\n * they miss this by orders of magnitude rather than by a little.\n */\nexport const DEFAULT_MAX_SPEED_KNOTS = 100\n\nconst METRES_PER_NAUTICAL_MILE = 1852\n\ninterface LastAccepted {\n position: LatLngTuple\n timestamp: number\n}\n\nexport interface GlitchFilterConfig {\n /** Ceiling in knots. 0 or below disables the filter. */\n maxSpeedKnots: number\n}\n\nexport class GlitchFilter {\n private readonly last = new Map<Context, LastAccepted>()\n private readonly maxSpeedKnots: number\n /** Rejections per context, for the plugin status line. */\n private readonly rejected = new Map<Context, number>()\n\n constructor(config: GlitchFilterConfig) {\n this.maxSpeedKnots = config.maxSpeedKnots\n }\n\n /**\n * Whether to keep this position.\n *\n * Accepting updates the reference point, so a sustained move — a genuinely\n * fast vessel, or a track resuming after a gap — is not fought after the\n * first fix.\n */\n accept(context: Context, position: LatLngTuple, timestamp: number): boolean {\n if (this.maxSpeedKnots <= 0) {\n return true\n }\n const previous = this.last.get(context)\n if (previous === undefined) {\n this.last.set(context, { position, timestamp })\n return true\n }\n\n // Out-of-order or same-instant fixes carry no usable speed: dividing by a\n // zero or negative interval says nothing about whether the jump is real.\n // Bootstrapped history arrives back-dated, so this is not hypothetical.\n const seconds = (timestamp - previous.timestamp) / 1000\n if (seconds <= 0) {\n return true\n }\n\n const metres = createDistanceTo(previous.position)(position)\n const knots = metres / seconds / (METRES_PER_NAUTICAL_MILE / 3600)\n if (knots > this.maxSpeedKnots) {\n this.rejected.set(context, (this.rejected.get(context) ?? 0) + 1)\n return false\n }\n\n this.last.set(context, { position, timestamp })\n return true\n }\n\n /** How many positions have been rejected for a context. */\n rejectedCount(context: Context): number {\n return this.rejected.get(context) ?? 0\n }\n\n /** Total rejections across every context. */\n totalRejected(): number {\n let total = 0\n for (const count of this.rejected.values()) {\n total += count\n }\n return total\n }\n\n /** Forget every reference point; used when the plugin restarts. */\n clear(): void {\n this.last.clear()\n this.rejected.clear()\n }\n}\n","import { uncoveredBetween } from './timeWindow.js'\nimport type { TimedPosition } from './types.js'\n\n/**\n * Combines positions from a history provider with the plugin's own store.\n *\n * The two sources record the same vessel differently. The plugin's store keeps\n * one position a minute forever, which is what a track from three years ago is\n * made of. A history provider keeps whatever resolution it was configured for\n * — often every couple of seconds — but only for as long as its retention\n * allows, and only for as long as it has actually been running.\n *\n * So neither is simply better. History is finer where it reaches; the store is\n * what remains everywhere else.\n *\n * ## The rule\n *\n * Trust history for any bucket where it returns a usable position. Use the\n * store for every other bucket.\n *\n * That one rule covers every way a provider's coverage can be incomplete,\n * without reading any configuration:\n *\n * - retention has dropped the old data — history returns nothing there\n * - the provider was installed after the boat started recording — likewise\n * - the provider was disabled, or its database was down, for a week — likewise\n * - the vessel was ashore and nothing was recorded — history returns nothing\n * and so does the store, which is the correct answer\n *\n * Coverage is therefore discovered per query rather than configured, and\n * nothing breaks when it changes underneath.\n *\n * ## Why the sources cannot double up\n *\n * A history provider aggregates into resolution buckets and timestamps each\n * one on its boundary — 19:00:00.000, 19:01:00.000. The store keeps the actual\n * time a fix arrived, 19:00:01.212. The same physical position therefore has\n * two different timestamps, so joining on equality would silently keep both.\n *\n * Bucketing avoids the problem rather than solving it: each bucket is filled\n * from exactly one source, so no comparison between individual points is ever\n * needed.\n */\n\n/** A bucket index for a timestamp, so both sources land on the same grid. */\nconst bucketOf = (timestamp: number, resolution: number): number => Math.floor(timestamp / resolution)\n\nexport interface ReconcileResult {\n positions: TimedPosition[]\n /** Buckets served from the history provider. */\n fromHistory: number\n /** Buckets served from the plugin's own store. */\n fromStore: number\n}\n\n/**\n * Merge history and store positions into one track.\n *\n * `resolution` is the bucket width in milliseconds; it should be the\n * resolution the history provider was asked for, so its rows land one per\n * bucket.\n */\nexport function reconcile(history: TimedPosition[], stored: TimedPosition[], resolution: number): ReconcileResult {\n if (resolution <= 0) {\n // Without a bucket width there is no grid to reconcile on. History is\n // still the finer source, so it wins outright and the store fills only\n // what lies outside its extent.\n return reconcileByExtent(history, stored)\n }\n\n const covered = new Set<number>()\n const positions: TimedPosition[] = []\n for (const point of history) {\n covered.add(bucketOf(point.timestamp, resolution))\n positions.push(point)\n }\n const fromHistory = positions.length\n\n for (const point of stored) {\n if (!covered.has(bucketOf(point.timestamp, resolution))) {\n positions.push(point)\n }\n }\n\n positions.sort((a, b) => a.timestamp - b.timestamp)\n return {\n positions: measuredFromHistory(positions, history),\n fromHistory,\n fromStore: positions.length - fromHistory,\n }\n}\n\n/**\n * A stored point's `pauseBefore` was measured over the stored points thinning\n * dropped before it. Where a history point now comes right before it, history\n * covered part of that time, so the pause is at most the time since that\n * bucket, and at most the longest step the store recorded, whichever is less.\n */\nfunction measuredFromHistory(positions: TimedPosition[], history: TimedPosition[]): TimedPosition[] {\n const fromHistory = new Set(history)\n return positions.map((point, i) => {\n const before = positions[i - 1]\n if (point.pauseBefore === undefined || before === undefined || !fromHistory.has(before)) {\n return point\n }\n return { ...point, pauseBefore: Math.min(point.pauseBefore, uncoveredBetween(before, point)) }\n })\n}\n\n/**\n * Fallback when no bucket width is known: history covers its own time span,\n * and the store supplies whatever falls outside it.\n *\n * Less precise than bucketing — a gap inside history's span is not filled —\n * but it cannot produce duplicates, which matters more.\n */\nfunction reconcileByExtent(history: TimedPosition[], stored: TimedPosition[]): ReconcileResult {\n if (history.length === 0) {\n return { positions: [...stored], fromHistory: 0, fromStore: stored.length }\n }\n // Min and max rather than first and last: nothing guarantees a provider\n // returns rows in time order, and assuming it does would judge a stored\n // point inside history's span to be outside it, keeping both.\n let first = history[0]!.timestamp\n let last = first\n for (const point of history) {\n if (point.timestamp < first) first = point.timestamp\n if (point.timestamp > last) last = point.timestamp\n }\n const outside = stored.filter((p) => p.timestamp < first || p.timestamp > last)\n const positions = [...history, ...outside].sort((a, b) => a.timestamp - b.timestamp)\n return {\n positions: measuredFromHistory(positions, history),\n fromHistory: history.length,\n fromStore: outside.length,\n }\n}\n","import type { Context } from './types.js'\n\n/**\n * Watches which `$source` each context's positions arrive from.\n *\n * `navigation.position` is a rapid update — a GPS may report at 10Hz — and a\n * typical boat has several receivers: an internal GPS, an AIS transponder, a\n * chart plotter echoing its own fix.\n *\n * Signal K resolves which one wins through source priority, and the bus this\n * plugin listens on is the filtered one: the server applies `toPreferredDelta`\n * before emitting `delta`, which is what feeds `getBus()`. So with a priority\n * rule in place, only the winner arrives and there is nothing to warn about.\n *\n * The gap is when no rule *matches* the path. Then the engine passes every\n * source through, the fixes from receivers metres apart interleave, and the\n * track zigzags between them rather than following the boat. The fix is to\n * configure source priority for `navigation.position`; this watcher exists to\n * say so, because the symptom (a track that looks noisy) does not obviously\n * point at the cause.\n *\n * Detection is therefore empirical rather than reading the priority config:\n * seeing several sources here *is* the evidence that no rule is matching,\n * which a config that merely exists would not tell us.\n *\n * With one qualification. A priority rule has a failover window — when the\n * top-ranked source goes quiet the engine promotes the next one — so over a\n * long run several sources legitimately reach this bus even with a rule in\n * place, one at a time. Counting every source ever seen would therefore\n * report a conflict on a correctly configured boat whose GPS drops out\n * occasionally. What distinguishes competition is *alternation*: unfiltered\n * sources hand back and forth repeatedly within seconds, while a failover\n * switches once and stays.\n */\n\n/**\n * How quickly the source must change again for it to count as alternation.\n *\n * Shorter than the server's 15s default failover, so a promotion — one\n * switch after a silence — never chains into a sequence. Positions are a\n * rapid update, often 10Hz, so genuinely competing sources alternate far\n * faster than this.\n */\nconst ALTERNATION_WINDOW_MS = 10 * 1000\n\n/**\n * How long a conflict is remembered after it was last observed.\n *\n * Longer than the 30s status tick, so a conflict seen just after one tick is\n * still reported at the next and does not flicker. Finite, so fixing the\n * priorities clears the warning on its own rather than needing a restart —\n * which is the whole complaint about a watcher that only ever accumulates.\n */\nconst CONFLICT_RETENTION_MS = 90 * 1000\n\n/**\n * How many source changes in a row before it counts as competition.\n *\n * One change is a failover, two is a failback — the preferred source\n * returning and winning immediately, which the server does on purpose. Three\n * means the sources are trading the path back and forth, which no priority\n * rule does.\n */\nconst CHANGES_BEFORE_WARNING = 3\n\nexport class SourceWatch {\n /** Last time each source produced a position, per context. */\n private readonly seen = new Map<Context, Map<string, number>>()\n /** Sources found competing, per context, with when each was last seen so. */\n private readonly competing = new Map<Context, Map<string, number>>()\n /** The current alternation per context: who is in it, and how long it is. */\n private readonly lastSeen = new Map<\n Context,\n { source: string; at: number; changedAt: number; changes: number; participants: Set<string> }\n >()\n\n /** Record that `context` produced a position from `$source`. */\n add(context: Context, source: string | undefined, now: number = Date.now()): void {\n if (!source) {\n return\n }\n let sources = this.seen.get(context)\n if (!sources) {\n sources = new Map()\n this.seen.set(context, sources)\n }\n // Counted per *change* of source, not per overlapping report: a recovered\n // source reporting repeatedly is one transition, however many fixes it\n // sends. Judged as positions arrive rather than when the status is\n // published, which is up to 30s later.\n const previous = this.lastSeen.get(context)\n // Recorded before the conflict below reads each participant's last-seen\n // time: a source returning after a long silence would otherwise be filed\n // with its old timestamp and expire immediately.\n sources.set(source, now)\n // Timed from the last *change*, not the last report: repeated fixes from\n // the winner are the healthy state and neither advance the sequence nor\n // end it, while a run of them long enough to pass the window does.\n const stale = !previous || now - previous.changedAt > ALTERNATION_WINDOW_MS\n if (stale || previous.source === source) {\n this.lastSeen.set(context, {\n source,\n at: now,\n changedAt: stale ? now : previous.changedAt,\n changes: stale ? 0 : previous.changes,\n participants: stale ? new Set([source]) : previous.participants,\n })\n } else {\n const changes = previous.changes + 1\n const participants = previous.participants.add(source)\n this.lastSeen.set(context, { source, at: now, changedAt: now, changes, participants })\n if (changes >= CHANGES_BEFORE_WARNING) {\n // Every source in the alternation, not just the last pair: three\n // receivers trading a path is one conflict, and naming two of them\n // sends the user after the wrong hardware.\n const found = this.competing.get(context) ?? new Map<string, number>()\n for (const participant of participants) {\n // Each carries its own last-seen time, so one that drops out of the\n // alternation expires on schedule instead of being kept alive by\n // the others still trading.\n found.set(participant, sources.get(participant) ?? now)\n }\n this.competing.set(context, found)\n }\n }\n }\n\n /** Every source seen for a context, in the order they first appeared. */\n sourcesFor(context: Context): string[] {\n return [...(this.seen.get(context) ?? new Map<string, number>()).keys()]\n }\n\n /**\n * The sources found live at the same time, which is what a warning names.\n *\n * Expired entries are dropped rather than merely hidden, so a conflict that\n * has stopped recurring leaves nothing behind.\n */\n competingFor(context: Context, now: number = Date.now()): string[] {\n const found = this.competing.get(context)\n if (!found) {\n return []\n }\n for (const [source, at] of found) {\n if (now - at > CONFLICT_RETENTION_MS) {\n found.delete(source)\n }\n }\n // One source left is nobody to compete with.\n if (found.size < 2) {\n this.competing.delete(context)\n return []\n }\n return [...found.keys()]\n }\n\n /** Contexts that have received positions from two sources at once. */\n conflicted(now: number = Date.now()): Context[] {\n return [...this.competing.keys()].filter((context) => this.competingFor(context, now).length > 1)\n }\n\n /**\n * A status line naming the problem, or undefined when nothing is wrong.\n *\n * `selfContext` is called out by name because the own vessel's track is the\n * one a user looks at, and it is the one whose priority they can fix. Other\n * vessels are usually AIS, where multiple receivers are expected and less\n * worth nagging about — they are counted, not named.\n */\n warning(selfContext?: Context, now: number = Date.now()): string | undefined {\n const conflicted = this.conflicted(now)\n if (conflicted.length === 0) {\n return undefined\n }\n const selfConflicted = selfContext !== undefined && conflicted.includes(selfContext)\n const others = conflicted.length - (selfConflicted ? 1 : 0)\n\n if (selfConflicted) {\n const recent = this.competingFor(selfContext, now)\n const sources = recent.join(', ')\n const tail = others > 0 ? ` (and ${others} other ${others === 1 ? 'vessel' : 'vessels'})` : ''\n return (\n `navigation.position for the own vessel is arriving from ${recent.length} sources ` +\n `(${sources})${tail}. Set source priority for navigation.position, or the track will zigzag between them.`\n )\n }\n return `navigation.position is arriving from multiple sources for ${others} ${others === 1 ? 'vessel' : 'vessels'}.`\n }\n\n /** Forget everything seen; used when the plugin restarts. */\n clear(): void {\n this.seen.clear()\n this.competing.clear()\n this.lastSeen.clear()\n }\n}\n","/**\n * Pauses recording of the own vessel's track while it is not going anywhere.\n *\n * A boat on a mooring for the winter emits a position every second and travels\n * nowhere, so the track gains nothing but rows. Gating on `navigation.state`\n * lets those months cost nothing, which matters once tracks are kept\n * indefinitely.\n *\n * Requested in https://github.com/SignalK/tracks/issues/1, where the\n * suggestion is `signalk-autostate` as the source of the state.\n *\n * Two limits are deliberate.\n *\n * **`anchored` is not a pause state, and is not offered as a default.** An\n * anchor alarm needs exactly the track a vessel makes while anchored — swinging\n * on the rode is the signal it watches. Gating there would break a real\n * consumer, so a user has to choose it explicitly if they ever want it.\n *\n * **Own vessel only.** `navigation.state` for an AIS target is the\n * navigational status its operator set on the transponder, and it is often\n * stale: vessels under way still reporting `moored` are ordinary. Gating those\n * would drop targets from the track while they are visibly moving.\n */\nimport type { Context } from './types.js'\n\n// Recording a vessel that is not going anywhere costs rows and produces a\n// smear of GPS noise rather than a track. `anchored` is deliberately absent: an\n// anchor alarm watches exactly the track a vessel makes while swinging on its\n// rode, so pausing there would remove the one thing that use case needs.\nexport const DEFAULT_PAUSE_STATES: string[] = ['moored', 'not-under-way', 'aground']\n\n/**\n * The states a boat is plausibly parked in, offered in the plugin UI.\n *\n * A subset of the `navigation.state` enum in `@signalk/signalk-schema` rather\n * than the whole of it: most of that list is AIS navigational status, and\n * offering `trawling-hauling` as a reason to stop recording would be noise.\n */\nexport const PAUSABLE_STATES = ['moored', 'not-under-way', 'anchored', 'aground'] as const\n\nexport class StateGate {\n private readonly pauseStates: ReadonlySet<string>\n private readonly selfContext: string\n /** Last state seen, for the plugin status line. */\n private lastState: string | undefined\n private pausedCount = 0\n\n constructor(selfContext: string, pauseStates: readonly string[]) {\n this.selfContext = selfContext\n this.pauseStates = new Set(pauseStates)\n }\n\n /** Whether the gate can ever pause anything. */\n get enabled(): boolean {\n return this.pauseStates.size > 0\n }\n\n /**\n * Whether to record this position.\n *\n * `state` is whatever `navigation.state` currently reads, or undefined when\n * the vessel does not report it. Undefined records: a boat with no state at\n * all must not silently stop being tracked.\n */\n accept(context: Context, state: string | undefined): boolean {\n if (!this.enabled || context !== this.selfContext) {\n return true\n }\n this.lastState = state\n if (state !== undefined && this.pauseStates.has(state)) {\n this.pausedCount++\n return false\n }\n return true\n }\n\n /** The state last seen for the own vessel, or undefined if none yet. */\n get state(): string | undefined {\n return this.lastState\n }\n\n /** How many positions have been skipped. */\n get skipped(): number {\n return this.pausedCount\n }\n\n /** A status line while paused, or undefined when recording. */\n status(): string | undefined {\n if (!this.enabled || this.lastState === undefined) {\n return undefined\n }\n if (!this.pauseStates.has(this.lastState)) {\n return undefined\n }\n return `Not recording: navigation.state is '${this.lastState}'. ${this.pausedCount} position(s) skipped.`\n }\n\n /** Forget what was seen; used when the plugin restarts. */\n clear(): void {\n this.lastState = undefined\n this.pausedCount = 0\n }\n}\n","import { DOMParser } from '@xmldom/xmldom'\nimport type { Document, Element } from '@xmldom/xmldom'\nimport type { TimedPosition } from './types.js'\n\n/**\n * GPX 1.1 serialisation and parsing for recorded tracks.\n *\n * Hand-written rather than through a library: the vocabulary is `gpx`,\n * `metadata`, `trk`, `name`, `extensions`, `signalk:context`, `trkseg`,\n * `trkpt` and `time` — nine element types — and a dependency for that would be\n * more surface than the code it replaces.\n * What matters is getting the details right, and those are documented below.\n */\n\nconst LAT = 0\nconst LNG = 1\n\n/** Identity of the vessel a track belongs to, as far as it is known. */\nexport interface GpxTrackIdentity {\n /** Display label, e.g. `Own Ship` or `AIS Ariadne`. Becomes `<name>`. */\n name: string\n /** Fully qualified Signal K context, when there is one. */\n context?: string\n}\n\nexport interface GpxTrack extends GpxTrackIdentity {\n /** Segments as `segment()` produces them: a recording gap starts a new one. */\n segments: TimedPosition[][]\n}\n\n/**\n * Serialise tracks as a GPX 1.1 document.\n *\n * Several tracks go in one file, each as its own `<trk>`. GPX allows it, and\n * it is what makes \"select three trips, export\" produce one file rather than\n * three — TimeZero writes one file per vessel, which is a choice rather than a\n * constraint of the format.\n *\n * The Signal K context travels in `<extensions>`. TimeZero puts an opaque\n * GUID there and drops the MMSI entirely, so its exports cannot round-trip an\n * identity; carrying the context means ours can, while staying readable by\n * anything else, since `<extensions>` is ignorable by specification.\n */\nexport function toGpx(tracks: GpxTrack[], now: Date = new Date()): string {\n const lines = [\n '<?xml version=\"1.0\" encoding=\"UTF-8\"?>',\n '<gpx version=\"1.1\" creator=\"@signalk/tracks-plugin\" xmlns=\"http://www.topografix.com/GPX/1/1\">',\n ' <metadata>',\n ` <time>${iso(now.getTime())}</time>`,\n ' </metadata>',\n ]\n for (const track of tracks) {\n const segments = track.segments\n .map((points) => points.filter((p) => inRange(p.position[LAT], p.position[LNG])))\n .filter((points) => points.length > 0)\n // A <trk> with no <trkseg> is a name and nothing else: fromGpx ignores it,\n // so writing one loses the track and its identity on a round-trip.\n if (segments.length === 0) {\n continue\n }\n lines.push(' <trk>')\n lines.push(` <name>${escapeXml(track.name)}</name>`)\n if (track.context !== undefined) {\n lines.push(' <extensions>')\n lines.push(` <signalk:context xmlns:signalk=\"${NAMESPACE}\">${escapeXml(track.context)}</signalk:context>`)\n lines.push(' </extensions>')\n }\n for (const points of segments) {\n lines.push(' <trkseg>')\n for (const { position, timestamp } of points) {\n lines.push(` <trkpt lat=\"${coordinate(position[LAT])}\" lon=\"${coordinate(position[LNG])}\">`)\n // <time> is the only child, and GPX 1.1 fixes the order of trkpt's\n // children — a schema-validating reader rejects them out of sequence.\n lines.push(` <time>${iso(timestamp)}</time>`)\n lines.push(' </trkpt>')\n }\n lines.push(' </trkseg>')\n }\n lines.push(' </trk>')\n }\n lines.push('</gpx>')\n return lines.join('\\n') + '\\n'\n}\n\n/** GPX's own namespace, which structural elements must be in. */\nconst GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1'\n\n/** Namespace for the one extension element this plugin writes. */\nconst NAMESPACE = 'https://signalk.org/specification/1.7.0/'\n\n/**\n * Parse a GPX document into tracks.\n *\n * Structure follows GPX's own hierarchy: the root is `<gpx>`, tracks are its\n * children, segments are a track's, points are a segment's. Anything else —\n * a `<trkseg>` inside an `<extensions>` payload, a `<trk>` below the root — is\n * not track data, and a namespace check alone cannot tell the difference,\n * since a payload declaring no namespace inherits GPX's own.\n *\n * Deliberately forgiving about structure and strict about values: elements it\n * does not recognise are ignored rather than rejected, but a coordinate that is\n * not an `xsd:decimal` in range, or a time that is not an `xsd:dateTime`, is\n * refused. One NaN reaching the store stretches every bounding box that track\n * appears in.\n *\n * **Points are sorted by time within each segment.** TimeZero paginates its\n * export in blocks of 150 and the block seams overlap: the last point of a\n * block can be a few milliseconds newer than the first point of the next. Out\n * of 5,550 points in a real export, 16 pairs are out of order, every one on a\n * 150-point boundary. Anything downstream that assumes ordering — time-window\n * queries, the history reconciliation — would quietly misbehave on such a file.\n *\n * Sorting stops at the segment boundary, deliberately: a segment is a stretch\n * of continuous recording, and reordering points across a gap would merge two\n * passages into one line. So `segments.flat()` is not globally sorted for a\n * file whose segments interleave in time, and a caller that needs that must\n * sort for itself.\n *\n * A document the parser rejects yields no tracks rather than throwing: this\n * reads files the plugin did not write, and an import that fails should say so\n * by returning nothing rather than by taking the caller down.\n */\nexport function fromGpx(xml: string): GpxTrack[] {\n let document: Document\n try {\n // onError swallows the warnings xmldom would otherwise print; a fatal\n // error still throws, which the catch below turns into an empty result.\n document = new DOMParser({\n onError: (level) => {\n // A \"warning\" is xmldom telling us about something it handled; an\n // \"error\" is a syntax fault it recovered from by guessing. The\n // contract is that a document which is not well-formed yields no\n // tracks, so a guess is refused rather than imported.\n if (level !== 'warning') {\n throw new Error('malformed XML')\n }\n },\n }).parseFromString(xml, 'text/xml')\n } catch {\n return []\n }\n\n // The root must be GPX's own: a <trk> nested in an <extensions> payload\n // inherits the namespace and would otherwise import as a real track, and a\n // document rooted at something else entirely is not GPX at all.\n const root = document.documentElement\n if (root === null || root.localName !== 'gpx' || !isGpxNamespace(root)) {\n return []\n }\n\n const tracks: GpxTrack[] = []\n for (const trk of gpxChildren(root, 'trk')) {\n const segments: TimedPosition[][] = []\n for (const trkseg of gpxChildren(trk, 'trkseg')) {\n const points = readPoints(trkseg)\n if (points.length > 0) {\n segments.push(points)\n }\n }\n if (segments.length === 0) {\n continue\n }\n // Trimmed because surrounding whitespace is the writer's layout rather\n // than part of the name, and defaulted because a track list with a blank\n // row in it is worse than one with a generic label.\n const name = childText(trk, 'name').trim() || 'Track'\n const context = signalKContext(trk)\n tracks.push({ name, segments, ...(context === undefined ? {} : { context }) })\n }\n return tracks\n}\n\n/**\n * The text of a direct GPX child with this local name.\n *\n * Direct rather than `getElementsByTagName`, which would reach a `<name>`\n * inside an `<extensions>` payload and label the track with a vendor's string.\n */\nfunction childText(trk: Element, local: string): string {\n for (const child of Array.from(trk.childNodes)) {\n if (isElement(child) && child.localName === local && isGpxNamespace(child)) {\n return child.textContent ?? ''\n }\n }\n return ''\n}\n\n/**\n * The Signal K context declared in a track's own `<extensions>`.\n *\n * Matched on namespace URI, which is what the prefix actually means: GPX\n * extensions are an open field any vendor may write into, so a foreign\n * `<context>` — or one under a different prefix bound elsewhere — must not\n * claim the track's identity. Read only from the track's direct\n * `<extensions>` children, because a trackpoint carries its own and those\n * belong to that point.\n */\nfunction signalKContext(trk: Element): string | undefined {\n for (const child of Array.from(trk.childNodes)) {\n if (!isElement(child) || child.localName !== 'extensions' || !isGpxNamespace(child)) {\n continue\n }\n for (const candidate of Array.from(child.getElementsByTagNameNS(NAMESPACE, 'context'))) {\n const value = (candidate.textContent ?? '').trim()\n if (value !== '') {\n return value\n }\n }\n }\n return undefined\n}\n\n/** The points of one `<trkseg>`, oldest first. */\nfunction readPoints(trkseg: Element): TimedPosition[] {\n const points: TimedPosition[] = []\n for (const trkpt of gpxChildren(trkseg, 'trkpt')) {\n const latitude = decimal(trkpt.getAttribute('lat') ?? undefined)\n const longitude = decimal(trkpt.getAttribute('lon') ?? undefined)\n if (!inRange(latitude, longitude)) {\n continue\n }\n const raw = childText(trkpt, 'time').trim()\n const timestamp = raw === '' ? Number.NaN : dateTime(raw)\n points.push({\n position: [latitude, longitude],\n // A point with no usable time is dated to the start of time rather than\n // dropped: its position is still real, and a window query treats it as\n // older than anything live.\n timestamp: Number.isFinite(timestamp) ? timestamp : 0,\n })\n }\n // Stable by construction: equal timestamps keep their file order, so a block\n // seam does not reshuffle points that were already correct.\n return points.sort((a, b) => a.timestamp - b.timestamp)\n}\n\n/**\n * Direct GPX children of `root` with this local name.\n *\n * Direct, because GPX fixes the hierarchy: a `<trkseg>` is a child of `<trk>`\n * and a `<trkpt>` a child of `<trkseg>`. Scanning descendants instead lets an\n * `<extensions>` payload contribute track data — and a payload that declares\n * no namespace of its own inherits GPX's, so a namespace check cannot catch\n * it. A vendor's `<trkseg>` inside `<extensions>` became a phantom segment.\n */\nfunction gpxChildren(root: Element, local: string): Element[] {\n return Array.from(root.childNodes).filter(\n (node): node is Element => isElement(node) && node.localName === local && isGpxNamespace(node),\n )\n}\n\n/**\n * Whether an element is GPX's own.\n *\n * A null namespace counts: a great many files in the wild omit the xmlns\n * declaration entirely, and refusing those would reject most of what users\n * actually have.\n */\nfunction isGpxNamespace(element: Element): boolean {\n return element.namespaceURI === null || element.namespaceURI === GPX_NAMESPACE\n}\n\n/** Narrow a DOM node to an element without relying on a global Node. */\nfunction isElement(node: { nodeType: number }): node is Element {\n return node.nodeType === 1\n}\n\n/**\n * Coordinates at seven decimal places: ~11 mm, finer than any GPS fix and\n * short enough that a long track does not carry pointless digits.\n */\nfunction coordinate(value: number): string {\n const fixed = value.toFixed(7).replace(/\\.?0+$/, '')\n // A tiny negative rounds to \"-0.0000000\" and strips to \"-0\", which is a\n // number no position ever is. Zero is zero.\n return fixed === '-0' ? '0' : fixed\n}\n\n/**\n * A GPX `<time>` as epoch milliseconds, or NaN when it is not one.\n *\n * GPX types the element as `xsd:dateTime`, and `Date.parse` is far looser: it\n * reads a bare `2024` and `Jan 1 2024` as instants, and silently rolls\n * `2024-02-30` forward to March. A date that never existed should reach the\n * caller's fallback rather than arrive as a real -- and wrong -- position in\n * time.\n */\nfunction dateTime(value: string): number {\n const match = /^(\\d{4})-(\\d{2})-(\\d{2})T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$/.exec(value)\n if (!match) {\n return Number.NaN\n }\n const parsed = Date.parse(value)\n if (Number.isNaN(parsed)) {\n return Number.NaN\n }\n // Date.parse normalises an impossible day rather than rejecting it, so the\n // calendar fields are compared against what they round-tripped to.\n const year = Number(match[1])\n const month = Number(match[2])\n const day = Number(match[3])\n const utc = new Date(Date.UTC(year, month - 1, day))\n return utc.getUTCFullYear() === year && utc.getUTCMonth() === month - 1 && utc.getUTCDate() === day\n ? parsed\n : Number.NaN\n}\n\n/**\n * ISO-8601 UTC, because a track outlives the timezone it was recorded in.\n *\n * A timestamp outside the Date range throws out of `toISOString`, and one bad\n * point would abort the export of every track in the file. It is dated to the\n * start of time instead, which is what `fromGpx` does with a time it cannot\n * read -- losing one point's time beats losing the document.\n */\nfunction iso(timestamp: number): string {\n const at = new Date(timestamp)\n return Number.isNaN(at.getTime()) ? new Date(0).toISOString() : at.toISOString()\n}\n\n/**\n * XML-escape a value for element content.\n *\n * Characters XML cannot express are dropped rather than escaped, because they\n * have no entity form: emitting one produces a document no conformant reader\n * will load, and losing the character beats losing the file.\n */\nfunction escapeXml(value: string): string {\n return (\n [...value]\n // Dropped rather than escaped: a NUL or a lone surrogate has no entity\n // form, so emitting one produces a document no conformant reader will\n // load -- losing the character beats losing the file.\n .filter((character) => isXmlChar(character.codePointAt(0) ?? -1))\n .join('')\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&apos;')\n )\n}\n\n/**\n * Whether a code point is one XML 1.0 permits in content.\n *\n * The range has holes: NUL, most C0 controls and the surrogate block are all\n * excluded. Decoding `&#0;` or `&#xD800;` puts a character into a track name\n * that cannot be written back out as well-formed XML, so the reference is\n * kept verbatim instead -- the same treatment as one that names no character\n * at all.\n */\nfunction isXmlChar(value: number): boolean {\n if (!Number.isInteger(value)) {\n return false\n }\n return (\n value === 0x9 ||\n value === 0xa ||\n value === 0xd ||\n (value >= 0x20 && value <= 0xd7ff) ||\n (value >= 0xe000 && value <= 0xfffd) ||\n (value >= 0x10000 && value <= 0x10ffff)\n )\n}\n\n/**\n * An attribute value as an XSD decimal, or NaN when it is not one.\n *\n * GPX types lat and lon as `xsd:decimal`, which is digits with an optional\n * sign and point -- no exponent, no hexadecimal. `Number()` is far more\n * generous: it reads `0x10` as 16 and `1e1` as 10, so a file using either\n * imported a position it never expressed.\n */\nfunction decimal(value: string | undefined): number {\n return value !== undefined && /^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)$/.test(value) ? Number(value) : Number.NaN\n}\n\n/**\n * Whether a position is one GPX can express.\n *\n * Latitude beyond ±90 or longitude beyond ±180 is not a place. Such a value\n * reaching the store stretches every bounding box the track appears in, and\n * writing one produces a document a validating reader rejects — so it is\n * refused in both directions rather than trusted from a file or passed on.\n */\nfunction inRange(latitude: number, longitude: number): boolean {\n return (\n Number.isFinite(latitude) &&\n Number.isFinite(longitude) &&\n latitude >= -90 &&\n latitude <= 90 &&\n longitude >= -180 &&\n longitude <= 180\n )\n}\n","import { BehaviorSubject, combineLatest, connectable, firstValueFrom, ReplaySubject, Subject } from 'rxjs'\nimport type { Connectable, Observable } from 'rxjs'\nimport { map, scan, startWith, throttleTime } from 'rxjs/operators'\nimport { thin } from './timeWindow.js'\nimport type { TrackQuery } from './timeWindow.js'\nimport type { TrackStore } from './store.js'\nimport type {\n Context,\n Debug,\n LatLngTuple,\n TimedPosition,\n TimedTrackCollection,\n TimeWindow,\n TrackCollection,\n TrackParams,\n} from './types.js'\nimport { createMatcher } from './utils.js'\n\ninterface TracksMap {\n [context: string]: TrackAccumulator\n}\n\ninterface VesselTrack {\n context: string\n track: LatLngTuple[]\n}\n\nexport interface TracksConfig {\n resolution: number\n pointsToKeep: number\n fetchInitialTrack?: boolean\n}\n\n/**\n * The in-memory store: a bounded sliding window of positions per context, held\n * in RxJS accumulators and lost on restart.\n *\n * No longer used by the plugin, which always records to SQLite. It remains as\n * the client-side `TrackAccumulator` this package exports, where a browser\n * holding one vessel's recent track in memory is exactly the right shape.\n */\nexport class Tracks implements TrackStore {\n tracks: TracksMap = {}\n debug: Debug\n config: TracksConfig\n constructor(config: TracksConfig, debug: Debug) {\n if (debug.enabled) {\n debug(JSON.stringify(config))\n }\n this.config = config\n this.debug = debug\n }\n\n newPosition(context: Context, position: LatLngTuple, timestamp?: number): void {\n this.getAccumulator(context)?.nextLatLngTuple(position, timestamp)\n }\n\n initialTrack(context: Context, track: LatLngTuple[], timestamps?: number[]): void {\n this.getAccumulator(context)?.setInitialTrack(track, timestamps)\n }\n\n getAccumulator(context: Context, createIfMissing = true): TrackAccumulator | undefined {\n if (context.indexOf('vessels.') === -1 && context.indexOf('aircraft.') === -1) {\n return undefined\n }\n let result = this.tracks[context]\n if (!result && createIfMissing) {\n const accParams: AccumulatorParams = this.config.fetchInitialTrack\n ? { ...this.config, fetchTrackFor: context }\n : { ...this.config }\n result = this.tracks[context] = new TrackAccumulator(accParams)\n }\n return result\n }\n\n get(context: Context, window?: TimeWindow): Promise<LatLngTuple[]> {\n return this.getTimed(context, window).then((points) => points.map(({ position }) => position))\n }\n\n /**\n * Track points with their timestamps, optionally narrowed to a time window.\n *\n * Rejects with EmptyError if the stream completes without emitting; the route\n * handlers turn that into the same 404 as an unknown context.\n */\n getTimed(context: Context, window?: TimeWindow): Promise<TimedPosition[]> {\n const accumulator = this.getAccumulator(context, false)\n if (!accumulator) {\n return Promise.reject(new Error(`No track accumulator for ${context}`))\n }\n return firstValueFrom(accumulator.timedTrack).then((points) =>\n window\n ? points.filter(\n ({ timestamp }) =>\n timestamp >= window.from && (window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to),\n )\n : points,\n )\n }\n\n getAllTracks(query?: TrackQuery): Promise<VesselTrack[]> {\n return Promise.all(\n Object.keys(this.tracks).map((context) =>\n this.getTimed(context, query?.window).then((points) => ({\n context,\n track: thin(points, query?.resolution).map(({ position }) => position),\n })),\n ),\n )\n }\n\n // Return all / filtered vessels and their tracks\n async getFilteredTracks(\n params: TrackParams,\n selfPosition?: LatLngTuple,\n debug?: Debug,\n query?: TrackQuery,\n ): Promise<TrackCollection> {\n const timed = await this.getFilteredTimedTracks(params, selfPosition, debug, query)\n return Object.fromEntries(\n Object.entries(timed).map(([context, points]) => [context, points.map(({ position }) => position)]),\n )\n }\n\n /**\n * The same filtering as `getFilteredTracks`, keeping each point's timestamp.\n *\n * The untimed form is derived from this one so the two cannot disagree about\n * which tracks match — the filter runs once, here.\n */\n async getFilteredTimedTracks(\n params: TrackParams,\n selfPosition?: LatLngTuple,\n debug?: Debug,\n query?: TrackQuery,\n ): Promise<TimedTrackCollection> {\n this.debug(params)\n this.debug('Self position', selfPosition)\n const matcher = createMatcher(params, selfPosition, debug)\n\n const requested = params.contexts ? new Set(params.contexts) : undefined\n const contexts = Object.keys(this.tracks).filter((context) => !requested || requested.has(context))\n const tracks = await Promise.all(\n contexts.map((context) =>\n this.getTimed(context, query?.window).then((points) => ({\n context,\n points: thin(points, query?.resolution),\n })),\n ),\n )\n return tracks.reduce<TimedTrackCollection>((acc, { context, points }) => {\n // The matcher tests the last *position*, so it sees the same geometry it\n // always did; only what is carried alongside changed.\n if (matcher(points.map(({ position }) => position))) {\n acc[context] = points\n }\n return acc\n }, {})\n }\n\n /**\n * Remembered names, by context.\n *\n * In memory only: this store is the client-side accumulator, so there is no\n * restart for a stored name to survive. It implements the same contract so\n * the two stores stay interchangeable and the plugin needs no special case.\n */\n private readonly names = new Map<Context, { name: string; timestamp: number }>()\n\n recordName(context: Context, name: string, timestamp: number = Date.now()): void {\n const trimmed = name.trim()\n if (trimmed === '') {\n return\n }\n const seen = this.names.get(context)\n if (seen && seen.timestamp > timestamp) {\n return\n }\n this.names.set(context, { name: trimmed, timestamp })\n }\n\n nameFor(context: Context): string | undefined {\n return this.names.get(context)?.name\n }\n\n prune(maxAge: number, keep?: Context): void {\n const cutoff = Date.now() - maxAge\n const deleted: string[] = []\n Object.entries(this.tracks).forEach(([key, value]) => {\n if (key !== keep && value.latestLatLngTuple < cutoff) {\n delete this.tracks[key]\n // The remembered name goes with the track. Left behind it would both\n // grow unboundedly and, worse, hand a returning context its stale\n // label back -- the very thing resolving live first avoids.\n this.names.delete(key)\n deleted.push(key)\n }\n })\n if (this.debug.enabled) {\n this.debug(`deleted tracks for ${deleted.join(', ')}`)\n }\n }\n}\n\ninterface AccumulatorParams {\n resolution: number\n pointsToKeep: number\n fetchTrackFor?: string\n}\n\nexport class TrackAccumulator {\n initialTrack: Subject<TimedPosition[]> = new BehaviorSubject<TimedPosition[]>([])\n input: Subject<TimedPosition> = new Subject()\n latestLatLngTuple = 0\n accumulatedTrack: Connectable<TimedPosition[]>\n /** Timestamped points, used to answer time-window queries. */\n timedTrack: Observable<TimedPosition[]>\n /** Positions only — the long-standing public shape. */\n track: Observable<LatLngTuple[]>\n\n constructor({ resolution, pointsToKeep, fetchTrackFor }: AccumulatorParams) {\n // rxjs 7 replaces the publishReplay operator + ConnectableObservable cast\n // with an explicit connectable(); the ReplaySubject(1) connector preserves\n // the replay-latest behaviour a late subscriber depends on.\n this.accumulatedTrack = connectable(\n this.input.pipe(\n throttleTime(resolution),\n scan<TimedPosition, TimedPosition[]>((acc, point) => {\n acc.push(point)\n return acc.slice(Math.max(0, acc.length - pointsToKeep))\n }, []),\n // combineLatest below only emits once every source has, and `scan` stays\n // silent until the first live position. Without this an accumulator that\n // has only been given an initial track, before any live delta, never\n // yields a readable track.\n startWith<TimedPosition[]>([]),\n ),\n { connector: () => new ReplaySubject<TimedPosition[]>(1), resetOnDisconnect: false },\n )\n this.timedTrack = combineLatest([this.initialTrack, this.accumulatedTrack]).pipe(\n map(([initialTrack, accumulatedTrack]) => [...initialTrack, ...accumulatedTrack]),\n )\n this.track = this.timedTrack.pipe(map((points) => points.map(({ position }) => position)))\n this.accumulatedTrack.connect()\n\n if (fetchTrackFor) {\n void fetchTrack(fetchTrackFor).then((trackGEOJson) => {\n const coordinates = trackGEOJson?.coordinates?.[0]\n if (coordinates) {\n // The fetched track carries no timestamps; date them at the start of\n // time so a time-window query treats them as older than anything live.\n this.initialTrack.next(coordinates.map((position) => ({ position, timestamp: 0 })))\n }\n })\n }\n }\n\n nextLatLngTuple(position: LatLngTuple, timestamp: number = Date.now()): void {\n this.input.next({ position, timestamp })\n this.latestLatLngTuple = timestamp\n }\n\n setInitialTrack(track: LatLngTuple[], timestamps?: number[]): void {\n this.initialTrack.next(track.map((position, i) => ({ position, timestamp: timestamps?.[i] ?? 0 })))\n }\n}\n\ninterface TrackGeoJson {\n coordinates?: LatLngTuple[][]\n}\n\n// Browser-side helper for the exported TrackAccumulator: only reachable when a\n// webapp constructs one with fetchInitialTrack, never on the server.\nconst fetchTrack = (context: Context): Promise<TrackGeoJson> => {\n const contextParts = context.split('.')\n if (contextParts[0] !== 'vessels') {\n return Promise.resolve({})\n }\n return fetch(`/signalk/v1/api/vessels/${contextParts[1]}/track`, {\n credentials: 'include',\n }).then((r) => (r.status === 200 ? (r.json() as Promise<TrackGeoJson>) : Promise.resolve({})))\n}\n","/*\n * Copyright 2021 Teppo Kurki <teppo.kurki@iki.fi>\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Temporal } from '@js-temporal/polyfill'\nimport type { Request, RequestHandler, Response, Router } from 'express'\nimport { join } from 'node:path'\nimport { createTrackProvider } from './trackProvider.js'\nimport type { TrackApi } from './trackApi.js'\nimport { AsyncTrackStore } from './asyncTrackStore.js'\nimport type { TrackStore } from './store.js'\nimport { DEFAULT_MAX_SPEED_KNOTS, GlitchFilter } from './glitchFilter.js'\nimport { reconcile } from './reconcile.js'\nimport { SourceWatch } from './sourceWatch.js'\nimport { DEFAULT_PAUSE_STATES, PAUSABLE_STATES, StateGate } from './stateGate.js'\nimport { toGpx } from './gpx.js'\nimport { parseTrackQuery, segment, thin, TimeWindowError } from './timeWindow.js'\nimport type { TrackQuery } from './timeWindow.js'\nimport type {\n Context,\n Debug,\n LatLngTuple,\n LngLatTuple,\n Position,\n TimedPosition,\n TimeWindow,\n TimedTrackCollection,\n TrackCollection,\n TrackParams,\n} from './types.js'\nimport {\n historyRowPosition,\n resolveContext,\n toIsoTimes,\n validateParameters,\n trackLabel,\n contextName,\n gpxFilename,\n asciiFilename,\n rfc8187,\n unwrapString,\n QueryParameterError,\n SelfPositionUnavailableError,\n} from './utils.js'\n\nexport interface ContextPosition {\n context: Context\n value: Position\n /** ISO-8601 time the value was recorded, as carried by the Signal K delta. */\n timestamp?: string\n /**\n * Which source produced the value.\n *\n * The bus is priority-filtered, so normally this is the one source the\n * server selected. Seeing several for one context means no priority rule is\n * matching the path — which is what `SourceWatch` reports on.\n */\n $source?: string\n}\n\ninterface AllTracksResult {\n [context: string]: {\n type: 'MultiLineString'\n coordinates: LngLatTuple[][]\n /** Present only when `?times` was asked for; aligned with `coordinates`. */\n times?: string[][]\n /**\n * Whether this is the own vessel's track.\n *\n * Stated rather than left to the client: telling own vessel from an AIS\n * target otherwise means string-matching the context against the server's\n * self identity, which a client can only do if it has fetched that\n * separately and knows the `vessels.self` alias resolves to a `urn:mrn:`\n * context. Getting it wrong draws someone else's track as your own.\n */\n isSelf: boolean\n /**\n * A human label for the track, as a chart plotter would show it.\n *\n * `Own Ship` or `AIS <shipname>`, falling back to the MMSI. A client\n * listing tracks otherwise has to render a `urn:mrn:` context, and no\n * user recognises their own boat that way.\n */\n name: string\n }\n}\n\n// Minimal History API types (from @signalk/server-api)\n// Defined locally to avoid a hard dependency on a specific server-api version\ninterface HistoryValuesQuery {\n context: string\n from: Temporal.Instant\n to: Temporal.Instant\n pathSpecs: { path: string; aggregate: string }[]\n resolution: number\n}\n\ninterface HistoryApi {\n getValues(query: HistoryValuesQuery): Promise<HistoryValuesResponse>\n /**\n * Contexts the provider holds data for, within a time range.\n *\n * Optional here though the upstream interface requires it: a provider\n * registered against an older server-api may not implement it, and a missing\n * method must degrade rather than throw.\n */\n getContexts?(query: HistoryContextsQuery): Promise<unknown[]>\n}\n\n/** `ContextsRequest` upstream: a time range, with Instants rather than strings. */\ninterface HistoryContextsQuery {\n from: Temporal.Instant\n to: Temporal.Instant\n}\n\ninterface HistoryValuesResponse {\n context: string\n range: { from: string; to: string }\n values: unknown[]\n /** Each element: [timestamp_string, [lon, lat]] */\n data: unknown[]\n}\n\ninterface App {\n debug: Debug\n error: (...args: unknown[]) => void\n streambundle: {\n getBus: (path: string) => {\n onValue: (cb: (x: ContextPosition) => void) => () => void\n }\n }\n getSelfPath: (path: string) => unknown\n /**\n * Read any path in the full data model, including other vessels'.\n *\n * `getSelfPath` only reaches the own vessel, so naming an AIS track needs\n * this. Optional: absent on older servers, where tracks fall back to the\n * MMSI parsed out of the context.\n */\n getPath?: (path: string) => unknown\n selfContext: string\n /** Shown against the plugin in the server's dashboard. Absent on older servers. */\n setPluginStatus?: (msg: string) => void\n /** Plugin-private directory for persistent data; absent on older servers. */\n getDataDirPath?: () => string\n /** Resolves the named provider, or the configured default when omitted. */\n getHistoryApi?: (providerId?: string) => Promise<HistoryApi>\n /**\n * Offer this plugin's tracks to the v2 Track API. Absent on servers older\n * than SignalK/signalk-server#2995.\n *\n * The server unregisters the provider itself when the plugin stops, so\n * `stop()` here must not do it a second time.\n */\n registerTrackApiProvider?: (provider: TrackApi) => void\n config?: {\n settings?: {\n historyApi?: { defaultProvider?: string }\n }\n }\n}\n\ninterface Plugin {\n start: (c: TracksPluginConfig) => void | Promise<void>\n /** Enable on install, without waiting for a visit to the plugin config page. */\n enabledByDefault: boolean\n stop: () => Promise<void>\n signalKApiRoutes: (r: Router) => Router\n id: string\n name: string\n description: string\n schema: Record<string, unknown>\n /**\n * The live accumulator, or undefined before `start()`.\n *\n * Exposed so a track can be installed or inspected without going through the\n * position bus, which throttles against the wall clock. The Signal K server\n * does not use this.\n */\n getTracks: () => TrackStore | undefined\n}\n\ninterface TracksPluginConfig {\n resolution?: number\n /** Days another vessel is kept after its last fix. 0 keeps every vessel. */\n aisRetentionDays?: number\n /** Minutes without a fix that start a new track segment. 0 disables. */\n segmentGapMinutes?: number\n /** Speed above which a position is treated as a glitch. 0 disables. */\n maxSpeedKnots?: number\n /** navigation.state values that pause recording of the own vessel. */\n pauseWhenState?: string[]\n}\n\nconst toLngLat = ([lat, lng]: LatLngTuple): LngLatTuple => [lng, lat]\n\nconst DEFAULT_RESOLUTION = 60000\n// How long a vessel other than the own one is kept after its last fix. The own\n// vessel is never dropped — see TrackStore.prune. A month is long enough that a\n// passage last week is still there to compare against, and short enough that a\n// season in a busy harbour does not accumulate every target that ever passed.\nconst DEFAULT_AIS_RETENTION_DAYS = 30\n\n// A vessel that has aged out is not urgent, so this need not be frequent.\nconst PRUNE_INTERVAL_MS = 60 * 60 * 1000\n// Off by default. Segmenting changes the shape of every response, and measured\n// against real AIS traffic a 5-minute rule split 61 of 879 gaps that were just\n// a slow-updating target rather than a stop. Opt in, and pick a threshold that\n// suits the fleet being watched.\nconst DEFAULT_SEGMENT_GAP_MINUTES = 0\n\n// Long enough that a boat with one GPS never pays for the check in practice,\n// short enough that the warning appears while the user is still looking at the\n// plugin page after enabling it.\nconst SOURCE_STATUS_INTERVAL_MS = 30000\n\n/**\n * How long a history provider gets to answer a query before the store answers\n * alone.\n *\n * The provider is an enrichment, not a dependency: a track is still correct\n * without it, just coarser. Long enough for a database that has to warm up,\n * short enough that a wedged provider does not hold a request open.\n */\nconst HISTORY_QUERY_TIMEOUT_MS = 5000\n\n/**\n * How far back a query with no window of its own reaches when the store is\n * empty.\n *\n * There is nothing else to derive a span from, and asking a provider for all\n * of time would be expensive on a large one. A day covers the usual reason for\n * a windowless request — draw the recent trail — and an explicit `from`,\n * `duration` or `timespan` reaches further.\n */\nconst WINDOWLESS_HISTORY_SPAN_MS = 24 * 60 * 60 * 1000\n\n/**\n * The earliest instant the existence probe asks about.\n *\n * The probe has to name a bound: the History API's time range has no\n * unbounded form, every branch of `TimeRangeParams` carries one. A provider\n * filters its context list by the range it is given — questdb builds a SQL\n * `WHERE` from it — so any bound later than the provider's oldest row can\n * still miss a vessel and 404 it, which is the case the probe exists to catch.\n *\n * The Unix epoch is the bound rather than some span of years: it predates\n * satellite navigation, so no position fix can lie before it, and unlike a\n * fixed \"wide enough\" window it cannot be outlived. The cost lands only on the\n * branch that was already about to 404, and the answer is cached.\n */\nconst EXISTENCE_PROBE_FROM_MS = 0\n\n/**\n * How long a provider's context list is reused before asking again.\n *\n * The probe runs only for a vessel about to 404, which is precisely the\n * request a client can repeat without limit — the routes are open, so\n * enumerating vessel ids would otherwise drive one epoch-wide `getContexts`\n * per request, each able to block for the timeout.\n *\n * The whole list is cached rather than a per-context answer, or enumeration\n * would simply fill the cache with distinct keys and query just as often. The\n * cost is that a vessel a provider learns about becomes visible up to this\n * long after the fact, which is not a delay anyone can perceive in a track\n * that is already minutes old.\n */\nconst KNOWN_CONTEXTS_TTL_MS = 30_000\n\n/**\n * How long a *failed* context query is remembered.\n *\n * Short, so a provider that recovers is noticed quickly, but not zero: with\n * the entry simply dropped, a provider that hangs is asked again by every\n * subsequent miss while the earlier calls are still pending — reinstating per\n * request exactly the cost the cache exists to prevent.\n */\nconst KNOWN_CONTEXTS_FAILURE_TTL_MS = 1_000\n\n/**\n * Config values arrive from the plugin UI as numbers, but a hand-edited\n * settings file can supply strings. Accept both, reject anything non-finite so\n * a bad value falls back to the default instead of poisoning arithmetic with NaN.\n */\nconst toNumber = (value: unknown): number | undefined => {\n if (typeof value === 'number') {\n return Number.isFinite(value) ? value : undefined\n }\n if (typeof value === 'string' && value.trim() !== '') {\n const parsed = Number(value)\n return Number.isFinite(parsed) ? parsed : undefined\n }\n return undefined\n}\n\nconst notAvailable = (res: Response) => {\n res.status(404)\n res.json({ message: 'Tracks API not available because tracks plugin is not enabled' })\n}\n\n/**\n * The history provider could not answer, and nothing else could either.\n *\n * Distinct from a missing track: a 404 asserts that no source knows the\n * vessel, which a failed provider read cannot support. Reporting the failure\n * as an empty track would present an outage as \"this vessel has no positions\n * here\" — the one answer that is certainly wrong.\n */\nclass HistoryUnavailableError extends Error {}\n\nconst errorDetail = (err: unknown): string => (err instanceof Error && err.stack ? err.stack : String(err))\n\n/**\n * A window covering everything the store holds, up to now.\n *\n * Used when a query names no window of its own, so a history provider can\n * still be asked something concrete. Undefined when the store is empty, since\n * there is then no span to ask about.\n */\nfunction windowSpanning(stored: TimedPosition[], fallbackMs: number): TimeWindow {\n const to = Date.now()\n if (stored.length === 0) {\n // Nothing stored says nothing about what a provider holds: a vessel\n // recorded only by the provider — before this plugin was installed, say —\n // would otherwise never be asked about and 404.\n return { from: to - fallbackMs, to, inclusiveEnd: true }\n }\n let from = stored[0]!.timestamp\n for (const point of stored) {\n if (point.timestamp < from) from = point.timestamp\n }\n return { from, to, inclusiveEnd: true }\n}\n\n/**\n * Reject once `ms` has passed.\n *\n * Written out rather than `Promise.race` so the timer is cleared when the\n * promise wins. A race leaves it armed, holding the event loop open for the\n * remainder of the timeout on every call that succeeds.\n */\n/**\n * A bound expired. Distinct from the error a provider itself raises, because\n * \"did not answer in time\" and \"is not installed\" are different answers and\n * only one of them is an outage.\n */\nclass HistoryTimeoutError extends Error {}\n\nfunction withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new HistoryTimeoutError(`timed out after ${ms}ms`)), ms)\n promise.then(\n (value) => {\n clearTimeout(timer)\n resolve(value)\n },\n (err: unknown) => {\n clearTimeout(timer)\n reject(err instanceof Error ? err : new Error(String(err)))\n },\n )\n })\n}\n\n/**\n * A cache entry holding the promise rather than its value, so callers that\n * arrive while a query is still running join it instead of starting another.\n */\ninterface KnownContexts {\n at: number\n contexts: Promise<ReadonlySet<string>>\n}\n\n/** A provider's context list as a set, with the `vessels.self` spelling resolved. */\nconst toContextSet = (listed: unknown[] | undefined, selfContext: string): ReadonlySet<string> =>\n new Set(\n (listed ?? [])\n .filter((c): c is string => typeof c === 'string')\n .map((c) => (c === 'vessels.self' ? selfContext : c)),\n )\n\n/**\n * Whether a history provider holds anything at all for a context.\n *\n * Asked only when a track is about to 404: `getValues` narrowed to a window\n * cannot tell \"this vessel is unknown\" from \"this vessel has nothing *here*\",\n * and 404ing on the second is wrong — the contract is that a 404 means neither\n * source knows the vessel. A vessel whose history lies wholly outside the\n * asked window is known, and deserves an empty track.\n *\n * Best-effort, like every other provider call here: a provider that lacks the\n * method, errors, or hangs leaves the 404 standing rather than holding the\n * request open.\n */\nasync function historyKnowsContext(\n app: App,\n context: Context,\n debug: Debug,\n cache: { current: KnownContexts | undefined },\n): Promise<boolean> {\n const getHistoryApi = app.getHistoryApi\n if (!getHistoryApi) {\n return false\n }\n try {\n const now = Date.now()\n // Reused while fresh, and shared while in flight: a burst of misses is one\n // question to the provider, not one each. The promise is cached rather than\n // its result so concurrent callers join the same query instead of starting\n // their own.\n //\n // Resolving the provider is inside the cached promise, not before it: a\n // slow or failing `getHistoryApi` would otherwise be paid per request, and\n // could time out into a 404 for a vessel the cached list already knows.\n const cached = cache.current\n if (cached === undefined || now - cached.at >= KNOWN_CONTEXTS_TTL_MS) {\n const contexts = withTimeout(\n getHistoryApi(app.config?.settings?.historyApi?.defaultProvider)\n .then((historyApi) =>\n historyApi.getContexts\n ? historyApi.getContexts({\n from: Temporal.Instant.from(new Date(EXISTENCE_PROBE_FROM_MS).toISOString()),\n to: Temporal.Instant.from(new Date(now).toISOString()),\n })\n : // A provider built against an older server-api has no such\n // method. Nothing is known, and that is a cacheable answer.\n [],\n )\n // Indexed once per fetch rather than scanned per request: the branch\n // that consults this is the one an open route lets a client repeat\n // without limit, and a provider on a busy install lists thousands of\n // vessels. The `vessels.self` spelling at least one provider returns\n // is resolved here, while `app.selfContext` is a single fixed value,\n // so the lookup itself is a plain membership test.\n .then((listed) => toContextSet(listed, app.selfContext)),\n HISTORY_QUERY_TIMEOUT_MS,\n )\n // Dropped on failure so the next miss retries rather than inheriting a\n // rejection for the rest of the window. The catch is attached here, not\n // awaited, so a rejection never escapes as an unhandled one.\n const entry: KnownContexts = { at: now, contexts }\n contexts.catch(() => {\n if (cache.current === entry) {\n // Back-dated rather than dropped: the next miss after the failure\n // window retries, while a burst inside it does not re-ask.\n cache.current = { at: now - KNOWN_CONTEXTS_TTL_MS + KNOWN_CONTEXTS_FAILURE_TTL_MS, contexts }\n }\n })\n cache.current = entry\n }\n return (await cache.current!.contexts).has(context)\n } catch (err) {\n debug(`History contexts unavailable for ${context}: ${errorDetail(err)}`)\n return false\n }\n}\n\n/**\n * Contexts a history provider holds data for within a window, or none.\n *\n * What lets the v2 provider return a vessel the store has never seen, or match\n * a box on positions only history recorded: the store is often far younger\n * than the provider. Best-effort like every other provider call: no provider,\n * a provider without `getContexts`, an error or a hang all answer with no\n * contexts, and the store answers alone.\n *\n * A window with no start is asked from the epoch, for the same reason the\n * existence probe is; without any window the probe's cached all-time list is\n * reused rather than asking again per query.\n */\nasync function historyContextsIn(\n app: App,\n window: TimeWindow | undefined,\n debug: Debug,\n cache: { current: KnownContexts | undefined },\n): Promise<ReadonlySet<string>> {\n const getHistoryApi = app.getHistoryApi\n if (!getHistoryApi) {\n return new Set()\n }\n try {\n if (!window) {\n await historyKnowsContext(app, app.selfContext, debug, cache)\n return (await cache.current?.contexts) ?? new Set()\n }\n const historyApi = await withTimeout(\n getHistoryApi(app.config?.settings?.historyApi?.defaultProvider),\n HISTORY_QUERY_TIMEOUT_MS,\n )\n if (!historyApi.getContexts) {\n return new Set()\n }\n const listed = await withTimeout(\n historyApi.getContexts({\n from: Temporal.Instant.from(new Date(Math.max(window.from, EXISTENCE_PROBE_FROM_MS)).toISOString()),\n to: Temporal.Instant.from(new Date(window.to).toISOString()),\n }),\n HISTORY_QUERY_TIMEOUT_MS,\n )\n return toContextSet(listed, app.selfContext)\n } catch (err) {\n if (debug.enabled) {\n debug(`History contexts unavailable: ${errorDetail(err)}`)\n }\n return new Set()\n }\n}\n\n/**\n * Positions a history provider holds for a window, or none.\n *\n * Best-effort by design: a provider that is absent, slow, or failing must not\n * fail the query, because the plugin's own store can always answer it. The\n * provider is the finer source where it reaches, not a required one.\n */\nasync function historyPositions(\n app: App,\n context: Context,\n window: TimeWindow,\n resolutionMs: number,\n debug: Debug,\n): Promise<{ points: TimedPosition[]; resolutionMs: number; failed: boolean }> {\n // The API takes whole seconds, so the width the provider actually bucketed\n // by is not necessarily the one asked for. Reconciling on the requested\n // width would leave a stored point in a bucket history already covered, and\n // keep both.\n const providerSeconds = Math.max(1, Math.round(resolutionMs / 1000))\n const applied = providerSeconds * 1000\n const getHistoryApi = app.getHistoryApi\n if (!getHistoryApi) {\n // Not a failure: no provider installed is the documented normal case, and\n // the store answers alone. Only a provider that was asked and could not\n // answer counts as one.\n return { points: [], resolutionMs: applied, failed: false }\n }\n // Resolved in its own step, because failing to reach a provider and failing\n // to read one are different answers. The server rejects this call outright\n // when no provider is registered — the default install — and a service that\n // is not installed cannot be having an outage. Only a read that fails after\n // a provider was obtained is one.\n let historyApi: HistoryApi\n try {\n // Bounded because both awaits reach third-party code. Without this a\n // provider that never settles holds the request open, and the store\n // fallback below is never reached — which would make the \"best-effort\"\n // this function promises untrue.\n historyApi = await withTimeout(\n getHistoryApi(app.config?.settings?.historyApi?.defaultProvider),\n HISTORY_QUERY_TIMEOUT_MS,\n )\n } catch (err) {\n // A provider that was registered but did not answer in time is an outage;\n // only the server's own rejection means there is nothing installed to ask.\n const timedOut = err instanceof HistoryTimeoutError\n if (debug.enabled) {\n debug(`${timedOut ? 'History provider timed out' : 'No history provider'} for ${context}: ${errorDetail(err)}`)\n }\n return { points: [], resolutionMs: applied, failed: timedOut }\n }\n try {\n const response = await withTimeout(\n historyApi.getValues({\n context,\n // Instants, not ISO strings: providers call Instant methods on these.\n // A window with only an end starts at minus infinity, which no Date\n // can hold.\n from: Temporal.Instant.from(new Date(Math.max(window.from, EXISTENCE_PROBE_FROM_MS)).toISOString()),\n to: Temporal.Instant.from(new Date(window.to).toISOString()),\n pathSpecs: [{ path: 'navigation.position', aggregate: 'first' }],\n resolution: providerSeconds,\n }),\n HISTORY_QUERY_TIMEOUT_MS,\n )\n const points: TimedPosition[] = []\n for (const row of response?.data ?? []) {\n const position = historyRowPosition(row)\n // A row with no position means the provider had none for that bucket,\n // which is what lets the store fill it.\n if (position) {\n const timestamp = historyRowTimestamp(row)\n // Clipped to the window rather than trusted: a provider that returns a\n // wider range would otherwise widen the answer beyond what was asked.\n // Matching the stores, which treat a window without `inclusiveEnd`\n // as half-open so consecutive bands tile without repeating the point\n // they share. Clipping inclusively here would reintroduce exactly\n // that duplicate from the provider side.\n const withinEnd = window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to\n if (timestamp >= window.from && withinEnd) {\n // A bucket, not a fix: its width keeps segment() from reading the\n // spacing between buckets as a stop in the recording.\n points.push({ position, timestamp, span: applied })\n }\n }\n }\n if (debug.enabled) {\n debug(`History supplied ${points.length} position(s) for ${context}`)\n }\n return { points, resolutionMs: applied, failed: false }\n } catch (err) {\n if (debug.enabled) {\n debug(`History unavailable for ${context}: ${errorDetail(err)}`)\n }\n // Reported rather than swallowed. The store still answers where it can —\n // a provider is an enrichment, not a dependency — but a caller with\n // nothing else to serve has to be able to tell \"no positions\" from \"could\n // not ask\".\n return { points: [], resolutionMs: applied, failed: true }\n }\n}\n\n/** Epoch milliseconds for a history row, or 0 when the timestamp is unusable. */\nconst historyRowTimestamp = (row: unknown): number => {\n const raw: unknown = Array.isArray(row) ? row[0] : undefined\n if (typeof raw !== 'string') {\n return 0\n }\n const parsed = Date.parse(raw)\n return Number.isNaN(parsed) ? 0 : parsed\n}\n\nexport default function ThePlugin(app: App): Plugin {\n /**\n * The server's own lookup, falling back to what the store remembers.\n *\n * Live wins whenever the data model has an answer, which keeps the rule the\n * resolvers were written for: an AIS static report arriving late must be\n * able to correct or replace what was known. The stored value only covers\n * the window where the model knows nothing -- after a restart, or for a\n * vessel that has since sailed out of range.\n */\n const remembering =\n (store: () => TrackStore | undefined) =>\n (path: string): unknown => {\n const live = app.getPath?.(path)\n if (live !== undefined) {\n return live\n }\n const match = /^(.*)\\.name$/.exec(path)\n return match ? store()?.nameFor(match[1] as Context) : undefined\n }\n\n let onStop: (() => void)[] = []\n let tracks: TrackStore | undefined = undefined\n let segmentGap = 0\n /**\n * How often the store keeps a position, in ms.\n *\n * Used as the bucket width when reconciling with a history provider, so the\n * two sources land on the same grid when a query names no resolution of its\n * own.\n */\n let storageError: string | undefined\n let storeResolution = DEFAULT_RESOLUTION\n const sourceWatch = new SourceWatch()\n let glitchFilter = new GlitchFilter({ maxSpeedKnots: DEFAULT_MAX_SPEED_KNOTS })\n let stateGate = new StateGate(app.selfContext, DEFAULT_PAUSE_STATES)\n /**\n * The history provider's context list, cached between existence probes.\n *\n * Per plugin instance rather than module-global: two instances would\n * otherwise answer from each other's provider.\n */\n const knownContexts: { current: KnownContexts | undefined } = { current: undefined }\n\n /** The own vessel's navigation.state, or undefined when it reports none. */\n function getVesselState(): string | undefined {\n const s = app.getSelfPath('navigation.state')\n if (s && typeof s === 'object' && 'value' in s) {\n const { value } = s as { value?: unknown }\n return typeof value === 'string' ? value : undefined\n }\n return undefined\n }\n\n function getVesselPosition(): LatLngTuple | undefined {\n const p = app.getSelfPath('navigation.position')\n if (p && typeof p === 'object' && 'value' in p) {\n const { value } = p as { value?: Partial<Position> }\n if (typeof value?.latitude === 'number' && typeof value.longitude === 'number') {\n return [value.latitude, value.longitude]\n }\n }\n return undefined\n }\n\n return {\n start: function (config: TracksPluginConfig) {\n storageError = undefined\n const { resolution } = config\n storeResolution = toNumber(config.resolution) ?? DEFAULT_RESOLUTION\n const segmentGapMinutes = toNumber(config.segmentGapMinutes) ?? DEFAULT_SEGMENT_GAP_MINUTES\n segmentGap = segmentGapMinutes > 0 ? segmentGapMinutes * 60 * 1000 : 0\n\n // Rebuilt on every start so a changed ceiling takes effect, and so the\n // reference positions do not survive a restart the user made precisely\n // because the track looked wrong.\n glitchFilter = new GlitchFilter({\n maxSpeedKnots: toNumber(config.maxSpeedKnots) ?? DEFAULT_MAX_SPEED_KNOTS,\n })\n stateGate = new StateGate(\n app.selfContext,\n Array.isArray(config.pauseWhenState) ? config.pauseWhenState : DEFAULT_PAUSE_STATES,\n )\n\n // getDataDirPath is what makes the file the server's to manage: backed up\n // and removed with the plugin. Every server that can load this plugin\n // provides it, so there is no in-memory fallback — a track recorder that\n // forgets everything on restart is not what anyone installs.\n const dataDir = app.getDataDirPath?.()\n if (!dataDir) {\n app.error('This server provides no plugin data directory, so tracks cannot be recorded.')\n return\n }\n\n // Always a fresh store: stop() has closed the previous handle, and a\n // restart must not keep one built with a resolution since changed.\n //\n // Guarded because opening a database touches the filesystem: a read-only\n // or full data directory throws here, and an uncaught throw out of\n // start() takes down more than this plugin.\n let starting: AsyncTrackStore\n try {\n starting = new AsyncTrackStore(\n {\n file: join(dataDir, 'tracks.db'),\n resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,\n // The own vessel's track is never aged out: it is the one worth\n // keeping, and a year of it at the default resolution is a few\n // megabytes. Other vessels are pruned by aisRetentionDays.\n retention: 0,\n segmentGap,\n },\n app.debug,\n (error) => {\n if (tracks !== starting) return\n storageError = `Could not use track database: ${errorDetail(error)}`\n app.error(storageError)\n app.setPluginStatus?.(storageError)\n },\n {},\n (message) => {\n if (tracks !== starting) return\n storageError = undefined\n app.error(message) // Keep a visible record of the recording gap.\n app.setPluginStatus?.(stateGate.status() ?? sourceWatch.warning(app.selfContext) ?? 'Recording tracks')\n },\n )\n tracks = starting\n } catch (err) {\n app.error(`Could not open the track database in ${dataDir}: ${errorDetail(err)}`)\n return\n }\n onStop.push(\n app.streambundle.getBus('navigation.position').onValue((update: ContextPosition): void => {\n if (!update.value || update.value.latitude == null || update.value.longitude == null) return\n sourceWatch.add(update.context, update.$source)\n // Captured here rather than by subscribing to `name`: the data model\n // already holds it by the time a position arrives, and a vessel with\n // no positions is not a track worth naming.\n //\n // Deliberately reads the model directly instead of going through\n // contextName, which now falls back to this very store -- routing\n // capture through it would re-date a months-old stored name as a\n // fresh observation on every position, defeating the ordering.\n //\n // Unwrapped the same way the resolvers read this field: the model\n // carries a bare string for some sources and the `{value}` wrapper\n // for others. Accepting only one shape here would let a name render\n // live and then vanish on restart, having never been captured.\n const live = unwrapString(app.getPath?.(`${update.context}.name`))\n if (live !== undefined) {\n tracks?.recordName(update.context, live)\n }\n // Prefer the delta's own timestamp so a replayed or delayed update is\n // filed at the time it was recorded, not the time it arrived.\n const recorded = update.timestamp === undefined ? undefined : Date.parse(update.timestamp)\n const timestamp = recorded !== undefined && !Number.isNaN(recorded) ? recorded : undefined\n const position: LatLngTuple = [update.value.latitude, update.value.longitude]\n // Read per delta rather than subscribed to: navigation.state changes\n // rarely, and the current value is what matters at the moment a\n // position arrives.\n if (!stateGate.accept(update.context, getVesselState())) {\n return\n }\n // Filtered here rather than in each store: both would otherwise need\n // the same check, and a rejected fix should reach neither.\n if (!glitchFilter.accept(update.context, position, timestamp ?? Date.now())) {\n return\n }\n tracks?.newPosition(update.context, position, timestamp)\n }),\n )\n // Days rather than the seconds the old maxAge used: this is about how long\n // a passing vessel stays interesting, not about clearing a live display.\n // 0 keeps every vessel forever.\n const aisRetentionMs = (toNumber(config.aisRetentionDays) ?? DEFAULT_AIS_RETENTION_DAYS) * 24 * 60 * 60 * 1000\n // prune() does two things: drop whole contexts that have gone quiet, and\n // apply the own vessel's row-level retention. They are configured\n // separately, so it runs whenever either is set — gating the call on the\n // AIS setting alone meant `aisRetentionDays: 0` silently disabled the\n // own vessel's retention too. A maxAge of Infinity ages nothing out.\n const pruneInterval = setInterval(() => {\n tracks?.prune(aisRetentionMs > 0 ? aisRetentionMs : Infinity, app.selfContext)\n }, PRUNE_INTERVAL_MS)\n onStop.push(() => {\n clearInterval(pruneInterval)\n })\n\n // Report on a timer rather than per delta: at 10Hz across several\n // vessels that would rewrite the status thousands of times a minute, and\n // the first fix from a second source is not yet evidence of a problem —\n // a source that appears once and stops is not worth a warning.\n // Reported rather than left to go stale: without the else branch the last\n // \"not recording\" message stayed on the dashboard after the vessel got\n // under way, which is worse than saying nothing at all.\n let lastStatus: string | undefined\n const statusInterval = setInterval(() => {\n // Being paused is the more useful thing to report: a user who has\n // opted in wants to see it is working, and one who has not wonders why\n // nothing is recording.\n const status = stateGate.status() ?? sourceWatch.warning(app.selfContext) ?? 'Recording tracks'\n if (status !== lastStatus) {\n lastStatus = status\n app.setPluginStatus?.(storageError ?? status)\n }\n }, SOURCE_STATUS_INTERVAL_MS)\n onStop.push(() => {\n clearInterval(statusInterval)\n })\n\n // Offer the accumulated tracks to the v2 Track API.\n //\n // The provider reads `tracks` through a getter rather than capturing it,\n // because start() replaces the store wholesale and a captured reference\n // would keep serving the store a restart was meant to discard.\n //\n // Deliberately not unregistered in stop(): the server pushes its own\n // unregister onto the plugin's stop handlers when this is called, so\n // doing it here as well would unregister twice.\n app.registerTrackApiProvider?.(\n createTrackProvider({\n store: () => tracks,\n selfContext: () => app.selfContext,\n segmentGap: () => segmentGap,\n contextName: (context: string) =>\n contextName(\n context,\n remembering(() => tracks),\n ),\n // The same reconciliation the v1 routes have done since #73. Without\n // it the plugin answers differently depending on which route a client\n // uses: the store alone through v2, the store enriched by a history\n // provider through v1.\n historyContexts: (window) => historyContextsIn(app, window, app.debug, knownContexts),\n reconcileWithHistory: async (context, stored, window, resolutionMs) => {\n const effective = resolutionMs ?? storeResolution\n // A query with no window still gets history, for the same reason\n // the v1 route does: a context recorded only by the provider —\n // before this plugin was installed — would otherwise never be asked\n // about.\n const asked = window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS)\n const history = await historyPositions(app, context, asked, effective, app.debug)\n // `failed` is deliberately not escalated here: the v2 contract has\n // no per-context error and no 404, so a provider outage degrades to\n // the store's own points rather than failing a multi-context query\n // for every other vessel in it.\n return history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored\n },\n }),\n )\n return starting.ready.catch(async () => {\n // A failed start must release the worker before callers remove the data directory.\n await starting.close().catch(() => {})\n if (tracks === starting) tracks = undefined\n })\n },\n\n stop: async function () {\n onStop.forEach((f) => {\n try {\n f()\n } catch (err) {\n app.error(err)\n }\n })\n onStop = []\n // Forget which sources were seen: the point of a restart is often to\n // apply a source priority change, and carrying the old observations over\n // would keep warning about a setup that has just been fixed.\n sourceWatch.clear()\n glitchFilter.clear()\n stateGate.clear()\n // A restart often follows a provider change; keeping the old list would\n // answer from a provider that is no longer the one installed.\n knownContexts.current = undefined\n // Release the file handle a sqlite store holds, so a plugin restart does\n // not leak it and the WAL gets checkpointed.\n //\n // A stopped plugin serves nothing: closing the database releases the file\n // handle and checkpoints the WAL, so the routes answer 404 until the next\n // start() builds a fresh store. They stay mounted and degrade rather than\n // throw, because the server calls stop() on a config save.\n const closing = tracks\n tracks = undefined\n try {\n await closing?.close?.()\n } catch (err) {\n app.error(err)\n }\n },\n\n signalKApiRoutes: function (router: Router) {\n // Resolved per request rather than cached: an AIS target's static report\n // can arrive long after its first position, so a track named from the\n // MMSI early on picks up the real name as soon as the server has it.\n const nameOf = (context: string) =>\n trackLabel(\n context,\n app.selfContext,\n remembering(() => tracks),\n )\n\n /**\n * The points of one context, reconciled with history and segmented.\n *\n * Shared by the JSON and GPX routes so the two cannot answer the same\n * question differently: the history reconciliation, the windowless\n * fallback and the 404-only-for-an-unknown-vessel rule are subtle enough\n * that a second copy would drift.\n *\n * Rejects with `undefined` when neither source knows the vessel at all.\n */\n const readSegments = async (context: string, query: TrackQuery): Promise<TimedPosition[][] | undefined> => {\n const effectiveResolution = query.resolution ?? storeResolution\n const { points: stored, known } = await tracks!\n // Resolving instead of rejecting for an unknown context: a vessel\n // the store has never seen may still be in a history provider —\n // recorded before this plugin was installed, say — and 404ing\n // before asking would hide data that exists.\n .getTimed(context, query.window)\n .then((points) => ({ points, known: true }))\n .catch(() => ({ points: [] as TimedPosition[], known: false }))\n // A history provider is the finer source for as long as its retention\n // reaches; the store is what remains of everything older, and of any\n // period the provider missed. A query with no window still gets\n // history: `/self/track` with no parameters is the common case, and\n // skipping the provider there would quietly serve store-only data.\n const window = query.window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS)\n const history = await historyPositions(app, context, window, effectiveResolution, app.debug)\n const points = history.points.length\n ? reconcile(history.points, stored, history.resolutionMs).positions\n : stored\n // 404 only for a vessel neither source knows at all. A known vessel\n // with nothing inside the window is an empty track, not a missing one.\n //\n // The provider is asked a second time here, and only here: `getValues`\n // narrowed to a window cannot distinguish an unknown vessel from one\n // whose history lies outside it. That second question is deliberately\n // not scoped to the window, for the same reason.\n if (points.length === 0) {\n // Either source knowing the vessel is enough: the store may hold it\n // with nothing inside the asked window, which is an empty track\n // rather than a missing one.\n const vesselKnown = known || (await historyKnowsContext(app, context, app.debug, knownContexts))\n if (!vesselKnown) {\n return undefined\n }\n // Known, but there is nothing to serve and the one source that might\n // have had something could not be read. Answering 200 here would\n // report an outage as an empty history.\n if (history.failed) {\n throw new HistoryUnavailableError(`History provider could not be read for ${context}`)\n }\n }\n return segment(thin(points, query.resolution), segmentGap)\n }\n\n const singleTrackHandler =\n (contextOf: (req: Request) => string): RequestHandler =>\n (req: Request, res: Response) => {\n if (!tracks) {\n notAvailable(res)\n return\n }\n const context = contextOf(req)\n let query: TrackQuery\n try {\n query = parseTrackQuery(req.query)\n } catch (err) {\n res.status(400)\n res.json({ message: err instanceof TimeWindowError ? err.message : 'Invalid query parameters' })\n return\n }\n readSegments(context, query)\n .then((segments) => {\n if (segments === undefined) {\n res.status(404)\n res.json({ message: `No track available for ${context}` })\n return\n }\n res.json({\n type: 'MultiLineString',\n coordinates: segments.map((points) => points.map(({ position }) => toLngLat(position))),\n ...(query.times ? { times: segments.map(toIsoTimes) } : {}),\n context,\n isSelf: context === app.selfContext,\n name: nameOf(context),\n })\n })\n .catch((err: unknown) => {\n // A provider outage is not a missing vessel. Reporting it as 404\n // told clients the track does not exist, which is the one thing\n // a failed read cannot establish.\n if (err instanceof HistoryUnavailableError) {\n app.error(`${err.message}`)\n res.status(503)\n res.json({ message: `Track history is temporarily unavailable for ${context}` })\n return\n }\n res.status(404)\n res.json({ message: `No track available for ${context}` })\n })\n }\n\n /**\n * One vessel's track as a GPX file.\n *\n * Serialised by `toGpx` rather than assembled here, so the export a user\n * downloads is the same document the module produces everywhere else.\n * The webapp cannot reach that module — it ships as static files with no\n * bundler — which is why the conversion lives behind a route instead of\n * being duplicated in the page.\n */\n const gpxHandler =\n (contextOf: (req: Request) => string): RequestHandler =>\n (req: Request, res: Response) => {\n if (!tracks) {\n notAvailable(res)\n return\n }\n const context = contextOf(req)\n let query: TrackQuery\n try {\n query = parseTrackQuery(req.query)\n } catch (err) {\n res.status(400)\n res.json({ message: err instanceof TimeWindowError ? err.message : 'Invalid query parameters' })\n return\n }\n readSegments(context, query)\n .then((segments) => {\n if (segments === undefined) {\n res.status(404)\n res.json({ message: `No track available for ${context}` })\n return\n }\n const label = nameOf(context)\n const filename = gpxFilename(label)\n res.type('application/gpx+xml')\n // Two forms, per RFC 5987. A header carries bytes, not text, so\n // `setHeader` throws ERR_INVALID_CHAR on a name outside Latin-1\n // -- which this route's catch would have reported as a 404, and\n // a name inside it would arrive mojibaked instead. The quoted\n // form is ASCII for readers that understand nothing else; the\n // `filename*` form carries the real name.\n res.setHeader(\n 'Content-Disposition',\n `attachment; filename=\"${asciiFilename(filename)}\"; filename*=UTF-8''${rfc8187(filename)}`,\n )\n res.send(toGpx([{ name: label, context, segments }]))\n })\n .catch((err: unknown) => {\n // Not a 404: `readSegments` already reported an unknown vessel by\n // resolving undefined, so anything reaching here is this route\n // failing -- serialisation, a header, a write. Reporting that as\n // \"no track available\" is what disguised an ERR_INVALID_CHAR\n // from a non-Latin-1 filename as a missing track.\n if (err instanceof HistoryUnavailableError) {\n app.error(`${err.message}`)\n if (!res.headersSent) {\n res.status(503)\n res.json({ message: `Track history is temporarily unavailable for ${context}` })\n }\n return\n }\n app.error(`Could not export GPX for ${context}: ${errorDetail(err)}`)\n if (!res.headersSent) {\n res.status(500)\n res.json({ message: `Could not export the track for ${context}` })\n }\n })\n }\n\n const trackHandler = singleTrackHandler((req) => resolveContext(String(req.params.vesselId), app.selfContext))\n router.get('/vessels/:vesselId/track', trackHandler)\n router.get(\n '/vessels/:vesselId/track.gpx',\n gpxHandler((req) => resolveContext(String(req.params.vesselId), app.selfContext)),\n )\n router.get(\n '/self/track.gpx',\n gpxHandler(() => app.selfContext),\n )\n\n // Freeboard-SK requests the own vessel's trail here rather than at\n // /vessels/self/track. Plugin routes are mounted before the v1 REST\n // interface, so this takes precedence over the data-tree walker.\n router.get(\n '/self/track',\n singleTrackHandler(() => app.selfContext),\n )\n\n // return all / filtered vessel tracks\n /** Only the collection route filters spatially; see AGENTS.md on v1 vs v2. */\n const SPATIAL_PARAMS = ['bbox', 'radius'] as const\n\n const allTracksHandler: RequestHandler = (req: Request, res: Response) => {\n app.debug(req.query)\n if (!tracks) {\n notAvailable(res)\n return\n }\n let query: TrackQuery\n let params: TrackParams\n try {\n query = parseTrackQuery(req.query, Date.now(), SPATIAL_PARAMS)\n // Inside the try on purpose: a malformed bbox used to reach the\n // matcher, throw there, and be caught by the result handler's\n // `.catch()`, which answers 404 -- reporting a bad query as an empty\n // one. Validating here lets it surface as the 400 it is.\n params = validateParameters(req.query, undefined)\n } catch (err) {\n res.status(400)\n res.json({\n message:\n err instanceof TimeWindowError || err instanceof QueryParameterError\n ? err.message\n : 'Invalid query parameters',\n })\n return\n }\n const selfPosition = getVesselPosition()\n\n // Two paths on purpose. Without `times` the response keeps its\n // long-standing shape exactly — one un-segmented line per vessel — so\n // existing clients are unaffected. With `times` the track is segmented\n // like the single-vessel route, because a times array can only line up\n // with coordinates if both are split the same way.\n const result = query.times\n ? tracks.getFilteredTimedTracks(params, selfPosition, app.debug, query).then((tc: TimedTrackCollection) =>\n Object.entries(tc).reduce<AllTracksResult>((acc, [context, points]) => {\n const segments = segment(points, segmentGap)\n acc[context] = {\n type: 'MultiLineString',\n coordinates: segments.map((s) => s.map(({ position }) => toLngLat(position))),\n times: segments.map(toIsoTimes),\n isSelf: context === app.selfContext,\n name: nameOf(context),\n }\n return acc\n }, {}),\n )\n : tracks.getFilteredTracks(params, selfPosition, app.debug, query).then((tc: TrackCollection) =>\n Object.entries(tc).reduce<AllTracksResult>((acc, [context, track]) => {\n acc[context] = {\n type: 'MultiLineString',\n coordinates: [track.map(toLngLat)],\n isSelf: context === app.selfContext,\n name: nameOf(context),\n }\n return acc\n }, {}),\n )\n\n result\n .then((trks) => {\n res.json(trks)\n })\n .catch((err: unknown) => {\n // A radius query the server cannot measure is not a missing track.\n // Reporting it as 404 said no vessel matched, which is the one\n // thing an unanswerable query cannot establish.\n if (err instanceof SelfPositionUnavailableError) {\n res.status(503)\n res.json({ message: 'No position for the own vessel, so radius cannot be measured' })\n return\n }\n res.status(404)\n res.json({ message: `No track available for vessels.` })\n })\n }\n router.get('/tracks', allTracksHandler)\n // Express 4 path syntax: the Signal K server mounts plugin routers on\n // express 4, where a bare `*` is the wildcard (express 5 renamed it).\n router.get('/tracks/*', allTracksHandler)\n\n return router\n },\n\n getTracks: () => tracks,\n\n id: 'tracks',\n name: 'Tracks',\n // On by default: a track recorder that records nothing until someone finds\n // and enables it loses exactly the passage the user wanted kept.\n enabledByDefault: true,\n description: 'Record vessel tracks to SQLite and serve them through the track API',\n schema: {\n type: 'object',\n properties: {\n resolution: {\n type: 'integer',\n minimum: 0,\n title: 'Track resolution (milliseconds)',\n default: DEFAULT_RESOLUTION,\n },\n aisRetentionDays: {\n type: 'integer',\n minimum: 0,\n title: 'Days to keep another vessel after its last fix',\n description:\n 'The own vessel is never removed. Other vessels are: a busy harbour puts hundreds of AIS targets past the receiver in a day, and keeping all of them forever is rarely what anyone wants. 0 keeps every vessel indefinitely.',\n default: DEFAULT_AIS_RETENTION_DAYS,\n },\n segmentGapMinutes: {\n type: 'integer',\n title: 'Split a track after this many minutes without a fix',\n description:\n 'A gap longer than this starts a new track segment, so a stop overnight or a spell out of AIS range does not draw a straight line across it. 0 (the default) returns the track as a single line, as before. Note that slow-updating AIS targets can legitimately go many minutes between fixes, so a low value will fragment their tracks.',\n default: DEFAULT_SEGMENT_GAP_MINUTES,\n },\n maxSpeedKnots: {\n type: 'integer',\n title: 'Discard positions implying a speed above this (knots)',\n description:\n 'A receiver occasionally reports a position far from the vessel. On a live map it flickers past; in a stored track it is permanent, stretching the bounding box and drawing a line across the chart. A fix that would require travelling faster than this since the previous one is discarded. The default is well above any real vessel, and glitches miss it by orders of magnitude. 0 disables the check.',\n default: DEFAULT_MAX_SPEED_KNOTS,\n },\n pauseWhenState: {\n type: 'array',\n title: 'Pause recording while navigation.state is one of',\n description:\n \"Stops recording the own vessel while it is not going anywhere, so a winter on a mooring costs no rows. Needs navigation.state to be set, by signalk-autostate or by hand. Leave empty (the default) to always record. Note that 'anchored' is offered but rarely wanted: an anchor alarm watches exactly the track a vessel makes while swinging on its rode. AIS targets are never gated, since their status comes from the transponder and is often stale.\",\n items: {\n type: 'string',\n enum: [...PAUSABLE_STATES],\n },\n uniqueItems: true,\n default: DEFAULT_PAUSE_STATES,\n },\n },\n },\n }\n}\n\nexport { fromGpx, toGpx } from './gpx.js'\nexport type { GpxTrack, GpxTrackIdentity } from './gpx.js'\nexport { Tracks, TrackAccumulator } from './tracks.js'\nexport type { TracksConfig } from './tracks.js'\nexport type { TrackStore } from './store.js'\nexport type * from './types.js'\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":["LAT","LNG","toLngLat"],"sources":["../src/simplify.ts","../src/trackProvider.ts","../src/asyncTrackStore.ts","../src/glitchFilter.ts","../src/reconcile.ts","../src/sourceWatch.ts","../src/stateGate.ts","../src/gpx.ts","../src/tracks.ts","../src/index.ts"],"sourcesContent":["import type { TimedPosition } from './types.js'\n\nconst LAT = 0\nconst LNG = 1\n\n/**\n * Metres per degree of latitude. Good to ~0.5% over the WGS84 ellipsoid.\n *\n * Exported because the provider derives its automatic tolerance from the same\n * figure: \"one part in a thousand of the diagonal\" only holds while both use\n * one metres-per-degree value.\n */\nexport const M_PER_DEG = 111_320\n\n/**\n * Span, in scaled degrees, beyond which the flat projection is not trusted.\n *\n * Ten degrees is ~1,100 km, where the projection still agrees with a spherical\n * cross-track calculation to 0.1%. No leg between two recorded positions comes\n * close; a segment that does is a data artefact rather than a passage.\n */\nconst SPAN_LIMIT = 10\n\n/**\n * Douglas-Peucker simplification of a recorded track.\n *\n * Distinct from `thin()`, and the difference is the whole point: thinning\n * decimates by *time*, so it keeps a point every N seconds whether the boat was\n * turning or holding a course, and a corner falling between two samples is\n * simply lost. Simplification decimates by *shape* — it keeps the points that\n * carry the geometry and drops the ones a straight line already describes.\n *\n * That is what makes this the piece a route conversion needs. 5,550 points at\n * 1.2 s spacing is a recording, not a route; thinning it to 30 points gives 30\n * evenly spaced positions that miss every turn, while simplifying it to 30\n * gives the turns and nothing else.\n *\n * `epsilon` is a tolerance in **metres**: no point of the original track is\n * further than this from the simplified line.\n */\nexport function simplify(points: TimedPosition[], epsilon: number): TimedPosition[] {\n if (!(epsilon > 0) || points.length <= 2) {\n return points\n }\n const keep = new Uint8Array(points.length)\n keep[0] = 1\n keep[points.length - 1] = 1\n // Iterative rather than recursive: a long track recursing per segment can\n // exhaust the stack, and a track is exactly the kind of input that arrives\n // with tens of thousands of points.\n const stack: [number, number][] = [[0, points.length - 1]]\n while (stack.length > 0) {\n const [first, last] = stack.pop()!\n if (last <= first + 1) {\n continue\n }\n let worst = 0\n let worstAt = -1\n for (let i = first + 1; i < last; i++) {\n const d = perpendicularDistance(points[i]!, points[first]!, points[last]!)\n if (d > worst) {\n worst = d\n worstAt = i\n }\n }\n if (worstAt >= 0 && worst > epsilon) {\n keep[worstAt] = 1\n stack.push([first, worstAt], [worstAt, last])\n }\n }\n return points.filter((_, i) => keep[i] === 1)\n}\n\n/**\n * Distance in metres from `p` to the segment `a`-`b`.\n *\n * Positions are projected to a local plane with longitude scaled by\n * cos(latitude) at the segment's midpoint. Measured against a spherical\n * cross-track reference the error is 0.1% for segments spanning up to ten\n * degrees of latitude — some 1,100 km, longer than any leg between two\n * recorded fixes — which is far below the metre-scale tolerances this runs\n * with, and it avoids trigonometry per point per recursion level (2.9 ms for\n * 5,550 points, and `simplifyToBudget` calls this ~40 times).\n *\n * One flat scale cannot hold across a segment spanning most of a hemisphere:\n * an 80N-to-equator segment reads 85 km where the truth is 111 km. Such a\n * segment means two consecutive fixes an ocean and a continent apart, which\n * recorded data does not contain — but the tolerance is documented in metres,\n * so `SPAN_LIMIT` refuses to answer rather than answer wrongly, in either\n * axis.\n */\nfunction perpendicularDistance(p: TimedPosition, a: TimedPosition, b: TimedPosition): number {\n // Scale longitudes at the latitude midway along the segment, so a track near\n // the poles is not simplified as though a degree of longitude were 111 km\n // wide. Taking the *midpoint* rather than the start matters once a segment\n // spans latitudes: scaling an 80N-to-equator segment at 80N shrinks every\n // longitude by cos(80) and reports a point 111 km off the line as 19 km.\n const cos = Math.cos((((a.position[LAT] + b.position[LAT]) / 2) * Math.PI) / 180)\n const originLng = a.position[LNG]\n // Beyond this the flat projection stops being trustworthy, so the point is\n // kept rather than measured: keeping a point that could have gone costs a\n // little geometry, dropping one that should have stayed loses the shape.\n //\n // Both spans are checked, longitude after scaling, so the limit means the\n // same ground distance either way. Near the equator that scale is ~1, and\n // two fixes a quarter of the globe apart would otherwise be measured on the\n // flat plane and read as far closer than they are.\n if (\n Math.abs(a.position[LAT] - b.position[LAT]) > SPAN_LIMIT ||\n Math.abs(nearestLongitude(b.position[LNG], originLng) - originLng) * cos > SPAN_LIMIT\n ) {\n return Infinity\n }\n const ax = originLng * cos\n const ay = a.position[LAT]\n const bx = nearestLongitude(b.position[LNG], originLng) * cos\n const by = b.position[LAT]\n const px = nearestLongitude(p.position[LNG], originLng) * cos\n const py = p.position[LAT]\n\n const dx = bx - ax\n const dy = by - ay\n let t = 0\n const lenSq = dx * dx + dy * dy\n if (lenSq > 0) {\n // Clamped, so a point beyond either end measures to the endpoint rather\n // than to the infinite line — otherwise a track that doubles back on\n // itself reports a distance far smaller than the real deviation.\n t = Math.min(1, Math.max(0, ((px - ax) * dx + (py - ay) * dy) / lenSq))\n }\n const cx = ax + t * dx\n const cy = ay + t * dy\n return Math.hypot(px - cx, py - cy) * M_PER_DEG\n}\n\n/**\n * The smallest epsilon that simplifies `points` to at most `target` of them.\n *\n * Epsilon is meaningful in the physical world but not in the thing a user\n * cares about: \"accurate to 10 m\" gives 8 waypoints across an open bay and 400\n * through an archipelago, so a client asking for a usable route cannot pick a\n * number in advance. A point budget is predictable; the tolerance that\n * achieved it is what gets reported back.\n *\n *\n * Not reachable through the v2 API, which takes a tolerance rather than a\n * budget. It is here for the track-management webapp's \"convert to route\",\n * where the user asks for a usable number of waypoints and is told the\n * accuracy that produced them.\n */\nexport function simplifyToBudget(\n points: TimedPosition[],\n target: number,\n): { points: TimedPosition[]; epsilon: number } {\n // Two endpoints always survive, so a smaller budget is unmeetable: the\n // search would grow its bound to the 1e9 ceiling and return the endpoints\n // anyway, having reported an absurd tolerance for doing so.\n const budget = Math.max(2, target)\n if (points.length <= budget) {\n return { points, epsilon: 0 }\n }\n const found = searchTolerance(points, budget)\n // A budget the tolerance cannot reach: some segment spans more than the\n // projection is trusted across, so no epsilon drops another point. Search\n // again for the smallest tolerance yielding what was actually achievable,\n // so the reported epsilon still reproduces the returned track -- the\n // ceiling the first search stopped at would claim a billion metres was\n // applied, and zero would name a tolerance returning the original.\n return found.points.length > budget ? searchTolerance(points, found.points.length) : found\n}\n\n/**\n * The smallest tolerance whose result is within `bound` points.\n *\n * Shared by both callers so the invariant that makes a reported epsilon\n * reproducible -- the tolerance returned is one that yields exactly the points\n * returned -- lives in one place.\n *\n * Binary search rather than an exact solve: Douglas-Peucker is monotonic in\n * epsilon, so this converges, and it is bounded by iteration count rather than\n * by reaching an exact hit that may not exist.\n */\nfunction searchTolerance(points: TimedPosition[], bound: number): { points: TimedPosition[]; epsilon: number } {\n let lo = 0\n let hi = 1\n // Grow the upper bound until it is loose enough. A track can span an ocean,\n // so a fixed ceiling would either fail on large tracks or waste iterations\n // on small ones.\n let best = simplify(points, hi)\n while (best.length > bound && hi < 1e9) {\n hi *= 4\n best = simplify(points, hi)\n }\n let bestEpsilon = hi\n for (let i = 0; i < 40 && hi - lo > 0.01; i++) {\n const mid = (lo + hi) / 2\n const candidate = simplify(points, mid)\n if (candidate.length > bound) {\n lo = mid\n } else {\n hi = mid\n best = candidate\n bestEpsilon = mid\n }\n }\n return { points: best, epsilon: bestEpsilon }\n}\n\n/**\n * `lng` shifted by whole turns to sit within 180 degrees of `origin`.\n *\n * A track crossing the antimeridian is stored as 179.9 then -179.9, a tenth of\n * a degree apart on the ground but 359.8 apart numerically. Without this, a\n * straight line across it reads as a segment spanning almost the whole globe,\n * and points that lie exactly on that line measure kilometres away from it —\n * so simplification keeps every one of them.\n */\nfunction nearestLongitude(lng: number, origin: number): number {\n return lng - 360 * Math.round((lng - origin) / 360)\n}\n","import { Temporal } from '@js-temporal/polyfill'\nimport { M_PER_DEG, simplify } from './simplify.js'\nimport { segment, thin, thinToBudget } from './timeWindow.js'\nimport type { TrackStore } from './store.js'\nimport type { TrackApi, TrackBoundingBox, TrackFeature, TracksRequest, TracksResponse } from './trackApi.js'\nimport type { GeoBounds, LatLngTuple, TimedPosition, TimeWindow } from './types.js'\nimport { clipToBounds, createInBounds, toIsoTimes } from './utils.js'\n\n/**\n * Serves the v2 Track API from this plugin's store.\n *\n * The store answers the hard parts — spatial filtering, time windows, thinning\n * — so what is left here is mostly translation: v2's GeoJSON coordinate order\n * and Temporal values in, GeoJSON Features out. Segmentation is done here,\n * by `segment()`, because it is what turns a thinned track into the\n * MultiLineString the wire format wants.\n *\n * Registered alongside the v1 routes rather than replacing them, so\n * Freeboard-SK keeps working until it moves.\n */\n\nexport interface TrackProviderDeps {\n store: () => TrackStore | undefined\n selfContext: () => string\n /** Gap in ms that starts a new segment; 0 leaves the track as one line. */\n segmentGap: () => number\n /**\n * The vessel's name for `contextName`, or undefined when unknown.\n *\n * Injected like the rest: resolving it needs the server's data model, which\n * the provider has no handle on. Called per query rather than cached,\n * because an AIS target's static report routinely lands well after its\n * first position.\n */\n contextName: (context: string) => string | undefined\n /**\n * Positions a history provider holds for a context and window, reconciled\n * with the store's own.\n *\n * Injected rather than reached for directly: the reconciliation belongs to\n * the plugin, and the v1 routes have done it since #73 — a query answered\n * through the v2 provider has to give the same answer as the same query\n * answered through v1, or the plugin contradicts itself depending on which\n * route a client happens to use.\n *\n * Resolves to the store's own points unchanged when no provider is\n * installed, which is the common case.\n */\n /**\n * Contexts a history provider holds data for within the window, empty when\n * there is no provider.\n *\n * The store can be far younger than the provider, so it alone cannot say\n * which vessels exist or which passed through a box: a vessel recorded\n * only before this plugin was installed is known to history alone.\n */\n historyContexts: (window: TimeWindow | undefined) => Promise<ReadonlySet<string>>\n reconcileWithHistory: (\n context: string,\n stored: TimedPosition[],\n window: TimeWindow | undefined,\n resolutionMs: number | undefined,\n ) => Promise<TimedPosition[]>\n}\n\n/** v2 sends `[west, south, east, north]`; a bounds here is `[lat, lng]` corners. */\nconst toGeoBounds = (bbox: TracksRequest['bbox']): GeoBounds | null => {\n if (!bbox) {\n return null\n }\n const [west, south, east, north] = bbox\n return { sw: [south, west], ne: [north, east] }\n}\n\n/**\n * The window a request describes, in epoch milliseconds.\n *\n * The server resolves `duration` into `from`/`to` before a provider sees it,\n * so `duration` is handled only as a fallback for a caller that reaches this\n * directly.\n */\nconst toTimeWindow = (query: TracksRequest): TimeWindow | undefined => {\n const to = query.to ? query.to.epochMilliseconds : undefined\n const from = query.from ? query.from.epochMilliseconds : undefined\n if (from === undefined && to === undefined && !query.duration) {\n return undefined\n }\n const end = to ?? Date.now()\n const start =\n from ??\n (query.duration\n ? Temporal.Instant.fromEpochMilliseconds(end).toZonedDateTimeISO('UTC').subtract(query.duration).toInstant()\n .epochMilliseconds\n : Number.NEGATIVE_INFINITY)\n // Half-open when the caller named an end, closed when it defaulted to now.\n // A client walking adjacent windows — [T0,T1) then [T1,T2) — would otherwise\n // get the point at exactly T1 in both and draw it twice. When `to` was not\n // given the window ends at now, has no neighbour to overlap, and excluding\n // the newest point would just hide the latest fix. The v1 routes resolve it\n // the same way; see parseTrackQuery.\n return { from: start, to: end, inclusiveEnd: to === undefined }\n}\n\n/**\n * A duration in milliseconds, including the calendar units.\n *\n * `total()` refuses weeks, months and years without a starting point, because\n * their length depends on where you start counting — and the API validates\n * `resolution` as any positive ISO 8601 duration, so `?resolution=P1W` reaches\n * here and would otherwise throw a RangeError out as a 500.\n *\n * Resolved against a fixed reference rather than the queried window: a\n * resolution is a spacing, not a position, so the same query has to thin to the\n * same grid wherever in the calendar it lands. Against the window instead, two\n * identical requests would thin differently depending on when they were asked.\n *\n * The reference makes `P1M` 31 days and `P1Y` 366 — January's length, and a\n * leap year. Which month it lands on is arbitrary either way; what matters is\n * that it is the *same* arbitrary choice every time, and at these scales a\n * day's difference in the spacing is not something a caller asking for\n * month-apart points is relying on.\n */\nconst CALENDAR_REFERENCE = Temporal.PlainDate.from('2000-01-01')\n\nconst totalMilliseconds = (duration: Temporal.Duration): number =>\n duration.total({ unit: 'milliseconds', relativeTo: CALENDAR_REFERENCE })\n\n/**\n * Milliseconds back to a Duration, for reporting the spacing applied.\n *\n * Exact, not rounded to something tidier. A budget-derived spacing lands on\n * values like 24470ms, and `PT24.47S` does read as false precision — but the\n * field says what was applied, and a client re-querying with a rounded `PT24S`\n * gets 51 points where the budget it asked for was 50. Reproducibility beats\n * tidiness.\n */\nconst msToDuration = (ms: number): Temporal.Duration =>\n // Split into whole milliseconds and a nanosecond remainder: `Duration.from`\n // rejects a fractional value in any unit, and a resolution below a\n // millisecond reaches here intact — the API accepts `PT0.0005S`, which is\n // 0.5ms. Rounding it away would report a spacing that was not applied.\n Temporal.Duration.from({\n milliseconds: Math.trunc(ms),\n nanoseconds: Math.round((ms - Math.trunc(ms)) * 1e6),\n }).round({\n largestUnit: 'hour',\n })\n\nconst toLngLat = ([lat, lng]: LatLngTuple): [number, number] => [lng, lat]\n\n/** Bounding box of the returned geometry, in GeoJSON order. */\nconst boundsOf = (points: TimedPosition[]): TracksRequest['bbox'] => {\n if (points.length === 0) {\n return undefined\n }\n let south = points[0]!.position[0]\n let north = south\n const longitudes: number[] = []\n for (const { position } of points) {\n const [lat, lng] = position\n if (lat < south) south = lat\n if (lat > north) north = lat\n longitudes.push(lng)\n }\n const [west, east] = longitudeSpan(longitudes)\n return [west, south, east, north]\n}\n\n/**\n * The narrower of the two longitude intervals covering these points.\n *\n * A plain min/max describes a track spanning the antimeridian as almost the\n * whole globe: two fixes at 179 and -179 are two degrees apart, but min/max\n * reports 358. RFC 7946 says a box crossing the antimeridian is written with\n * west greater than east, so the wrap-around interval is expressible — pick it\n * whenever it is the shorter of the two.\n *\n * The widest gap between adjacent longitudes is the part of the globe the track\n * does not cover, so the complement of that gap is the tightest interval that\n * does.\n */\nconst longitudeSpan = (longitudes: number[]): [number, number] => {\n const sorted = [...longitudes].sort((a, b) => a - b)\n const min = sorted[0]!\n const max = sorted[sorted.length - 1]!\n let widestGap = 360 - (max - min)\n let west = min\n let east = max\n for (let i = 1; i < sorted.length; i++) {\n const gap = sorted[i]! - sorted[i - 1]!\n if (gap > widestGap) {\n widestGap = gap\n west = sorted[i]!\n east = sorted[i - 1]!\n }\n }\n return [west, east]\n}\n\nexport function createTrackProvider(deps: TrackProviderDeps): TrackApi {\n const matching = async (\n query: TracksRequest,\n ): Promise<{ tracks: Map<string, TimedPosition[]>; resolution: number | undefined }> => {\n const store = deps.store()\n if (!store) {\n return { tracks: new Map(), resolution: undefined }\n }\n const window = toTimeWindow(query)\n const resolution = query.resolution ? totalMilliseconds(query.resolution) : undefined\n const wanted = query.contexts?.length\n ? [...new Set(query.contexts.map(resolveSelf(deps.selfContext())))]\n : undefined\n const bounds = toGeoBounds(query.bbox)\n // Without a box there is nothing to clip to, whatever `clip` says.\n const clipTo = query.clip === true ? bounds : null\n\n // Thinning is handed to the store rather than applied to the result: it is\n // the same `thin()` either way, and asking twice is both wasted work and a\n // second place for the two to disagree about what a resolution means.\n //\n // The bbox path goes through the store's spatial filter, which the sqlite\n // store answers from its cell index rather than by reading every track,\n // and the requested contexts go with it so the store reads only those.\n const collection = await store.getFilteredTimedTracks(\n // intersects: the v2 contract matches a track on any position within the\n // window, not on where the vessel ended up — \"a vessel that crossed the\n // box an hour ago and has since left still matches\". The v1 routes keep\n // the last-position rule, which is the right answer to their own\n // question.\n {\n bbox: bounds,\n radius: null,\n intersects: true,\n ...(wanted ? { contexts: wanted } : {}),\n ...(clipTo ? { clip: true } : {}),\n },\n undefined,\n undefined,\n {\n ...(window ? { window } : {}),\n // A track matched against a box is thinned after matching, below, so\n // thinning cannot drop the point inside it, and a clipped one keeps\n // the points that carry it to the box edge.\n ...(resolution === undefined || bounds ? {} : { resolution }),\n },\n )\n\n // Contexts the store did not return but history holds data for: a vessel\n // the store has never seen, or one whose crossing of the box only history\n // recorded. The store's own points, if any, still take part in the\n // reconciliation; whether such a context matches the box is decided on\n // the reconciled track, since the store could not decide it.\n const fromHistory = [...(await deps.historyContexts(window))].filter(\n (context) => !(context in collection) && (!wanted || wanted.includes(context)),\n )\n const inBounds = bounds ? createInBounds(bounds) : undefined\n const storedFor = async (context: string): Promise<TimedPosition[]> => {\n const points = await store.getTimed(context, window).catch(() => [])\n return bounds ? points : thin(points, resolution)\n }\n\n // A provider aggregates to one position per bucket of the resolution it is\n // asked for, so a box query asks at the recording resolution: at the\n // requested one, a crossing shorter than a bucket would be aggregated away\n // before the match could see it.\n const historyResolution = bounds ? undefined : resolution\n\n const result = new Map<string, TimedPosition[]>()\n // Reconciled per context rather than in one pass: a history provider is\n // asked per context, and the store's own points are what a provider-less\n // install returns unchanged.\n const reconciled = await Promise.all([\n ...Object.entries(collection).map(\n async ([context, stored]) =>\n [context, await deps.reconcileWithHistory(context, stored, window, historyResolution)] as const,\n ),\n ...fromHistory.map(\n async (context) =>\n [\n context,\n await deps.reconcileWithHistory(context, await storedFor(context), window, historyResolution),\n ] as const,\n ),\n ])\n for (const [context, reconciledPoints] of reconciled) {\n // History can replace the stored point that put a context in the box,\n // so the match is decided on the reconciled track for every context.\n if (inBounds && !reconciledPoints.some(({ position }) => inBounds(position))) {\n continue\n }\n // Clipped after reconciling, because history positions come for the\n // whole window and the box has to cut those too.\n const points = clipTo\n ? thin(clipToBounds(reconciledPoints, clipTo), resolution)\n : bounds\n ? thin(reconciledPoints, resolution)\n : reconciledPoints\n // Dropped here rather than in getTracks, so both entry points agree on\n // what matched. A store filters on the *last* position, so a context can\n // match spatially and still have no point inside the time window;\n // listing it while getTracks returns nothing for it sends a client to\n // fetch a track that is not there.\n if (points.length === 0) {\n continue\n }\n result.set(context, points)\n }\n return { tracks: result, resolution }\n }\n\n return {\n async getTracks(query: TracksRequest): Promise<TracksResponse> {\n const { tracks: matched, resolution: requested } = await matching(query)\n const gap = deps.segmentGap()\n const selfContext = deps.selfContext()\n const features: TrackFeature[] = []\n for (const [context, all] of matched) {\n // The budget is applied per track, after the store's own thinning: a\n // client that named both is asking for this spacing *and* no more than\n // this many points, and the wider of the two wins.\n const { points, resolution: appliedMs } =\n query.maxPoints === undefined\n ? { points: all, resolution: requested }\n : thinToBudget(all, query.maxPoints, requested)\n if (points.length === 0) {\n continue\n }\n // Simplification runs after thinning, not before: thinning is what a\n // client asked for explicitly, and simplifying first would spend the\n // shape budget on points that are about to be dropped anyway.\n //\n // `epsilon` implies `simplify`, per the spec. With `simplify` and no\n // epsilon the tolerance is sized to the query's box, or to the track\n // itself when there is none; see chooseEpsilon.\n //\n // Segmenting comes first and each segment is simplified on its own.\n // The simplifier has no notion of a time gap, so given the whole track\n // it can drop the points either side of one — two collinear legs\n // three hours apart reduce to a single line, and segmenting that\n // afterwards yields one-point segments, which are not drawable\n // geometry. Splitting first keeps every leg's own endpoints.\n const chosenEpsilon = chooseEpsilon(points, query)\n const segments = segment(points, gap).map((s) => (chosenEpsilon === undefined ? s : simplify(s, chosenEpsilon)))\n const shaped = segments.flat()\n const bbox = boundsOf(shaped)\n const name = deps.contextName(context)\n features.push({\n type: 'Feature',\n // geometry=false asks for the metadata only, so a client can list\n // what exists before paying for the coordinates.\n geometry:\n query.geometry === false\n ? null\n : {\n type: 'MultiLineString',\n coordinates: segments.map((s) => s.map(({ position }) => toLngLat(position))),\n },\n properties: {\n context,\n isSelf: context === selfContext,\n // Omitted rather than empty: the spec says \"where known\".\n ...(name === undefined ? {} : { contextName: name }),\n from: new Date(shaped[0]!.timestamp).toISOString(),\n to: new Date(shaped[shaped.length - 1]!.timestamp).toISOString(),\n ...(bbox ? { bbox } : {}),\n pointCount: shaped.length,\n // The spacing actually applied, which is not always the one asked\n // for: a maxPoints budget widens it. Reported so a client can tell\n // a thinned track from a full one, and see what produced it.\n ...(appliedMs === undefined ? {} : { resolution: msToDuration(appliedMs).toString() }),\n // The tolerance simplification ran with, absent when it did not\n // run at all. Note a tolerance can legitimately change nothing —\n // a track with no point further than epsilon from its own line is\n // already as simple as it gets — so this reports what was applied\n // rather than implying the geometry differs from the stored one.\n ...(chosenEpsilon === undefined ? {} : { epsilon: chosenEpsilon }),\n ...(query.times ? { coordTimes: segments.map(toIsoTimes) } : {}),\n },\n })\n }\n return { type: 'FeatureCollection', features }\n },\n\n async getTrackContexts(query: TracksRequest): Promise<string[]> {\n return [...(await matching(query)).tracks.keys()]\n },\n }\n}\n\n/**\n * The tolerance to simplify with, or undefined to leave the geometry alone.\n *\n * `epsilon` implies `simplify=true`, so an explicit tolerance is honoured\n * whether or not the flag came with it. `simplify` alone leaves the tolerance\n * to the provider, and the spec asks for one \"suited to the size of the box\":\n * with a `bbox` it is sized to that box, which for a chart viewer is its view,\n * so detail follows the zoom. With no box the choice is the provider's, and\n * the extent of the track *as thinning left it* stands in -- not the\n * simplified extent, since that is the thing being computed. A `bbox` without\n * `simplify` simplifies nothing.\n *\n * Chosen once per track rather than per segment, so every leg of one track is\n * simplified to the same tolerance and the single reported `epsilon` describes\n * all of them.\n *\n * A non-positive `epsilon` is not reachable through the API — the schema\n * declares `exclusiveMinimum: 0`, so the server rejects it — and is treated\n * here as no tolerance given, falling through to `simplify` if that was asked\n * for. Simplifying by zero would be a no-op reported as if it had done\n * something.\n */\nfunction chooseEpsilon(points: TimedPosition[], query: TracksRequest): number | undefined {\n const explicit = query.epsilon\n if (explicit !== undefined && explicit > 0) {\n return explicit\n }\n if (query.simplify !== true) {\n return undefined\n }\n if (query.bbox) {\n return extentEpsilon(query.bbox)\n }\n const bounds = boundsOf(points)\n return bounds ? extentEpsilon(bounds) : undefined\n}\n\n/**\n * How much of an extent -- the query box, or the track without one -- the\n * automatic tolerance may deviate by.\n *\n * One part in a thousand: across a view that is roughly one screen pixel.\n * Provisional while the v2 API is designed in SignalK/signalk-server#2504: it\n * is only a provider-chosen default for `simplify` without an explicit\n * epsilon, and a client that calibrates against the exact ratio rather than\n * reading the reported `epsilon` back would make it expensive to change.\n */\nconst AUTO_EPSILON_DIVISOR = 1000\n\n/**\n * Floor, in metres, under the automatic tolerance.\n *\n * Below the noise of any GPS fix, so it collapses the thousands of near\n * identical points a vessel records at anchor without altering a track that\n * actually moved.\n */\nconst MIN_AUTO_EPSILON = 1\n\n/**\n * A tolerance scaled to how much ground an extent covers.\n *\n * One part in a thousand of its diagonal. For a track that drops the jitter of\n * a boat holding station while keeping every turn a passage is made of; for a\n * view it keeps what a pixel can show. A fixed metre value cannot do either —\n * 10 m erases nothing on an ocean crossing and erases a marina approach\n * entirely.\n */\nfunction extentEpsilon([west, south, east, north]: TrackBoundingBox): number {\n const midLat = (south + north) / 2\n const height = (north - south) * M_PER_DEG\n // A box crossing the antimeridian is written west > east (RFC 7946), so a\n // plain subtraction turns a tenth of a degree into -359.8 and the derived\n // tolerance into something that would erase the whole track.\n const lngSpan = east >= west ? east - west : east + 360 - west\n const width = lngSpan * M_PER_DEG * Math.cos((midLat * Math.PI) / 180)\n const diagonal = Math.hypot(width, height)\n // A floor, not a fallback for a zero extent. A vessel swinging at anchor\n // covers tens of metres, so the proportional tolerance works out at\n // centimetres and drops nothing -- leaving the track most worth collapsing\n // untouched. One metre is below any GPS's own noise, so applying it as a\n // minimum cannot alter a track that actually went somewhere.\n return Math.max(diagonal / AUTO_EPSILON_DIVISOR, MIN_AUTO_EPSILON)\n}\n\n/** v2 accepts the `self` alias; the store keys on the qualified context. */\nconst resolveSelf =\n (selfContext: string) =>\n (context: string): string =>\n context === 'self' || context === 'vessels.self' ? selfContext : context\n","import { SelfPositionUnavailableError } from './utils.js'\nimport { Worker } from 'node:worker_threads'\nimport type { TrackStore } from './store.js'\nimport type { SqliteTrackStore, SqliteStoreConfig } from './sqliteStore.js'\nimport type { Debug, Context, LatLngTuple, TimeWindow, TrackParams } from './types.js'\nimport type { TrackQuery } from './timeWindow.js'\nimport type { Operation, StoreMethod, WorkerMessage } from './trackStoreProtocol.js'\ninterface Pending {\n operation: Operation\n bytes: number\n resolve?: (value: unknown) => void\n reject?: (error: Error) => void\n}\ninterface Limits {\n maxItems?: number\n maxBytes?: number\n}\n\n/** One database owner; the server never waits synchronously for storage. */\nexport class AsyncTrackStore implements TrackStore {\n private queue: Pending[]\n private active: Pending[]\n private names: Map<string, string>\n private sequence: number\n private pendingBytes: number\n private maxItems: number\n private maxBytes: number\n private onError: (error: Error) => void\n private debug: Debug\n private initialized: boolean\n private closing: boolean\n private failed: boolean\n private statusError: string | undefined\n private overloaded = false\n private dropped = 0\n private gapStarted = 0\n private writeFailed = false\n private readonly admitted = new Map<Context, number>()\n private readonly resolution: number\n private readonly onResume: (message: string) => void\n readonly ready: Promise<void>\n private resolveReady!: () => void\n private rejectReady!: (error: Error) => void\n private worker: Worker\n private exited: Promise<void>\n private closeAcknowledged = false\n private scheduled = false\n private closePromise: Promise<void> | undefined\n\n constructor(\n config: SqliteStoreConfig,\n debug: Debug,\n onError: (error: Error) => void = () => {},\n limits: Limits = {},\n onResume: (message: string) => void = () => {},\n ) {\n this.resolution = config.resolution ?? 0\n this.onResume = onResume\n this.queue = []\n this.active = []\n this.names = new Map()\n this.sequence = 0\n this.pendingBytes = 0\n this.maxItems = limits.maxItems ?? 10000\n this.maxBytes = limits.maxBytes ?? 8 * 1024 * 1024\n this.onError = onError\n this.debug = debug\n this.initialized = false\n this.closing = false\n this.failed = false\n this.statusError = undefined\n this.ready = new Promise((resolve, reject) => {\n this.resolveReady = resolve\n this.rejectReady = reject\n })\n // A startup failure must be reported even if nobody is awaiting a query.\n this.ready.catch(() => {})\n // Only the unit suite runs this module from source, and it builds the worker\n // into .vitest-worker before every run, watch reruns included. Decided by\n // this module's own location, not the environment: a server started from\n // a test run inherits VITEST, yet loads the published bundle.\n const fromSource = import.meta.url.endsWith('.ts')\n const workerUrl = new URL(\n /* @vite-ignore */ fromSource ? '../.vitest-worker/trackStoreWorker.js' : '../dist/trackStoreWorker.js',\n import.meta.url,\n )\n this.worker = new Worker(workerUrl, {\n workerData: { config, debugEnabled: Boolean(debug?.enabled) },\n })\n this.worker.on('message', (message: WorkerMessage) => this.receive(message))\n this.worker.on('error', (error: Error) => this.fail(error))\n this.exited = new Promise((resolve) =>\n this.worker.on('exit', (code) => {\n if (!this.closeAcknowledged && !this.failed)\n this.fail(new Error('Track worker exited before draining (code ' + code + ')'))\n resolve()\n }),\n )\n }\n\n private report(error: Error): void {\n if (!this.statusError) {\n this.statusError = 'Track storage error: ' + error.message\n this.onError(error)\n }\n }\n\n private resumeIfDrained(): void {\n if (!this.overloaded || this.failed || this.writeFailed || this.closing) return\n if (this.queue.length + this.active.length > this.maxItems / 2 || this.pendingBytes > this.maxBytes / 2) return\n this.overloaded = false\n this.statusError = undefined\n const message = `Track recording resumed; ${this.dropped} operations dropped during capacity gap starting ${new Date(this.gapStarted).toISOString()}`\n this.dropped = 0\n this.onResume(message)\n }\n\n private fail(error: Error): void {\n if (this.failed) return\n this.failed = true\n this.statusError = undefined\n this.report(error)\n this.rejectReady(error)\n for (const item of [...this.active, ...this.queue]) item.reject?.(error)\n this.active = []\n this.queue = []\n this.pendingBytes = 0\n }\n\n private enqueue<K extends StoreMethod>(\n method: K,\n args: Parameters<SqliteTrackStore[K]>,\n write = false,\n ): Promise<Awaited<ReturnType<SqliteTrackStore[K]>>> {\n this.resumeIfDrained()\n if (this.failed || (this.closing && method !== 'close') || (write && this.statusError)) {\n if (write && this.overloaded) this.dropped++\n const error = new Error(this.statusError ?? 'Track store is closing')\n return write ? Promise.resolve(undefined as Awaited<ReturnType<SqliteTrackStore[K]>>) : Promise.reject(error)\n }\n // Copy at admission: a caller must not mutate an accepted position later.\n let copy: Parameters<SqliteTrackStore[K]>, bytes: number\n try {\n copy = structuredClone(args)\n bytes = Buffer.byteLength(JSON.stringify(copy))\n } catch (error) {\n const failure = error instanceof Error ? error : new Error(String(error))\n if (write) {\n this.report(failure)\n return Promise.resolve(undefined as Awaited<ReturnType<SqliteTrackStore[K]>>)\n }\n return Promise.reject(failure)\n }\n if (\n method !== 'close' &&\n (this.queue.length + this.active.length >= this.maxItems || this.pendingBytes + bytes > this.maxBytes)\n ) {\n const error = new Error(\n 'Track queue capacity exceeded; recording paused until accepted work drains below half capacity.',\n )\n if (write) {\n this.overloaded = true\n this.gapStarted = Date.now()\n this.dropped++\n this.report(error)\n }\n return write ? Promise.resolve(undefined as Awaited<ReturnType<SqliteTrackStore[K]>>) : Promise.reject(error)\n }\n const item: Pending = { operation: { id: ++this.sequence, method, args: copy } as Operation, bytes }\n this.pendingBytes += bytes\n const result = write\n ? Promise.resolve(undefined)\n : new Promise<unknown>((resolve, reject) => Object.assign(item, { resolve, reject }))\n this.queue.push(item)\n this.schedule()\n return result as Promise<Awaited<ReturnType<SqliteTrackStore[K]>>>\n }\n\n private schedule(): void {\n if (this.scheduled || !this.initialized || this.active.length || this.failed) return\n this.scheduled = true\n queueMicrotask(() => {\n this.scheduled = false\n this.pump()\n })\n }\n\n private pump(): void {\n if (this.failed || this.active.length || !this.queue.length) return\n // Admission already bounds the run to maxBytes/maxItems. Drain the entire\n // adjacent write run in one commit, preserving query barriers and FIFO.\n const isWrite = (item: Pending) =>\n ['newPosition', 'recordName', 'initialTrack', 'prune'].includes(item.operation.method)\n let count = 1\n if (isWrite(this.queue[0]!)) while (count < this.queue.length && isWrite(this.queue[count]!)) count++\n this.active = this.queue.splice(0, count)\n try {\n this.worker.postMessage({ operations: this.active.map(({ operation }) => operation) })\n } catch (error) {\n this.fail(error instanceof Error ? error : new Error(String(error)))\n void this.worker.terminate()\n }\n }\n\n private receive(message: WorkerMessage): void {\n if (message.kind === 'debug') {\n this.debug?.(message.text)\n return\n }\n if (message.kind === 'fatal') {\n this.fail(new Error(message.error))\n return\n }\n if (message.kind === 'ready') {\n for (const { context, name } of message.names) this.names.set(context, name)\n this.initialized = true\n this.resolveReady()\n this.schedule()\n return\n }\n if (message.kind !== 'result' || this.failed) return\n const items = this.active\n if (\n message.results.length !== items.length ||\n items.some((item, index) => message.results[index]?.id !== item.operation.id)\n ) {\n this.fail(new Error('Invalid track worker response'))\n void this.worker.terminate()\n return\n }\n this.active = []\n for (let i = 0; i < items.length; i++) {\n const item = items[i]!,\n result = message.results[i]\n this.pendingBytes -= item.bytes\n if (!result) continue\n if (result.error) {\n if (result.writeFailed && !this.writeFailed) {\n this.writeFailed = true\n this.statusError = undefined\n this.report(new Error(result.error))\n }\n item.reject?.(\n result.selfPositionUnavailable ? new SelfPositionUnavailableError(result.error) : new Error(result.error),\n )\n } else {\n if (item.operation.method === 'prune') {\n const remaining = new Set(result.names ?? [])\n for (const context of this.names.keys()) if (!remaining.has(context)) this.names.delete(context)\n }\n if (result.name) {\n if (result.name.value === undefined) this.names.delete(result.name.context)\n else this.names.set(result.name.context, result.name.value)\n }\n if (item.operation.method === 'close') this.closeAcknowledged = true\n item.resolve?.(result.value)\n }\n }\n this.resumeIfDrained()\n this.schedule()\n }\n\n newPosition(context: Context, position: LatLngTuple, timestamp = Date.now()): void {\n const previous = this.admitted.get(context)\n if (this.resolution > 0 && previous !== undefined && timestamp - previous < this.resolution) return\n const sequence = this.sequence\n void this.enqueue('newPosition', [context, position, timestamp], true)\n // Dropped positions must not suppress the first fix after recovery.\n if (this.sequence !== sequence && this.resolution > 0) this.admitted.set(context, timestamp)\n }\n recordName(context: Context, name: string, timestamp = Date.now()): void {\n void this.enqueue('recordName', [context, name, timestamp], true)\n }\n nameFor(context: Context): string | undefined {\n return this.names.get(context)\n }\n initialTrack(context: Context, track: LatLngTuple[], timestamps?: number[]): void {\n const sequence = this.sequence\n void this.enqueue('initialTrack', [context, track, timestamps], true)\n if (this.sequence !== sequence) this.admitted.delete(context)\n }\n prune(maxAge: number, keep?: Context): void {\n const now = Date.now()\n const sequence = this.sequence\n void this.enqueue('prune', [maxAge, keep, now], true)\n if (this.sequence !== sequence) {\n for (const [context, timestamp] of this.admitted) {\n if (context !== keep && timestamp < now - maxAge) this.admitted.delete(context)\n }\n }\n }\n get(context: Context, window?: TimeWindow) {\n return this.enqueue('get', [context, window])\n }\n getTimed(context: Context, window?: TimeWindow) {\n return this.enqueue('getTimed', [context, window])\n }\n getAllTracks(query?: TrackQuery) {\n return this.enqueue('getAllTracks', [query])\n }\n getFilteredTracks(params: TrackParams, selfPosition?: LatLngTuple, _debug?: Debug, query?: TrackQuery) {\n return this.enqueue('getFilteredTracks', [params, selfPosition, undefined, query])\n }\n getFilteredTimedTracks(params: TrackParams, selfPosition?: LatLngTuple, _debug?: Debug, query?: TrackQuery) {\n return this.enqueue('getFilteredTimedTracks', [params, selfPosition, undefined, query])\n }\n close(): Promise<void> {\n if (!this.closePromise) {\n this.closing = true\n this.closePromise = this.enqueue('close', []).then(\n () => this.exited,\n async (error: unknown) => {\n await this.exited\n throw error\n },\n )\n this.closePromise.catch(() => {})\n }\n return this.closePromise\n }\n}\n","import { createDistanceTo } from './utils.js'\nimport type { Context, LatLngTuple } from './types.js'\n\n/**\n * Drops positions that imply an impossible speed since the last accepted one.\n *\n * A GPS occasionally emits a fix hundreds of miles away — a bad almanac, a\n * multipath reflection, a receiver resetting. On a live map it flickers past.\n * In a stored track it is permanent: one spike stretches the bounding box\n * across an ocean, inflates the distance travelled, and drags the line across\n * the chart every time the track is drawn.\n *\n * Speed, not distance, is the test. A vessel can legitimately jump a long way\n * between two fixes if the gap between them is long enough — after a passage\n * with the plugin stopped, or when a slow-reporting AIS target reappears. What\n * cannot happen is covering that distance faster than a vessel moves.\n *\n * Requested in https://github.com/SignalK/tracks/issues/48\n */\n\n/**\n * Default ceiling in knots.\n *\n * Above any displacement vessel and most planing craft, and well above the\n * ~50kn an AIS Class A target reports at the top of its scale, so a real fix is\n * never dropped. Glitches are typically wrong by whole degrees of latitude, so\n * they miss this by orders of magnitude rather than by a little.\n */\nexport const DEFAULT_MAX_SPEED_KNOTS = 100\n\nconst METRES_PER_NAUTICAL_MILE = 1852\n\ninterface LastAccepted {\n position: LatLngTuple\n timestamp: number\n}\n\nexport interface GlitchFilterConfig {\n /** Ceiling in knots. 0 or below disables the filter. */\n maxSpeedKnots: number\n}\n\nexport class GlitchFilter {\n private readonly last = new Map<Context, LastAccepted>()\n private readonly maxSpeedKnots: number\n /** Rejections per context, for the plugin status line. */\n private readonly rejected = new Map<Context, number>()\n\n constructor(config: GlitchFilterConfig) {\n this.maxSpeedKnots = config.maxSpeedKnots\n }\n\n /**\n * Whether to keep this position.\n *\n * Accepting updates the reference point, so a sustained move — a genuinely\n * fast vessel, or a track resuming after a gap — is not fought after the\n * first fix.\n */\n accept(context: Context, position: LatLngTuple, timestamp: number): boolean {\n if (this.maxSpeedKnots <= 0) {\n return true\n }\n const previous = this.last.get(context)\n if (previous === undefined) {\n this.last.set(context, { position, timestamp })\n return true\n }\n\n // Out-of-order or same-instant fixes carry no usable speed: dividing by a\n // zero or negative interval says nothing about whether the jump is real.\n // Bootstrapped history arrives back-dated, so this is not hypothetical.\n const seconds = (timestamp - previous.timestamp) / 1000\n if (seconds <= 0) {\n return true\n }\n\n const metres = createDistanceTo(previous.position)(position)\n const knots = metres / seconds / (METRES_PER_NAUTICAL_MILE / 3600)\n if (knots > this.maxSpeedKnots) {\n this.rejected.set(context, (this.rejected.get(context) ?? 0) + 1)\n return false\n }\n\n this.last.set(context, { position, timestamp })\n return true\n }\n\n /** How many positions have been rejected for a context. */\n rejectedCount(context: Context): number {\n return this.rejected.get(context) ?? 0\n }\n\n /** Total rejections across every context. */\n totalRejected(): number {\n let total = 0\n for (const count of this.rejected.values()) {\n total += count\n }\n return total\n }\n\n /** Forget every reference point; used when the plugin restarts. */\n clear(): void {\n this.last.clear()\n this.rejected.clear()\n }\n}\n","import { uncoveredBetween } from './timeWindow.js'\nimport type { TimedPosition } from './types.js'\n\n/**\n * Combines positions from a history provider with the plugin's own store.\n *\n * The two sources record the same vessel differently. The plugin's store keeps\n * one position a minute forever, which is what a track from three years ago is\n * made of. A history provider keeps whatever resolution it was configured for\n * — often every couple of seconds — but only for as long as its retention\n * allows, and only for as long as it has actually been running.\n *\n * So neither is simply better. History is finer where it reaches; the store is\n * what remains everywhere else.\n *\n * ## The rule\n *\n * Trust history for any bucket where it returns a usable position. Use the\n * store for every other bucket.\n *\n * That one rule covers every way a provider's coverage can be incomplete,\n * without reading any configuration:\n *\n * - retention has dropped the old data — history returns nothing there\n * - the provider was installed after the boat started recording — likewise\n * - the provider was disabled, or its database was down, for a week — likewise\n * - the vessel was ashore and nothing was recorded — history returns nothing\n * and so does the store, which is the correct answer\n *\n * Coverage is therefore discovered per query rather than configured, and\n * nothing breaks when it changes underneath.\n *\n * ## Why the sources cannot double up\n *\n * A history provider aggregates into resolution buckets and timestamps each\n * one on its boundary — 19:00:00.000, 19:01:00.000. The store keeps the actual\n * time a fix arrived, 19:00:01.212. The same physical position therefore has\n * two different timestamps, so joining on equality would silently keep both.\n *\n * Bucketing avoids the problem rather than solving it: each bucket is filled\n * from exactly one source, so no comparison between individual points is ever\n * needed.\n */\n\n/** A bucket index for a timestamp, so both sources land on the same grid. */\nconst bucketOf = (timestamp: number, resolution: number): number => Math.floor(timestamp / resolution)\n\nexport interface ReconcileResult {\n positions: TimedPosition[]\n /** Buckets served from the history provider. */\n fromHistory: number\n /** Buckets served from the plugin's own store. */\n fromStore: number\n}\n\n/**\n * Merge history and store positions into one track.\n *\n * `resolution` is the bucket width in milliseconds; it should be the\n * resolution the history provider was asked for, so its rows land one per\n * bucket.\n */\nexport function reconcile(history: TimedPosition[], stored: TimedPosition[], resolution: number): ReconcileResult {\n if (resolution <= 0) {\n // Without a bucket width there is no grid to reconcile on. History is\n // still the finer source, so it wins outright and the store fills only\n // what lies outside its extent.\n return reconcileByExtent(history, stored)\n }\n\n const covered = new Set<number>()\n const positions: TimedPosition[] = []\n for (const point of history) {\n covered.add(bucketOf(point.timestamp, resolution))\n positions.push(point)\n }\n const fromHistory = positions.length\n\n for (const point of stored) {\n if (!covered.has(bucketOf(point.timestamp, resolution))) {\n positions.push(point)\n }\n }\n\n positions.sort((a, b) => a.timestamp - b.timestamp)\n return {\n positions: measuredFromHistory(positions, history),\n fromHistory,\n fromStore: positions.length - fromHistory,\n }\n}\n\n/**\n * Merge history that is coarser than the store into it, the store first.\n *\n * The rule above holds while history is the finer source. A history read\n * widened to stay within its bucket budget is not: one of its buckets spans\n * several stored fixes, and letting it win would replace them all with one\n * point. So here the store keeps every bucket it has a point in, and history\n * fills only the buckets the store has nothing for — where the provider\n * recorded before this plugin was installed, or while it was off.\n */\nexport function fillFromHistory(\n history: TimedPosition[],\n stored: TimedPosition[],\n resolution: number,\n): ReconcileResult {\n const covered = new Set(stored.map(({ timestamp }) => bucketOf(timestamp, resolution)))\n const filling = history.filter(({ timestamp }) => !covered.has(bucketOf(timestamp, resolution)))\n const positions = [...stored, ...filling].sort((a, b) => a.timestamp - b.timestamp)\n return {\n positions: measuredFromHistory(positions, filling),\n fromHistory: filling.length,\n fromStore: stored.length,\n }\n}\n\n/**\n * A stored point's `pauseBefore` was measured over the stored points thinning\n * dropped before it. Where a history point now comes right before it, history\n * covered part of that time, so the pause is at most the time since that\n * bucket, and at most the longest step the store recorded, whichever is less.\n */\nfunction measuredFromHistory(positions: TimedPosition[], history: TimedPosition[]): TimedPosition[] {\n const fromHistory = new Set(history)\n return positions.map((point, i) => {\n const before = positions[i - 1]\n if (point.pauseBefore === undefined || before === undefined || !fromHistory.has(before)) {\n return point\n }\n return { ...point, pauseBefore: Math.min(point.pauseBefore, uncoveredBetween(before, point)) }\n })\n}\n\n/**\n * Fallback when no bucket width is known: history covers its own time span,\n * and the store supplies whatever falls outside it.\n *\n * Less precise than bucketing — a gap inside history's span is not filled —\n * but it cannot produce duplicates, which matters more.\n */\nfunction reconcileByExtent(history: TimedPosition[], stored: TimedPosition[]): ReconcileResult {\n if (history.length === 0) {\n return { positions: [...stored], fromHistory: 0, fromStore: stored.length }\n }\n // Min and max rather than first and last: nothing guarantees a provider\n // returns rows in time order, and assuming it does would judge a stored\n // point inside history's span to be outside it, keeping both.\n let first = history[0]!.timestamp\n let last = first\n for (const point of history) {\n if (point.timestamp < first) first = point.timestamp\n if (point.timestamp > last) last = point.timestamp\n }\n const outside = stored.filter((p) => p.timestamp < first || p.timestamp > last)\n const positions = [...history, ...outside].sort((a, b) => a.timestamp - b.timestamp)\n return {\n positions: measuredFromHistory(positions, history),\n fromHistory: history.length,\n fromStore: outside.length,\n }\n}\n","import type { Context } from './types.js'\n\n/**\n * Watches which `$source` each context's positions arrive from.\n *\n * `navigation.position` is a rapid update — a GPS may report at 10Hz — and a\n * typical boat has several receivers: an internal GPS, an AIS transponder, a\n * chart plotter echoing its own fix.\n *\n * Signal K resolves which one wins through source priority, and the bus this\n * plugin listens on is the filtered one: the server applies `toPreferredDelta`\n * before emitting `delta`, which is what feeds `getBus()`. So with a priority\n * rule in place, only the winner arrives and there is nothing to warn about.\n *\n * The gap is when no rule *matches* the path. Then the engine passes every\n * source through, the fixes from receivers metres apart interleave, and the\n * track zigzags between them rather than following the boat. The fix is to\n * configure source priority for `navigation.position`; this watcher exists to\n * say so, because the symptom (a track that looks noisy) does not obviously\n * point at the cause.\n *\n * Detection is therefore empirical rather than reading the priority config:\n * seeing several sources here *is* the evidence that no rule is matching,\n * which a config that merely exists would not tell us.\n *\n * With one qualification. A priority rule has a failover window — when the\n * top-ranked source goes quiet the engine promotes the next one — so over a\n * long run several sources legitimately reach this bus even with a rule in\n * place, one at a time. Counting every source ever seen would therefore\n * report a conflict on a correctly configured boat whose GPS drops out\n * occasionally. What distinguishes competition is *alternation*: unfiltered\n * sources hand back and forth repeatedly within seconds, while a failover\n * switches once and stays.\n */\n\n/**\n * How quickly the source must change again for it to count as alternation.\n *\n * Shorter than the server's 15s default failover, so a promotion — one\n * switch after a silence — never chains into a sequence. Positions are a\n * rapid update, often 10Hz, so genuinely competing sources alternate far\n * faster than this.\n */\nconst ALTERNATION_WINDOW_MS = 10 * 1000\n\n/**\n * How long a conflict is remembered after it was last observed.\n *\n * Longer than the 30s status tick, so a conflict seen just after one tick is\n * still reported at the next and does not flicker. Finite, so fixing the\n * priorities clears the warning on its own rather than needing a restart —\n * which is the whole complaint about a watcher that only ever accumulates.\n */\nconst CONFLICT_RETENTION_MS = 90 * 1000\n\n/**\n * How many source changes in a row before it counts as competition.\n *\n * One change is a failover, two is a failback — the preferred source\n * returning and winning immediately, which the server does on purpose. Three\n * means the sources are trading the path back and forth, which no priority\n * rule does.\n */\nconst CHANGES_BEFORE_WARNING = 3\n\nexport class SourceWatch {\n /** Last time each source produced a position, per context. */\n private readonly seen = new Map<Context, Map<string, number>>()\n /** Sources found competing, per context, with when each was last seen so. */\n private readonly competing = new Map<Context, Map<string, number>>()\n /** The current alternation per context: who is in it, and how long it is. */\n private readonly lastSeen = new Map<\n Context,\n { source: string; at: number; changedAt: number; changes: number; participants: Set<string> }\n >()\n\n /** Record that `context` produced a position from `$source`. */\n add(context: Context, source: string | undefined, now: number = Date.now()): void {\n if (!source) {\n return\n }\n let sources = this.seen.get(context)\n if (!sources) {\n sources = new Map()\n this.seen.set(context, sources)\n }\n // Counted per *change* of source, not per overlapping report: a recovered\n // source reporting repeatedly is one transition, however many fixes it\n // sends. Judged as positions arrive rather than when the status is\n // published, which is up to 30s later.\n const previous = this.lastSeen.get(context)\n // Recorded before the conflict below reads each participant's last-seen\n // time: a source returning after a long silence would otherwise be filed\n // with its old timestamp and expire immediately.\n sources.set(source, now)\n // Timed from the last *change*, not the last report: repeated fixes from\n // the winner are the healthy state and neither advance the sequence nor\n // end it, while a run of them long enough to pass the window does.\n const stale = !previous || now - previous.changedAt > ALTERNATION_WINDOW_MS\n if (stale || previous.source === source) {\n this.lastSeen.set(context, {\n source,\n at: now,\n changedAt: stale ? now : previous.changedAt,\n changes: stale ? 0 : previous.changes,\n participants: stale ? new Set([source]) : previous.participants,\n })\n } else {\n const changes = previous.changes + 1\n const participants = previous.participants.add(source)\n this.lastSeen.set(context, { source, at: now, changedAt: now, changes, participants })\n if (changes >= CHANGES_BEFORE_WARNING) {\n // Every source in the alternation, not just the last pair: three\n // receivers trading a path is one conflict, and naming two of them\n // sends the user after the wrong hardware.\n const found = this.competing.get(context) ?? new Map<string, number>()\n for (const participant of participants) {\n // Each carries its own last-seen time, so one that drops out of the\n // alternation expires on schedule instead of being kept alive by\n // the others still trading.\n found.set(participant, sources.get(participant) ?? now)\n }\n this.competing.set(context, found)\n }\n }\n }\n\n /** Every source seen for a context, in the order they first appeared. */\n sourcesFor(context: Context): string[] {\n return [...(this.seen.get(context) ?? new Map<string, number>()).keys()]\n }\n\n /**\n * The sources found live at the same time, which is what a warning names.\n *\n * Expired entries are dropped rather than merely hidden, so a conflict that\n * has stopped recurring leaves nothing behind.\n */\n competingFor(context: Context, now: number = Date.now()): string[] {\n const found = this.competing.get(context)\n if (!found) {\n return []\n }\n for (const [source, at] of found) {\n if (now - at > CONFLICT_RETENTION_MS) {\n found.delete(source)\n }\n }\n // One source left is nobody to compete with.\n if (found.size < 2) {\n this.competing.delete(context)\n return []\n }\n return [...found.keys()]\n }\n\n /** Contexts that have received positions from two sources at once. */\n conflicted(now: number = Date.now()): Context[] {\n return [...this.competing.keys()].filter((context) => this.competingFor(context, now).length > 1)\n }\n\n /**\n * A status line naming the problem, or undefined when nothing is wrong.\n *\n * `selfContext` is called out by name because the own vessel's track is the\n * one a user looks at, and it is the one whose priority they can fix. Other\n * vessels are usually AIS, where multiple receivers are expected and less\n * worth nagging about — they are counted, not named.\n */\n warning(selfContext?: Context, now: number = Date.now()): string | undefined {\n const conflicted = this.conflicted(now)\n if (conflicted.length === 0) {\n return undefined\n }\n const selfConflicted = selfContext !== undefined && conflicted.includes(selfContext)\n const others = conflicted.length - (selfConflicted ? 1 : 0)\n\n if (selfConflicted) {\n const recent = this.competingFor(selfContext, now)\n const sources = recent.join(', ')\n const tail = others > 0 ? ` (and ${others} other ${others === 1 ? 'vessel' : 'vessels'})` : ''\n return (\n `navigation.position for the own vessel is arriving from ${recent.length} sources ` +\n `(${sources})${tail}. Set source priority for navigation.position, or the track will zigzag between them.`\n )\n }\n return `navigation.position is arriving from multiple sources for ${others} ${others === 1 ? 'vessel' : 'vessels'}.`\n }\n\n /** Forget everything seen; used when the plugin restarts. */\n clear(): void {\n this.seen.clear()\n this.competing.clear()\n this.lastSeen.clear()\n }\n}\n","/**\n * Pauses recording of the own vessel's track while it is not going anywhere.\n *\n * A boat on a mooring for the winter emits a position every second and travels\n * nowhere, so the track gains nothing but rows. Gating on `navigation.state`\n * lets those months cost nothing, which matters once tracks are kept\n * indefinitely.\n *\n * Requested in https://github.com/SignalK/tracks/issues/1, where the\n * suggestion is `signalk-autostate` as the source of the state.\n *\n * Two limits are deliberate.\n *\n * **`anchored` is not a pause state, and is not offered as a default.** An\n * anchor alarm needs exactly the track a vessel makes while anchored — swinging\n * on the rode is the signal it watches. Gating there would break a real\n * consumer, so a user has to choose it explicitly if they ever want it.\n *\n * **Own vessel only.** `navigation.state` for an AIS target is the\n * navigational status its operator set on the transponder, and it is often\n * stale: vessels under way still reporting `moored` are ordinary. Gating those\n * would drop targets from the track while they are visibly moving.\n */\nimport type { Context } from './types.js'\n\n// Recording a vessel that is not going anywhere costs rows and produces a\n// smear of GPS noise rather than a track. `anchored` is deliberately absent: an\n// anchor alarm watches exactly the track a vessel makes while swinging on its\n// rode, so pausing there would remove the one thing that use case needs.\nexport const DEFAULT_PAUSE_STATES: string[] = ['moored', 'not-under-way', 'aground']\n\n/**\n * The states a boat is plausibly parked in, offered in the plugin UI.\n *\n * A subset of the `navigation.state` enum in `@signalk/signalk-schema` rather\n * than the whole of it: most of that list is AIS navigational status, and\n * offering `trawling-hauling` as a reason to stop recording would be noise.\n */\nexport const PAUSABLE_STATES = ['moored', 'not-under-way', 'anchored', 'aground'] as const\n\nexport class StateGate {\n private readonly pauseStates: ReadonlySet<string>\n private readonly selfContext: string\n /** Last state seen, for the plugin status line. */\n private lastState: string | undefined\n private pausedCount = 0\n\n constructor(selfContext: string, pauseStates: readonly string[]) {\n this.selfContext = selfContext\n this.pauseStates = new Set(pauseStates)\n }\n\n /** Whether the gate can ever pause anything. */\n get enabled(): boolean {\n return this.pauseStates.size > 0\n }\n\n /**\n * Whether to record this position.\n *\n * `state` is whatever `navigation.state` currently reads, or undefined when\n * the vessel does not report it. Undefined records: a boat with no state at\n * all must not silently stop being tracked.\n */\n accept(context: Context, state: string | undefined): boolean {\n if (!this.enabled || context !== this.selfContext) {\n return true\n }\n this.lastState = state\n if (state !== undefined && this.pauseStates.has(state)) {\n this.pausedCount++\n return false\n }\n return true\n }\n\n /** The state last seen for the own vessel, or undefined if none yet. */\n get state(): string | undefined {\n return this.lastState\n }\n\n /** How many positions have been skipped. */\n get skipped(): number {\n return this.pausedCount\n }\n\n /** A status line while paused, or undefined when recording. */\n status(): string | undefined {\n if (!this.enabled || this.lastState === undefined) {\n return undefined\n }\n if (!this.pauseStates.has(this.lastState)) {\n return undefined\n }\n return `Not recording: navigation.state is '${this.lastState}'. ${this.pausedCount} position(s) skipped.`\n }\n\n /** Forget what was seen; used when the plugin restarts. */\n clear(): void {\n this.lastState = undefined\n this.pausedCount = 0\n }\n}\n","import { DOMParser } from '@xmldom/xmldom'\nimport type { Document, Element } from '@xmldom/xmldom'\nimport type { TimedPosition } from './types.js'\n\n/**\n * GPX 1.1 serialisation and parsing for recorded tracks.\n *\n * Hand-written rather than through a library: the vocabulary is `gpx`,\n * `metadata`, `trk`, `name`, `extensions`, `signalk:context`, `trkseg`,\n * `trkpt` and `time` — nine element types — and a dependency for that would be\n * more surface than the code it replaces.\n * What matters is getting the details right, and those are documented below.\n */\n\nconst LAT = 0\nconst LNG = 1\n\n/** Identity of the vessel a track belongs to, as far as it is known. */\nexport interface GpxTrackIdentity {\n /** Display label, e.g. `Own Ship` or `AIS Ariadne`. Becomes `<name>`. */\n name: string\n /** Fully qualified Signal K context, when there is one. */\n context?: string\n}\n\nexport interface GpxTrack extends GpxTrackIdentity {\n /** Segments as `segment()` produces them: a recording gap starts a new one. */\n segments: TimedPosition[][]\n}\n\n/**\n * Serialise tracks as a GPX 1.1 document.\n *\n * Several tracks go in one file, each as its own `<trk>`. GPX allows it, and\n * it is what makes \"select three trips, export\" produce one file rather than\n * three — TimeZero writes one file per vessel, which is a choice rather than a\n * constraint of the format.\n *\n * The Signal K context travels in `<extensions>`. TimeZero puts an opaque\n * GUID there and drops the MMSI entirely, so its exports cannot round-trip an\n * identity; carrying the context means ours can, while staying readable by\n * anything else, since `<extensions>` is ignorable by specification.\n */\nexport function toGpx(tracks: GpxTrack[], now: Date = new Date()): string {\n const lines = [\n '<?xml version=\"1.0\" encoding=\"UTF-8\"?>',\n '<gpx version=\"1.1\" creator=\"@signalk/tracks-plugin\" xmlns=\"http://www.topografix.com/GPX/1/1\">',\n ' <metadata>',\n ` <time>${iso(now.getTime())}</time>`,\n ' </metadata>',\n ]\n for (const track of tracks) {\n const segments = track.segments\n .map((points) => points.filter((p) => inRange(p.position[LAT], p.position[LNG])))\n .filter((points) => points.length > 0)\n // A <trk> with no <trkseg> is a name and nothing else: fromGpx ignores it,\n // so writing one loses the track and its identity on a round-trip.\n if (segments.length === 0) {\n continue\n }\n lines.push(' <trk>')\n lines.push(` <name>${escapeXml(track.name)}</name>`)\n if (track.context !== undefined) {\n lines.push(' <extensions>')\n lines.push(` <signalk:context xmlns:signalk=\"${NAMESPACE}\">${escapeXml(track.context)}</signalk:context>`)\n lines.push(' </extensions>')\n }\n for (const points of segments) {\n lines.push(' <trkseg>')\n for (const { position, timestamp } of points) {\n lines.push(` <trkpt lat=\"${coordinate(position[LAT])}\" lon=\"${coordinate(position[LNG])}\">`)\n // <time> is the only child, and GPX 1.1 fixes the order of trkpt's\n // children — a schema-validating reader rejects them out of sequence.\n lines.push(` <time>${iso(timestamp)}</time>`)\n lines.push(' </trkpt>')\n }\n lines.push(' </trkseg>')\n }\n lines.push(' </trk>')\n }\n lines.push('</gpx>')\n return lines.join('\\n') + '\\n'\n}\n\n/** GPX's own namespace, which structural elements must be in. */\nconst GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1'\n\n/** Namespace for the one extension element this plugin writes. */\nconst NAMESPACE = 'https://signalk.org/specification/1.7.0/'\n\n/**\n * Parse a GPX document into tracks.\n *\n * Structure follows GPX's own hierarchy: the root is `<gpx>`, tracks are its\n * children, segments are a track's, points are a segment's. Anything else —\n * a `<trkseg>` inside an `<extensions>` payload, a `<trk>` below the root — is\n * not track data, and a namespace check alone cannot tell the difference,\n * since a payload declaring no namespace inherits GPX's own.\n *\n * Deliberately forgiving about structure and strict about values: elements it\n * does not recognise are ignored rather than rejected, but a coordinate that is\n * not an `xsd:decimal` in range, or a time that is not an `xsd:dateTime`, is\n * refused. One NaN reaching the store stretches every bounding box that track\n * appears in.\n *\n * **Points are sorted by time within each segment.** TimeZero paginates its\n * export in blocks of 150 and the block seams overlap: the last point of a\n * block can be a few milliseconds newer than the first point of the next. Out\n * of 5,550 points in a real export, 16 pairs are out of order, every one on a\n * 150-point boundary. Anything downstream that assumes ordering — time-window\n * queries, the history reconciliation — would quietly misbehave on such a file.\n *\n * Sorting stops at the segment boundary, deliberately: a segment is a stretch\n * of continuous recording, and reordering points across a gap would merge two\n * passages into one line. So `segments.flat()` is not globally sorted for a\n * file whose segments interleave in time, and a caller that needs that must\n * sort for itself.\n *\n * A document the parser rejects yields no tracks rather than throwing: this\n * reads files the plugin did not write, and an import that fails should say so\n * by returning nothing rather than by taking the caller down.\n */\nexport function fromGpx(xml: string): GpxTrack[] {\n let document: Document\n try {\n // onError swallows the warnings xmldom would otherwise print; a fatal\n // error still throws, which the catch below turns into an empty result.\n document = new DOMParser({\n onError: (level) => {\n // A \"warning\" is xmldom telling us about something it handled; an\n // \"error\" is a syntax fault it recovered from by guessing. The\n // contract is that a document which is not well-formed yields no\n // tracks, so a guess is refused rather than imported.\n if (level !== 'warning') {\n throw new Error('malformed XML')\n }\n },\n }).parseFromString(xml, 'text/xml')\n } catch {\n return []\n }\n\n // The root must be GPX's own: a <trk> nested in an <extensions> payload\n // inherits the namespace and would otherwise import as a real track, and a\n // document rooted at something else entirely is not GPX at all.\n const root = document.documentElement\n if (root === null || root.localName !== 'gpx' || !isGpxNamespace(root)) {\n return []\n }\n\n const tracks: GpxTrack[] = []\n for (const trk of gpxChildren(root, 'trk')) {\n const segments: TimedPosition[][] = []\n for (const trkseg of gpxChildren(trk, 'trkseg')) {\n const points = readPoints(trkseg)\n if (points.length > 0) {\n segments.push(points)\n }\n }\n if (segments.length === 0) {\n continue\n }\n // Trimmed because surrounding whitespace is the writer's layout rather\n // than part of the name, and defaulted because a track list with a blank\n // row in it is worse than one with a generic label.\n const name = childText(trk, 'name').trim() || 'Track'\n const context = signalKContext(trk)\n tracks.push({ name, segments, ...(context === undefined ? {} : { context }) })\n }\n return tracks\n}\n\n/**\n * The text of a direct GPX child with this local name.\n *\n * Direct rather than `getElementsByTagName`, which would reach a `<name>`\n * inside an `<extensions>` payload and label the track with a vendor's string.\n */\nfunction childText(trk: Element, local: string): string {\n for (const child of Array.from(trk.childNodes)) {\n if (isElement(child) && child.localName === local && isGpxNamespace(child)) {\n return child.textContent ?? ''\n }\n }\n return ''\n}\n\n/**\n * The Signal K context declared in a track's own `<extensions>`.\n *\n * Matched on namespace URI, which is what the prefix actually means: GPX\n * extensions are an open field any vendor may write into, so a foreign\n * `<context>` — or one under a different prefix bound elsewhere — must not\n * claim the track's identity. Read only from the track's direct\n * `<extensions>` children, because a trackpoint carries its own and those\n * belong to that point.\n */\nfunction signalKContext(trk: Element): string | undefined {\n for (const child of Array.from(trk.childNodes)) {\n if (!isElement(child) || child.localName !== 'extensions' || !isGpxNamespace(child)) {\n continue\n }\n for (const candidate of Array.from(child.getElementsByTagNameNS(NAMESPACE, 'context'))) {\n const value = (candidate.textContent ?? '').trim()\n if (value !== '') {\n return value\n }\n }\n }\n return undefined\n}\n\n/** The points of one `<trkseg>`, oldest first. */\nfunction readPoints(trkseg: Element): TimedPosition[] {\n const points: TimedPosition[] = []\n for (const trkpt of gpxChildren(trkseg, 'trkpt')) {\n const latitude = decimal(trkpt.getAttribute('lat') ?? undefined)\n const longitude = decimal(trkpt.getAttribute('lon') ?? undefined)\n if (!inRange(latitude, longitude)) {\n continue\n }\n const raw = childText(trkpt, 'time').trim()\n const timestamp = raw === '' ? Number.NaN : dateTime(raw)\n points.push({\n position: [latitude, longitude],\n // A point with no usable time is dated to the start of time rather than\n // dropped: its position is still real, and a window query treats it as\n // older than anything live.\n timestamp: Number.isFinite(timestamp) ? timestamp : 0,\n })\n }\n // Stable by construction: equal timestamps keep their file order, so a block\n // seam does not reshuffle points that were already correct.\n return points.sort((a, b) => a.timestamp - b.timestamp)\n}\n\n/**\n * Direct GPX children of `root` with this local name.\n *\n * Direct, because GPX fixes the hierarchy: a `<trkseg>` is a child of `<trk>`\n * and a `<trkpt>` a child of `<trkseg>`. Scanning descendants instead lets an\n * `<extensions>` payload contribute track data — and a payload that declares\n * no namespace of its own inherits GPX's, so a namespace check cannot catch\n * it. A vendor's `<trkseg>` inside `<extensions>` became a phantom segment.\n */\nfunction gpxChildren(root: Element, local: string): Element[] {\n return Array.from(root.childNodes).filter(\n (node): node is Element => isElement(node) && node.localName === local && isGpxNamespace(node),\n )\n}\n\n/**\n * Whether an element is GPX's own.\n *\n * A null namespace counts: a great many files in the wild omit the xmlns\n * declaration entirely, and refusing those would reject most of what users\n * actually have.\n */\nfunction isGpxNamespace(element: Element): boolean {\n return element.namespaceURI === null || element.namespaceURI === GPX_NAMESPACE\n}\n\n/** Narrow a DOM node to an element without relying on a global Node. */\nfunction isElement(node: { nodeType: number }): node is Element {\n return node.nodeType === 1\n}\n\n/**\n * Coordinates at seven decimal places: ~11 mm, finer than any GPS fix and\n * short enough that a long track does not carry pointless digits.\n */\nfunction coordinate(value: number): string {\n const fixed = value.toFixed(7).replace(/\\.?0+$/, '')\n // A tiny negative rounds to \"-0.0000000\" and strips to \"-0\", which is a\n // number no position ever is. Zero is zero.\n return fixed === '-0' ? '0' : fixed\n}\n\n/**\n * A GPX `<time>` as epoch milliseconds, or NaN when it is not one.\n *\n * GPX types the element as `xsd:dateTime`, and `Date.parse` is far looser: it\n * reads a bare `2024` and `Jan 1 2024` as instants, and silently rolls\n * `2024-02-30` forward to March. A date that never existed should reach the\n * caller's fallback rather than arrive as a real -- and wrong -- position in\n * time.\n */\nfunction dateTime(value: string): number {\n const match = /^(\\d{4})-(\\d{2})-(\\d{2})T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$/.exec(value)\n if (!match) {\n return Number.NaN\n }\n const parsed = Date.parse(value)\n if (Number.isNaN(parsed)) {\n return Number.NaN\n }\n // Date.parse normalises an impossible day rather than rejecting it, so the\n // calendar fields are compared against what they round-tripped to.\n const year = Number(match[1])\n const month = Number(match[2])\n const day = Number(match[3])\n const utc = new Date(Date.UTC(year, month - 1, day))\n return utc.getUTCFullYear() === year && utc.getUTCMonth() === month - 1 && utc.getUTCDate() === day\n ? parsed\n : Number.NaN\n}\n\n/**\n * ISO-8601 UTC, because a track outlives the timezone it was recorded in.\n *\n * A timestamp outside the Date range throws out of `toISOString`, and one bad\n * point would abort the export of every track in the file. It is dated to the\n * start of time instead, which is what `fromGpx` does with a time it cannot\n * read -- losing one point's time beats losing the document.\n */\nfunction iso(timestamp: number): string {\n const at = new Date(timestamp)\n return Number.isNaN(at.getTime()) ? new Date(0).toISOString() : at.toISOString()\n}\n\n/**\n * XML-escape a value for element content.\n *\n * Characters XML cannot express are dropped rather than escaped, because they\n * have no entity form: emitting one produces a document no conformant reader\n * will load, and losing the character beats losing the file.\n */\nfunction escapeXml(value: string): string {\n return (\n [...value]\n // Dropped rather than escaped: a NUL or a lone surrogate has no entity\n // form, so emitting one produces a document no conformant reader will\n // load -- losing the character beats losing the file.\n .filter((character) => isXmlChar(character.codePointAt(0) ?? -1))\n .join('')\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&apos;')\n )\n}\n\n/**\n * Whether a code point is one XML 1.0 permits in content.\n *\n * The range has holes: NUL, most C0 controls and the surrogate block are all\n * excluded. Decoding `&#0;` or `&#xD800;` puts a character into a track name\n * that cannot be written back out as well-formed XML, so the reference is\n * kept verbatim instead -- the same treatment as one that names no character\n * at all.\n */\nfunction isXmlChar(value: number): boolean {\n if (!Number.isInteger(value)) {\n return false\n }\n return (\n value === 0x9 ||\n value === 0xa ||\n value === 0xd ||\n (value >= 0x20 && value <= 0xd7ff) ||\n (value >= 0xe000 && value <= 0xfffd) ||\n (value >= 0x10000 && value <= 0x10ffff)\n )\n}\n\n/**\n * An attribute value as an XSD decimal, or NaN when it is not one.\n *\n * GPX types lat and lon as `xsd:decimal`, which is digits with an optional\n * sign and point -- no exponent, no hexadecimal. `Number()` is far more\n * generous: it reads `0x10` as 16 and `1e1` as 10, so a file using either\n * imported a position it never expressed.\n */\nfunction decimal(value: string | undefined): number {\n return value !== undefined && /^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)$/.test(value) ? Number(value) : Number.NaN\n}\n\n/**\n * Whether a position is one GPX can express.\n *\n * Latitude beyond ±90 or longitude beyond ±180 is not a place. Such a value\n * reaching the store stretches every bounding box the track appears in, and\n * writing one produces a document a validating reader rejects — so it is\n * refused in both directions rather than trusted from a file or passed on.\n */\nfunction inRange(latitude: number, longitude: number): boolean {\n return (\n Number.isFinite(latitude) &&\n Number.isFinite(longitude) &&\n latitude >= -90 &&\n latitude <= 90 &&\n longitude >= -180 &&\n longitude <= 180\n )\n}\n","import { BehaviorSubject, combineLatest, connectable, firstValueFrom, ReplaySubject, Subject } from 'rxjs'\nimport type { Connectable, Observable } from 'rxjs'\nimport { map, scan, startWith, throttleTime } from 'rxjs/operators'\nimport { thin } from './timeWindow.js'\nimport type { TrackQuery } from './timeWindow.js'\nimport type { TrackStore } from './store.js'\nimport type {\n Context,\n Debug,\n LatLngTuple,\n TimedPosition,\n TimedTrackCollection,\n TimeWindow,\n TrackCollection,\n TrackParams,\n} from './types.js'\nimport { createMatcher } from './utils.js'\n\ninterface TracksMap {\n [context: string]: TrackAccumulator\n}\n\ninterface VesselTrack {\n context: string\n track: LatLngTuple[]\n}\n\nexport interface TracksConfig {\n resolution: number\n pointsToKeep: number\n fetchInitialTrack?: boolean\n}\n\n/**\n * The in-memory store: a bounded sliding window of positions per context, held\n * in RxJS accumulators and lost on restart.\n *\n * No longer used by the plugin, which always records to SQLite. It remains as\n * the client-side `TrackAccumulator` this package exports, where a browser\n * holding one vessel's recent track in memory is exactly the right shape.\n */\nexport class Tracks implements TrackStore {\n tracks: TracksMap = {}\n debug: Debug\n config: TracksConfig\n constructor(config: TracksConfig, debug: Debug) {\n if (debug.enabled) {\n debug(JSON.stringify(config))\n }\n this.config = config\n this.debug = debug\n }\n\n newPosition(context: Context, position: LatLngTuple, timestamp?: number): void {\n this.getAccumulator(context)?.nextLatLngTuple(position, timestamp)\n }\n\n initialTrack(context: Context, track: LatLngTuple[], timestamps?: number[]): void {\n this.getAccumulator(context)?.setInitialTrack(track, timestamps)\n }\n\n getAccumulator(context: Context, createIfMissing = true): TrackAccumulator | undefined {\n if (context.indexOf('vessels.') === -1 && context.indexOf('aircraft.') === -1) {\n return undefined\n }\n let result = this.tracks[context]\n if (!result && createIfMissing) {\n const accParams: AccumulatorParams = this.config.fetchInitialTrack\n ? { ...this.config, fetchTrackFor: context }\n : { ...this.config }\n result = this.tracks[context] = new TrackAccumulator(accParams)\n }\n return result\n }\n\n get(context: Context, window?: TimeWindow): Promise<LatLngTuple[]> {\n return this.getTimed(context, window).then((points) => points.map(({ position }) => position))\n }\n\n /**\n * Track points with their timestamps, optionally narrowed to a time window.\n *\n * Rejects with EmptyError if the stream completes without emitting; the route\n * handlers turn that into the same 404 as an unknown context.\n */\n getTimed(context: Context, window?: TimeWindow): Promise<TimedPosition[]> {\n const accumulator = this.getAccumulator(context, false)\n if (!accumulator) {\n return Promise.reject(new Error(`No track accumulator for ${context}`))\n }\n return firstValueFrom(accumulator.timedTrack).then((points) =>\n window\n ? points.filter(\n ({ timestamp }) =>\n timestamp >= window.from && (window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to),\n )\n : points,\n )\n }\n\n getAllTracks(query?: TrackQuery): Promise<VesselTrack[]> {\n return Promise.all(\n Object.keys(this.tracks).map((context) =>\n this.getTimed(context, query?.window).then((points) => ({\n context,\n track: thin(points, query?.resolution).map(({ position }) => position),\n })),\n ),\n )\n }\n\n // Return all / filtered vessels and their tracks\n async getFilteredTracks(\n params: TrackParams,\n selfPosition?: LatLngTuple,\n debug?: Debug,\n query?: TrackQuery,\n ): Promise<TrackCollection> {\n const timed = await this.getFilteredTimedTracks(params, selfPosition, debug, query)\n return Object.fromEntries(\n Object.entries(timed).map(([context, points]) => [context, points.map(({ position }) => position)]),\n )\n }\n\n /**\n * The same filtering as `getFilteredTracks`, keeping each point's timestamp.\n *\n * The untimed form is derived from this one so the two cannot disagree about\n * which tracks match — the filter runs once, here.\n */\n async getFilteredTimedTracks(\n params: TrackParams,\n selfPosition?: LatLngTuple,\n debug?: Debug,\n query?: TrackQuery,\n ): Promise<TimedTrackCollection> {\n this.debug(params)\n this.debug('Self position', selfPosition)\n const matcher = createMatcher(params, selfPosition, debug)\n\n const requested = params.contexts ? new Set(params.contexts) : undefined\n const contexts = Object.keys(this.tracks).filter((context) => !requested || requested.has(context))\n const tracks = await Promise.all(\n contexts.map((context) =>\n this.getTimed(context, query?.window).then((points) => ({\n context,\n points: thin(points, query?.resolution),\n })),\n ),\n )\n return tracks.reduce<TimedTrackCollection>((acc, { context, points }) => {\n // The matcher tests the last *position*, so it sees the same geometry it\n // always did; only what is carried alongside changed.\n if (matcher(points.map(({ position }) => position))) {\n acc[context] = points\n }\n return acc\n }, {})\n }\n\n /**\n * Remembered names, by context.\n *\n * In memory only: this store is the client-side accumulator, so there is no\n * restart for a stored name to survive. It implements the same contract so\n * the two stores stay interchangeable and the plugin needs no special case.\n */\n private readonly names = new Map<Context, { name: string; timestamp: number }>()\n\n recordName(context: Context, name: string, timestamp: number = Date.now()): void {\n const trimmed = name.trim()\n if (trimmed === '') {\n return\n }\n const seen = this.names.get(context)\n if (seen && seen.timestamp > timestamp) {\n return\n }\n this.names.set(context, { name: trimmed, timestamp })\n }\n\n nameFor(context: Context): string | undefined {\n return this.names.get(context)?.name\n }\n\n prune(maxAge: number, keep?: Context): void {\n const cutoff = Date.now() - maxAge\n const deleted: string[] = []\n Object.entries(this.tracks).forEach(([key, value]) => {\n if (key !== keep && value.latestLatLngTuple < cutoff) {\n delete this.tracks[key]\n // The remembered name goes with the track. Left behind it would both\n // grow unboundedly and, worse, hand a returning context its stale\n // label back -- the very thing resolving live first avoids.\n this.names.delete(key)\n deleted.push(key)\n }\n })\n if (this.debug.enabled) {\n this.debug(`deleted tracks for ${deleted.join(', ')}`)\n }\n }\n}\n\ninterface AccumulatorParams {\n resolution: number\n pointsToKeep: number\n fetchTrackFor?: string\n}\n\nexport class TrackAccumulator {\n initialTrack: Subject<TimedPosition[]> = new BehaviorSubject<TimedPosition[]>([])\n input: Subject<TimedPosition> = new Subject()\n latestLatLngTuple = 0\n accumulatedTrack: Connectable<TimedPosition[]>\n /** Timestamped points, used to answer time-window queries. */\n timedTrack: Observable<TimedPosition[]>\n /** Positions only — the long-standing public shape. */\n track: Observable<LatLngTuple[]>\n\n constructor({ resolution, pointsToKeep, fetchTrackFor }: AccumulatorParams) {\n // rxjs 7 replaces the publishReplay operator + ConnectableObservable cast\n // with an explicit connectable(); the ReplaySubject(1) connector preserves\n // the replay-latest behaviour a late subscriber depends on.\n this.accumulatedTrack = connectable(\n this.input.pipe(\n throttleTime(resolution),\n scan<TimedPosition, TimedPosition[]>((acc, point) => {\n acc.push(point)\n return acc.slice(Math.max(0, acc.length - pointsToKeep))\n }, []),\n // combineLatest below only emits once every source has, and `scan` stays\n // silent until the first live position. Without this an accumulator that\n // has only been given an initial track, before any live delta, never\n // yields a readable track.\n startWith<TimedPosition[]>([]),\n ),\n { connector: () => new ReplaySubject<TimedPosition[]>(1), resetOnDisconnect: false },\n )\n this.timedTrack = combineLatest([this.initialTrack, this.accumulatedTrack]).pipe(\n map(([initialTrack, accumulatedTrack]) => [...initialTrack, ...accumulatedTrack]),\n )\n this.track = this.timedTrack.pipe(map((points) => points.map(({ position }) => position)))\n this.accumulatedTrack.connect()\n\n if (fetchTrackFor) {\n void fetchTrack(fetchTrackFor).then((trackGEOJson) => {\n const coordinates = trackGEOJson?.coordinates?.[0]\n if (coordinates) {\n // The fetched track carries no timestamps; date them at the start of\n // time so a time-window query treats them as older than anything live.\n this.initialTrack.next(coordinates.map((position) => ({ position, timestamp: 0 })))\n }\n })\n }\n }\n\n nextLatLngTuple(position: LatLngTuple, timestamp: number = Date.now()): void {\n this.input.next({ position, timestamp })\n this.latestLatLngTuple = timestamp\n }\n\n setInitialTrack(track: LatLngTuple[], timestamps?: number[]): void {\n this.initialTrack.next(track.map((position, i) => ({ position, timestamp: timestamps?.[i] ?? 0 })))\n }\n}\n\ninterface TrackGeoJson {\n coordinates?: LatLngTuple[][]\n}\n\n// Browser-side helper for the exported TrackAccumulator: only reachable when a\n// webapp constructs one with fetchInitialTrack, never on the server.\nconst fetchTrack = (context: Context): Promise<TrackGeoJson> => {\n const contextParts = context.split('.')\n if (contextParts[0] !== 'vessels') {\n return Promise.resolve({})\n }\n return fetch(`/signalk/v1/api/vessels/${contextParts[1]}/track`, {\n credentials: 'include',\n }).then((r) => (r.status === 200 ? (r.json() as Promise<TrackGeoJson>) : Promise.resolve({})))\n}\n","/*\n * Copyright 2021 Teppo Kurki <teppo.kurki@iki.fi>\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Temporal } from '@js-temporal/polyfill'\nimport type { Request, RequestHandler, Response, Router } from 'express'\nimport { join } from 'node:path'\nimport { createTrackProvider } from './trackProvider.js'\nimport type { TrackApi } from './trackApi.js'\nimport { AsyncTrackStore } from './asyncTrackStore.js'\nimport type { TrackStore } from './store.js'\nimport { DEFAULT_MAX_SPEED_KNOTS, GlitchFilter } from './glitchFilter.js'\nimport { fillFromHistory, reconcile } from './reconcile.js'\nimport { SourceWatch } from './sourceWatch.js'\nimport { DEFAULT_PAUSE_STATES, PAUSABLE_STATES, StateGate } from './stateGate.js'\nimport { toGpx } from './gpx.js'\nimport { parseTrackQuery, segment, thin, TimeWindowError } from './timeWindow.js'\nimport type { TrackQuery } from './timeWindow.js'\nimport type {\n Context,\n Debug,\n LatLngTuple,\n LngLatTuple,\n Position,\n TimedPosition,\n TimeWindow,\n TimedTrackCollection,\n TrackCollection,\n TrackParams,\n} from './types.js'\nimport {\n historyRowPosition,\n resolveContext,\n toIsoTimes,\n validateParameters,\n trackLabel,\n contextName,\n gpxFilename,\n asciiFilename,\n rfc8187,\n unwrapString,\n QueryParameterError,\n SelfPositionUnavailableError,\n} from './utils.js'\n\nexport interface ContextPosition {\n context: Context\n value: Position\n /** ISO-8601 time the value was recorded, as carried by the Signal K delta. */\n timestamp?: string\n /**\n * Which source produced the value.\n *\n * The bus is priority-filtered, so normally this is the one source the\n * server selected. Seeing several for one context means no priority rule is\n * matching the path — which is what `SourceWatch` reports on.\n */\n $source?: string\n}\n\ninterface AllTracksResult {\n [context: string]: {\n type: 'MultiLineString'\n coordinates: LngLatTuple[][]\n /** Present only when `?times` was asked for; aligned with `coordinates`. */\n times?: string[][]\n /**\n * Whether this is the own vessel's track.\n *\n * Stated rather than left to the client: telling own vessel from an AIS\n * target otherwise means string-matching the context against the server's\n * self identity, which a client can only do if it has fetched that\n * separately and knows the `vessels.self` alias resolves to a `urn:mrn:`\n * context. Getting it wrong draws someone else's track as your own.\n */\n isSelf: boolean\n /**\n * A human label for the track, as a chart plotter would show it.\n *\n * `Own Ship` or `AIS <shipname>`, falling back to the MMSI. A client\n * listing tracks otherwise has to render a `urn:mrn:` context, and no\n * user recognises their own boat that way.\n */\n name: string\n }\n}\n\n// Minimal History API types (from @signalk/server-api)\n// Defined locally to avoid a hard dependency on a specific server-api version\ninterface HistoryValuesQuery {\n context: string\n from: Temporal.Instant\n to: Temporal.Instant\n pathSpecs: { path: string; aggregate: string }[]\n resolution: number\n}\n\ninterface HistoryApi {\n getValues(query: HistoryValuesQuery): Promise<HistoryValuesResponse>\n /**\n * Contexts the provider holds data for, within a time range.\n *\n * Optional here though the upstream interface requires it: a provider\n * registered against an older server-api may not implement it, and a missing\n * method must degrade rather than throw.\n */\n getContexts?(query: HistoryContextsQuery): Promise<unknown[]>\n}\n\n/** `ContextsRequest` upstream: a time range, with Instants rather than strings. */\ninterface HistoryContextsQuery {\n from: Temporal.Instant\n to: Temporal.Instant\n}\n\ninterface HistoryValuesResponse {\n context: string\n range: { from: string; to: string }\n values: unknown[]\n /** Each element: [timestamp_string, [lon, lat]] */\n data: unknown[]\n}\n\ninterface App {\n debug: Debug\n error: (...args: unknown[]) => void\n streambundle: {\n getBus: (path: string) => {\n onValue: (cb: (x: ContextPosition) => void) => () => void\n }\n }\n getSelfPath: (path: string) => unknown\n /**\n * Read any path in the full data model, including other vessels'.\n *\n * `getSelfPath` only reaches the own vessel, so naming an AIS track needs\n * this. Optional: absent on older servers, where tracks fall back to the\n * MMSI parsed out of the context.\n */\n getPath?: (path: string) => unknown\n selfContext: string\n /** Shown against the plugin in the server's dashboard. Absent on older servers. */\n setPluginStatus?: (msg: string) => void\n /** Plugin-private directory for persistent data; absent on older servers. */\n getDataDirPath?: () => string\n /** Resolves the named provider, or the configured default when omitted. */\n getHistoryApi?: (providerId?: string) => Promise<HistoryApi>\n /**\n * Offer this plugin's tracks to the v2 Track API. Absent on servers older\n * than SignalK/signalk-server#2995.\n *\n * The server unregisters the provider itself when the plugin stops, so\n * `stop()` here must not do it a second time.\n */\n registerTrackApiProvider?: (provider: TrackApi) => void\n config?: {\n settings?: {\n historyApi?: { defaultProvider?: string }\n }\n }\n}\n\ninterface Plugin {\n start: (c: TracksPluginConfig) => void | Promise<void>\n /** Enable on install, without waiting for a visit to the plugin config page. */\n enabledByDefault: boolean\n stop: () => Promise<void>\n signalKApiRoutes: (r: Router) => Router\n id: string\n name: string\n description: string\n schema: Record<string, unknown>\n /**\n * The live accumulator, or undefined before `start()`.\n *\n * Exposed so a track can be installed or inspected without going through the\n * position bus, which throttles against the wall clock. The Signal K server\n * does not use this.\n */\n getTracks: () => TrackStore | undefined\n}\n\ninterface TracksPluginConfig {\n resolution?: number\n /** Days another vessel is kept after its last fix. 0 keeps every vessel. */\n aisRetentionDays?: number\n /** Minutes without a fix that start a new track segment. 0 disables. */\n segmentGapMinutes?: number\n /** Speed above which a position is treated as a glitch. 0 disables. */\n maxSpeedKnots?: number\n /** navigation.state values that pause recording of the own vessel. */\n pauseWhenState?: string[]\n}\n\nconst toLngLat = ([lat, lng]: LatLngTuple): LngLatTuple => [lng, lat]\n\nconst DEFAULT_RESOLUTION = 60000\n// How long a vessel other than the own one is kept after its last fix. The own\n// vessel is never dropped — see TrackStore.prune. A month is long enough that a\n// passage last week is still there to compare against, and short enough that a\n// season in a busy harbour does not accumulate every target that ever passed.\nconst DEFAULT_AIS_RETENTION_DAYS = 30\n\n// A vessel that has aged out is not urgent, so this need not be frequent.\nconst PRUNE_INTERVAL_MS = 60 * 60 * 1000\n// Off by default. Segmenting changes the shape of every response, and measured\n// against real AIS traffic a 5-minute rule split 61 of 879 gaps that were just\n// a slow-updating target rather than a stop. Opt in, and pick a threshold that\n// suits the fleet being watched.\nconst DEFAULT_SEGMENT_GAP_MINUTES = 0\n\n// Long enough that a boat with one GPS never pays for the check in practice,\n// short enough that the warning appears while the user is still looking at the\n// plugin page after enabling it.\nconst SOURCE_STATUS_INTERVAL_MS = 30000\n\n/**\n * How long a history provider gets to answer a query before the store answers\n * alone.\n *\n * The provider is an enrichment, not a dependency: a track is still correct\n * without it, just coarser. Long enough for a database that has to warm up,\n * short enough that a wedged provider does not hold a request open.\n */\nconst HISTORY_QUERY_TIMEOUT_MS = 5000\n\n/**\n * How far back a query with no window of its own reaches when the store is\n * empty.\n *\n * There is nothing else to derive a span from, and asking a provider for all\n * of time would be expensive on a large one. A day covers the usual reason for\n * a windowless request — draw the recent trail — and an explicit `from`,\n * `duration` or `timespan` reaches further.\n */\nconst WINDOWLESS_HISTORY_SPAN_MS = 24 * 60 * 60 * 1000\n\n/**\n * The earliest instant the existence probe asks about.\n *\n * The probe has to name a bound: the History API's time range has no\n * unbounded form, every branch of `TimeRangeParams` carries one. A provider\n * filters its context list by the range it is given — questdb builds a SQL\n * `WHERE` from it — so any bound later than the provider's oldest row can\n * still miss a vessel and 404 it, which is the case the probe exists to catch.\n *\n * The Unix epoch is the bound rather than some span of years: it predates\n * satellite navigation, so no position fix can lie before it, and unlike a\n * fixed \"wide enough\" window it cannot be outlived. The cost lands only on the\n * branch that was already about to 404, and the answer is cached.\n */\nconst EXISTENCE_PROBE_FROM_MS = 0\n\n/**\n * The most buckets one history read may ask a provider for.\n *\n * A provider has no spatial filter, so a box query reads every position in\n * its window, and the cost of that read grows with the window's span over the\n * resolution, not with what is drawn. Providers fabricate a row for every\n * bucket in the range — questdb fills empty ones with nulls — and refuse a\n * range past their own cap, which one-minute buckets over two years already\n * exceed. A month of one-minute buckets keeps a Pi-class server responsive;\n * a longer window is read at a wider resolution instead.\n */\nconst HISTORY_BUCKET_BUDGET = 30 * 24 * 60\n\n/**\n * How many buckets the read that finds where a provider's data begins asks\n * for.\n *\n * A window with no start would otherwise reach back to the Unix epoch, and\n * spread over fifty-odd years the bucket budget leaves buckets weeks wide.\n * One coarse read first finds the earliest bucket holding a position, so the\n * real read starts there. A thousand buckets over the whole epoch are about\n * three weeks each, which only sets how much empty time the real read starts\n * with.\n */\nconst START_PROBE_BUCKETS = 1000\n\n/**\n * How long a provider's context list is reused before asking again.\n *\n * The probe runs only for a vessel about to 404, which is precisely the\n * request a client can repeat without limit — the routes are open, so\n * enumerating vessel ids would otherwise drive one epoch-wide `getContexts`\n * per request, each able to block for the timeout.\n *\n * The whole list is cached rather than a per-context answer, or enumeration\n * would simply fill the cache with distinct keys and query just as often. The\n * cost is that a vessel a provider learns about becomes visible up to this\n * long after the fact, which is not a delay anyone can perceive in a track\n * that is already minutes old.\n */\nconst KNOWN_CONTEXTS_TTL_MS = 30_000\n\n/**\n * How long a *failed* context query is remembered.\n *\n * Short, so a provider that recovers is noticed quickly, but not zero: with\n * the entry simply dropped, a provider that hangs is asked again by every\n * subsequent miss while the earlier calls are still pending — reinstating per\n * request exactly the cost the cache exists to prevent.\n */\nconst KNOWN_CONTEXTS_FAILURE_TTL_MS = 1_000\n\n/**\n * Config values arrive from the plugin UI as numbers, but a hand-edited\n * settings file can supply strings. Accept both, reject anything non-finite so\n * a bad value falls back to the default instead of poisoning arithmetic with NaN.\n */\nconst toNumber = (value: unknown): number | undefined => {\n if (typeof value === 'number') {\n return Number.isFinite(value) ? value : undefined\n }\n if (typeof value === 'string' && value.trim() !== '') {\n const parsed = Number(value)\n return Number.isFinite(parsed) ? parsed : undefined\n }\n return undefined\n}\n\nconst notAvailable = (res: Response) => {\n res.status(404)\n res.json({ message: 'Tracks API not available because tracks plugin is not enabled' })\n}\n\n/**\n * The history provider could not answer, and nothing else could either.\n *\n * Distinct from a missing track: a 404 asserts that no source knows the\n * vessel, which a failed provider read cannot support. Reporting the failure\n * as an empty track would present an outage as \"this vessel has no positions\n * here\" — the one answer that is certainly wrong.\n */\nclass HistoryUnavailableError extends Error {}\n\nconst errorDetail = (err: unknown): string => (err instanceof Error && err.stack ? err.stack : String(err))\n\n/**\n * A window covering everything the store holds, up to now.\n *\n * Used when a query names no window of its own, so a history provider can\n * still be asked something concrete. Undefined when the store is empty, since\n * there is then no span to ask about.\n */\nfunction windowSpanning(stored: TimedPosition[], fallbackMs: number): TimeWindow {\n const to = Date.now()\n if (stored.length === 0) {\n // Nothing stored says nothing about what a provider holds: a vessel\n // recorded only by the provider — before this plugin was installed, say —\n // would otherwise never be asked about and 404.\n return { from: to - fallbackMs, to, inclusiveEnd: true }\n }\n let from = stored[0]!.timestamp\n for (const point of stored) {\n if (point.timestamp < from) from = point.timestamp\n }\n return { from, to, inclusiveEnd: true }\n}\n\n/**\n * Reject once `ms` has passed.\n *\n * Written out rather than `Promise.race` so the timer is cleared when the\n * promise wins. A race leaves it armed, holding the event loop open for the\n * remainder of the timeout on every call that succeeds.\n */\n/**\n * A bound expired. Distinct from the error a provider itself raises, because\n * \"did not answer in time\" and \"is not installed\" are different answers and\n * only one of them is an outage.\n */\nclass HistoryTimeoutError extends Error {}\n\nfunction withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new HistoryTimeoutError(`timed out after ${ms}ms`)), ms)\n promise.then(\n (value) => {\n clearTimeout(timer)\n resolve(value)\n },\n (err: unknown) => {\n clearTimeout(timer)\n reject(err instanceof Error ? err : new Error(String(err)))\n },\n )\n })\n}\n\n/**\n * A cache entry holding the promise rather than its value, so callers that\n * arrive while a query is still running join it instead of starting another.\n */\ninterface KnownContexts {\n at: number\n contexts: Promise<ReadonlySet<string>>\n}\n\n/** A provider's context list as a set, with the `vessels.self` spelling resolved. */\nconst toContextSet = (listed: unknown[] | undefined, selfContext: string): ReadonlySet<string> =>\n new Set(\n (listed ?? [])\n .filter((c): c is string => typeof c === 'string')\n .map((c) => (c === 'vessels.self' ? selfContext : c)),\n )\n\n/**\n * Whether a history provider holds anything at all for a context.\n *\n * Asked only when a track is about to 404: `getValues` narrowed to a window\n * cannot tell \"this vessel is unknown\" from \"this vessel has nothing *here*\",\n * and 404ing on the second is wrong — the contract is that a 404 means neither\n * source knows the vessel. A vessel whose history lies wholly outside the\n * asked window is known, and deserves an empty track.\n *\n * Best-effort, like every other provider call here: a provider that lacks the\n * method, errors, or hangs leaves the 404 standing rather than holding the\n * request open.\n */\nasync function historyKnowsContext(\n app: App,\n context: Context,\n debug: Debug,\n cache: { current: KnownContexts | undefined },\n): Promise<boolean> {\n const getHistoryApi = app.getHistoryApi\n if (!getHistoryApi) {\n return false\n }\n try {\n const now = Date.now()\n // Reused while fresh, and shared while in flight: a burst of misses is one\n // question to the provider, not one each. The promise is cached rather than\n // its result so concurrent callers join the same query instead of starting\n // their own.\n //\n // Resolving the provider is inside the cached promise, not before it: a\n // slow or failing `getHistoryApi` would otherwise be paid per request, and\n // could time out into a 404 for a vessel the cached list already knows.\n const cached = cache.current\n if (cached === undefined || now - cached.at >= KNOWN_CONTEXTS_TTL_MS) {\n const contexts = withTimeout(\n getHistoryApi(app.config?.settings?.historyApi?.defaultProvider)\n .then((historyApi) =>\n historyApi.getContexts\n ? historyApi.getContexts({\n from: Temporal.Instant.from(new Date(EXISTENCE_PROBE_FROM_MS).toISOString()),\n to: Temporal.Instant.from(new Date(now).toISOString()),\n })\n : // A provider built against an older server-api has no such\n // method. Nothing is known, and that is a cacheable answer.\n [],\n )\n // Indexed once per fetch rather than scanned per request: the branch\n // that consults this is the one an open route lets a client repeat\n // without limit, and a provider on a busy install lists thousands of\n // vessels. The `vessels.self` spelling at least one provider returns\n // is resolved here, while `app.selfContext` is a single fixed value,\n // so the lookup itself is a plain membership test.\n .then((listed) => toContextSet(listed, app.selfContext)),\n HISTORY_QUERY_TIMEOUT_MS,\n )\n // Dropped on failure so the next miss retries rather than inheriting a\n // rejection for the rest of the window. The catch is attached here, not\n // awaited, so a rejection never escapes as an unhandled one.\n const entry: KnownContexts = { at: now, contexts }\n contexts.catch(() => {\n if (cache.current === entry) {\n // Back-dated rather than dropped: the next miss after the failure\n // window retries, while a burst inside it does not re-ask.\n cache.current = { at: now - KNOWN_CONTEXTS_TTL_MS + KNOWN_CONTEXTS_FAILURE_TTL_MS, contexts }\n }\n })\n cache.current = entry\n }\n return (await cache.current!.contexts).has(context)\n } catch (err) {\n debug(`History contexts unavailable for ${context}: ${errorDetail(err)}`)\n return false\n }\n}\n\n/**\n * Contexts a history provider holds data for within a window, or none.\n *\n * What lets the v2 provider return a vessel the store has never seen, or match\n * a box on positions only history recorded: the store is often far younger\n * than the provider. Best-effort like every other provider call: no provider,\n * a provider without `getContexts`, an error or a hang all answer with no\n * contexts, and the store answers alone.\n *\n * A window with no start is asked from the epoch, for the same reason the\n * existence probe is; without any window the probe's cached all-time list is\n * reused rather than asking again per query.\n */\nasync function historyContextsIn(\n app: App,\n window: TimeWindow | undefined,\n debug: Debug,\n cache: { current: KnownContexts | undefined },\n): Promise<ReadonlySet<string>> {\n const getHistoryApi = app.getHistoryApi\n if (!getHistoryApi) {\n return new Set()\n }\n try {\n if (!window) {\n await historyKnowsContext(app, app.selfContext, debug, cache)\n return (await cache.current?.contexts) ?? new Set()\n }\n const historyApi = await withTimeout(\n getHistoryApi(app.config?.settings?.historyApi?.defaultProvider),\n HISTORY_QUERY_TIMEOUT_MS,\n )\n if (!historyApi.getContexts) {\n return new Set()\n }\n const listed = await withTimeout(\n historyApi.getContexts({\n from: Temporal.Instant.from(new Date(Math.max(window.from, EXISTENCE_PROBE_FROM_MS)).toISOString()),\n to: Temporal.Instant.from(new Date(window.to).toISOString()),\n }),\n HISTORY_QUERY_TIMEOUT_MS,\n )\n return toContextSet(listed, app.selfContext)\n } catch (err) {\n if (debug.enabled) {\n debug(`History contexts unavailable: ${errorDetail(err)}`)\n }\n return new Set()\n }\n}\n\n/**\n * Positions a history provider holds for a window, or none.\n *\n * Best-effort by design: a provider that is absent, slow, or failing must not\n * fail the query, because the plugin's own store can always answer it. The\n * provider is the finer source where it reaches, not a required one.\n */\nasync function historyPositions(\n app: App,\n context: Context,\n window: TimeWindow,\n resolutionMs: number,\n debug: Debug,\n): Promise<{ points: TimedPosition[]; resolutionMs: number; coarsened: boolean; failed: boolean }> {\n // The API takes whole seconds, so the width the provider actually bucketed\n // by is not necessarily the one asked for. Reconciling on the requested\n // width would leave a stored point in a bucket history already covered, and\n // keep both.\n const askedSeconds = Math.max(1, Math.round(resolutionMs / 1000))\n const none = (failed: boolean) => ({ points: [], resolutionMs: askedSeconds * 1000, coarsened: false, failed })\n const getHistoryApi = app.getHistoryApi\n if (!getHistoryApi) {\n // Not a failure: no provider installed is the documented normal case, and\n // the store answers alone. Only a provider that was asked and could not\n // answer counts as one.\n return none(false)\n }\n // Resolved in its own step, because failing to reach a provider and failing\n // to read one are different answers. The server rejects this call outright\n // when no provider is registered — the default install — and a service that\n // is not installed cannot be having an outage. Only a read that fails after\n // a provider was obtained is one.\n let historyApi: HistoryApi\n try {\n // Bounded because both awaits reach third-party code. Without this a\n // provider that never settles holds the request open, and the store\n // fallback below is never reached — which would make the \"best-effort\"\n // this function promises untrue.\n historyApi = await withTimeout(\n getHistoryApi(app.config?.settings?.historyApi?.defaultProvider),\n HISTORY_QUERY_TIMEOUT_MS,\n )\n } catch (err) {\n // A provider that was registered but did not answer in time is an outage;\n // only the server's own rejection means there is nothing installed to ask.\n const timedOut = err instanceof HistoryTimeoutError\n if (debug.enabled) {\n debug(`${timedOut ? 'History provider timed out' : 'No history provider'} for ${context}: ${errorDetail(err)}`)\n }\n return none(timedOut)\n }\n const read = async (from: number, seconds: number, throughEnd = false): Promise<TimedPosition[]> => {\n const response = await withTimeout(\n historyApi.getValues({\n context,\n // Instants, not ISO strings: providers call Instant methods on these.\n from: Temporal.Instant.from(new Date(from).toISOString()),\n to: Temporal.Instant.from(new Date(window.to).toISOString()),\n pathSpecs: [{ path: 'navigation.position', aggregate: 'first' }],\n resolution: seconds,\n }),\n HISTORY_QUERY_TIMEOUT_MS,\n )\n const points: TimedPosition[] = []\n for (const row of response?.data ?? []) {\n const position = historyRowPosition(row)\n // A row with no position means the provider had none for that bucket,\n // which is what lets the store fill it.\n if (position) {\n const timestamp = historyRowTimestamp(row)\n // Clipped to the window rather than trusted: a provider that returns a\n // wider range would otherwise widen the answer beyond what was asked.\n // Matching the stores, which treat a window without `inclusiveEnd`\n // as half-open so consecutive bands tile without repeating the point\n // they share. Clipping inclusively here would reintroduce exactly\n // that duplicate from the provider side.\n const withinEnd = window.inclusiveEnd || throughEnd ? timestamp <= window.to : timestamp < window.to\n if (timestamp >= window.from && withinEnd) {\n // A bucket, not a fix: its width keeps segment() from reading the\n // spacing between buckets as a stop in the recording.\n points.push({ position, timestamp, span: seconds * 1000 })\n }\n }\n }\n return points\n }\n try {\n // A window with only an end starts at minus infinity, which no Date can\n // hold, so it is read from where the provider's data begins.\n let from = Math.max(window.from, EXISTENCE_PROBE_FROM_MS)\n if (!Number.isFinite(window.from)) {\n const probeSeconds = Math.max(askedSeconds, Math.ceil((window.to - from) / 1000 / START_PROBE_BUCKETS))\n // Through the end: a provider that stamps buckets on their end can put\n // the last one exactly there, and the probe only needs to know where\n // data begins, not to tile with a neighbouring window.\n const probed = await read(from, probeSeconds, true)\n if (probed.length === 0) {\n return none(false)\n }\n // Back by one probe bucket, since providers disagree on whether a\n // bucket's timestamp marks its start or its end.\n const earliest = Math.min(...probed.map(({ timestamp }) => timestamp))\n from = Math.max(from, earliest - probeSeconds * 1000)\n }\n const providerSeconds = Math.max(askedSeconds, Math.ceil((window.to - from) / 1000 / HISTORY_BUCKET_BUDGET))\n const points = await read(from, providerSeconds)\n if (debug.enabled) {\n debug(`History supplied ${points.length} position(s) for ${context} at ${providerSeconds}s`)\n }\n return { points, resolutionMs: providerSeconds * 1000, coarsened: providerSeconds > askedSeconds, failed: false }\n } catch (err) {\n if (debug.enabled) {\n debug(`History unavailable for ${context}: ${errorDetail(err)}`)\n }\n // Reported rather than swallowed. The store still answers where it can —\n // a provider is an enrichment, not a dependency — but a caller with\n // nothing else to serve has to be able to tell \"no positions\" from \"could\n // not ask\".\n return none(true)\n }\n}\n\n/**\n * A track from the store and what history holds for it.\n *\n * History is the finer source where it reaches, so it wins its buckets —\n * unless the read was widened to stay within its budget, when it is the\n * coarser one and only fills what the store lacks.\n */\nconst withHistory = (\n history: { points: TimedPosition[]; resolutionMs: number; coarsened: boolean },\n stored: TimedPosition[],\n): TimedPosition[] =>\n history.points.length === 0\n ? stored\n : (history.coarsened ? fillFromHistory : reconcile)(history.points, stored, history.resolutionMs).positions\n\n/** Epoch milliseconds for a history row, or 0 when the timestamp is unusable. */\nconst historyRowTimestamp = (row: unknown): number => {\n const raw: unknown = Array.isArray(row) ? row[0] : undefined\n if (typeof raw !== 'string') {\n return 0\n }\n const parsed = Date.parse(raw)\n return Number.isNaN(parsed) ? 0 : parsed\n}\n\nexport default function ThePlugin(app: App): Plugin {\n /**\n * The server's own lookup, falling back to what the store remembers.\n *\n * Live wins whenever the data model has an answer, which keeps the rule the\n * resolvers were written for: an AIS static report arriving late must be\n * able to correct or replace what was known. The stored value only covers\n * the window where the model knows nothing -- after a restart, or for a\n * vessel that has since sailed out of range.\n */\n const remembering =\n (store: () => TrackStore | undefined) =>\n (path: string): unknown => {\n const live = app.getPath?.(path)\n if (live !== undefined) {\n return live\n }\n const match = /^(.*)\\.name$/.exec(path)\n return match ? store()?.nameFor(match[1] as Context) : undefined\n }\n\n let onStop: (() => void)[] = []\n let tracks: TrackStore | undefined = undefined\n let segmentGap = 0\n /**\n * How often the store keeps a position, in ms.\n *\n * Used as the bucket width when reconciling with a history provider, so the\n * two sources land on the same grid when a query names no resolution of its\n * own.\n */\n let storageError: string | undefined\n let storeResolution = DEFAULT_RESOLUTION\n const sourceWatch = new SourceWatch()\n let glitchFilter = new GlitchFilter({ maxSpeedKnots: DEFAULT_MAX_SPEED_KNOTS })\n let stateGate = new StateGate(app.selfContext, DEFAULT_PAUSE_STATES)\n /**\n * The history provider's context list, cached between existence probes.\n *\n * Per plugin instance rather than module-global: two instances would\n * otherwise answer from each other's provider.\n */\n const knownContexts: { current: KnownContexts | undefined } = { current: undefined }\n\n /** The own vessel's navigation.state, or undefined when it reports none. */\n function getVesselState(): string | undefined {\n const s = app.getSelfPath('navigation.state')\n if (s && typeof s === 'object' && 'value' in s) {\n const { value } = s as { value?: unknown }\n return typeof value === 'string' ? value : undefined\n }\n return undefined\n }\n\n function getVesselPosition(): LatLngTuple | undefined {\n const p = app.getSelfPath('navigation.position')\n if (p && typeof p === 'object' && 'value' in p) {\n const { value } = p as { value?: Partial<Position> }\n if (typeof value?.latitude === 'number' && typeof value.longitude === 'number') {\n return [value.latitude, value.longitude]\n }\n }\n return undefined\n }\n\n return {\n start: function (config: TracksPluginConfig) {\n storageError = undefined\n const { resolution } = config\n storeResolution = toNumber(config.resolution) ?? DEFAULT_RESOLUTION\n const segmentGapMinutes = toNumber(config.segmentGapMinutes) ?? DEFAULT_SEGMENT_GAP_MINUTES\n segmentGap = segmentGapMinutes > 0 ? segmentGapMinutes * 60 * 1000 : 0\n\n // Rebuilt on every start so a changed ceiling takes effect, and so the\n // reference positions do not survive a restart the user made precisely\n // because the track looked wrong.\n glitchFilter = new GlitchFilter({\n maxSpeedKnots: toNumber(config.maxSpeedKnots) ?? DEFAULT_MAX_SPEED_KNOTS,\n })\n stateGate = new StateGate(\n app.selfContext,\n Array.isArray(config.pauseWhenState) ? config.pauseWhenState : DEFAULT_PAUSE_STATES,\n )\n\n // getDataDirPath is what makes the file the server's to manage: backed up\n // and removed with the plugin. Every server that can load this plugin\n // provides it, so there is no in-memory fallback — a track recorder that\n // forgets everything on restart is not what anyone installs.\n const dataDir = app.getDataDirPath?.()\n if (!dataDir) {\n app.error('This server provides no plugin data directory, so tracks cannot be recorded.')\n return\n }\n\n // Always a fresh store: stop() has closed the previous handle, and a\n // restart must not keep one built with a resolution since changed.\n //\n // Guarded because opening a database touches the filesystem: a read-only\n // or full data directory throws here, and an uncaught throw out of\n // start() takes down more than this plugin.\n let starting: AsyncTrackStore\n try {\n starting = new AsyncTrackStore(\n {\n file: join(dataDir, 'tracks.db'),\n resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,\n // The own vessel's track is never aged out: it is the one worth\n // keeping, and a year of it at the default resolution is a few\n // megabytes. Other vessels are pruned by aisRetentionDays.\n retention: 0,\n segmentGap,\n },\n app.debug,\n (error) => {\n if (tracks !== starting) return\n storageError = `Could not use track database: ${errorDetail(error)}`\n app.error(storageError)\n app.setPluginStatus?.(storageError)\n },\n {},\n (message) => {\n if (tracks !== starting) return\n storageError = undefined\n app.error(message) // Keep a visible record of the recording gap.\n app.setPluginStatus?.(stateGate.status() ?? sourceWatch.warning(app.selfContext) ?? 'Recording tracks')\n },\n )\n tracks = starting\n } catch (err) {\n app.error(`Could not open the track database in ${dataDir}: ${errorDetail(err)}`)\n return\n }\n onStop.push(\n app.streambundle.getBus('navigation.position').onValue((update: ContextPosition): void => {\n if (!update.value || update.value.latitude == null || update.value.longitude == null) return\n sourceWatch.add(update.context, update.$source)\n // Captured here rather than by subscribing to `name`: the data model\n // already holds it by the time a position arrives, and a vessel with\n // no positions is not a track worth naming.\n //\n // Deliberately reads the model directly instead of going through\n // contextName, which now falls back to this very store -- routing\n // capture through it would re-date a months-old stored name as a\n // fresh observation on every position, defeating the ordering.\n //\n // Unwrapped the same way the resolvers read this field: the model\n // carries a bare string for some sources and the `{value}` wrapper\n // for others. Accepting only one shape here would let a name render\n // live and then vanish on restart, having never been captured.\n const live = unwrapString(app.getPath?.(`${update.context}.name`))\n if (live !== undefined) {\n tracks?.recordName(update.context, live)\n }\n // Prefer the delta's own timestamp so a replayed or delayed update is\n // filed at the time it was recorded, not the time it arrived.\n const recorded = update.timestamp === undefined ? undefined : Date.parse(update.timestamp)\n const timestamp = recorded !== undefined && !Number.isNaN(recorded) ? recorded : undefined\n const position: LatLngTuple = [update.value.latitude, update.value.longitude]\n // Read per delta rather than subscribed to: navigation.state changes\n // rarely, and the current value is what matters at the moment a\n // position arrives.\n if (!stateGate.accept(update.context, getVesselState())) {\n return\n }\n // Filtered here rather than in each store: both would otherwise need\n // the same check, and a rejected fix should reach neither.\n if (!glitchFilter.accept(update.context, position, timestamp ?? Date.now())) {\n return\n }\n tracks?.newPosition(update.context, position, timestamp)\n }),\n )\n // Days rather than the seconds the old maxAge used: this is about how long\n // a passing vessel stays interesting, not about clearing a live display.\n // 0 keeps every vessel forever.\n const aisRetentionMs = (toNumber(config.aisRetentionDays) ?? DEFAULT_AIS_RETENTION_DAYS) * 24 * 60 * 60 * 1000\n // prune() does two things: drop whole contexts that have gone quiet, and\n // apply the own vessel's row-level retention. They are configured\n // separately, so it runs whenever either is set — gating the call on the\n // AIS setting alone meant `aisRetentionDays: 0` silently disabled the\n // own vessel's retention too. A maxAge of Infinity ages nothing out.\n const pruneInterval = setInterval(() => {\n tracks?.prune(aisRetentionMs > 0 ? aisRetentionMs : Infinity, app.selfContext)\n }, PRUNE_INTERVAL_MS)\n onStop.push(() => {\n clearInterval(pruneInterval)\n })\n\n // Report on a timer rather than per delta: at 10Hz across several\n // vessels that would rewrite the status thousands of times a minute, and\n // the first fix from a second source is not yet evidence of a problem —\n // a source that appears once and stops is not worth a warning.\n // Reported rather than left to go stale: without the else branch the last\n // \"not recording\" message stayed on the dashboard after the vessel got\n // under way, which is worse than saying nothing at all.\n let lastStatus: string | undefined\n const statusInterval = setInterval(() => {\n // Being paused is the more useful thing to report: a user who has\n // opted in wants to see it is working, and one who has not wonders why\n // nothing is recording.\n const status = stateGate.status() ?? sourceWatch.warning(app.selfContext) ?? 'Recording tracks'\n if (status !== lastStatus) {\n lastStatus = status\n app.setPluginStatus?.(storageError ?? status)\n }\n }, SOURCE_STATUS_INTERVAL_MS)\n onStop.push(() => {\n clearInterval(statusInterval)\n })\n\n // Offer the accumulated tracks to the v2 Track API.\n //\n // The provider reads `tracks` through a getter rather than capturing it,\n // because start() replaces the store wholesale and a captured reference\n // would keep serving the store a restart was meant to discard.\n //\n // Deliberately not unregistered in stop(): the server pushes its own\n // unregister onto the plugin's stop handlers when this is called, so\n // doing it here as well would unregister twice.\n app.registerTrackApiProvider?.(\n createTrackProvider({\n store: () => tracks,\n selfContext: () => app.selfContext,\n segmentGap: () => segmentGap,\n contextName: (context: string) =>\n contextName(\n context,\n remembering(() => tracks),\n ),\n // The same reconciliation the v1 routes have done since #73. Without\n // it the plugin answers differently depending on which route a client\n // uses: the store alone through v2, the store enriched by a history\n // provider through v1.\n historyContexts: (window) => historyContextsIn(app, window, app.debug, knownContexts),\n reconcileWithHistory: async (context, stored, window, resolutionMs) => {\n const effective = resolutionMs ?? storeResolution\n // A query with no window still gets history, for the same reason\n // the v1 route does: a context recorded only by the provider —\n // before this plugin was installed — would otherwise never be asked\n // about.\n const asked = window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS)\n const history = await historyPositions(app, context, asked, effective, app.debug)\n // `failed` is deliberately not escalated here: the v2 contract has\n // no per-context error and no 404, so a provider outage degrades to\n // the store's own points rather than failing a multi-context query\n // for every other vessel in it.\n return withHistory(history, stored)\n },\n }),\n )\n return starting.ready.catch(async () => {\n // A failed start must release the worker before callers remove the data directory.\n await starting.close().catch(() => {})\n if (tracks === starting) tracks = undefined\n })\n },\n\n stop: async function () {\n onStop.forEach((f) => {\n try {\n f()\n } catch (err) {\n app.error(err)\n }\n })\n onStop = []\n // Forget which sources were seen: the point of a restart is often to\n // apply a source priority change, and carrying the old observations over\n // would keep warning about a setup that has just been fixed.\n sourceWatch.clear()\n glitchFilter.clear()\n stateGate.clear()\n // A restart often follows a provider change; keeping the old list would\n // answer from a provider that is no longer the one installed.\n knownContexts.current = undefined\n // Release the file handle a sqlite store holds, so a plugin restart does\n // not leak it and the WAL gets checkpointed.\n //\n // A stopped plugin serves nothing: closing the database releases the file\n // handle and checkpoints the WAL, so the routes answer 404 until the next\n // start() builds a fresh store. They stay mounted and degrade rather than\n // throw, because the server calls stop() on a config save.\n const closing = tracks\n tracks = undefined\n try {\n await closing?.close?.()\n } catch (err) {\n app.error(err)\n }\n },\n\n signalKApiRoutes: function (router: Router) {\n // Resolved per request rather than cached: an AIS target's static report\n // can arrive long after its first position, so a track named from the\n // MMSI early on picks up the real name as soon as the server has it.\n const nameOf = (context: string) =>\n trackLabel(\n context,\n app.selfContext,\n remembering(() => tracks),\n )\n\n /**\n * The points of one context, reconciled with history and segmented.\n *\n * Shared by the JSON and GPX routes so the two cannot answer the same\n * question differently: the history reconciliation, the windowless\n * fallback and the 404-only-for-an-unknown-vessel rule are subtle enough\n * that a second copy would drift.\n *\n * Rejects with `undefined` when neither source knows the vessel at all.\n */\n const readSegments = async (context: string, query: TrackQuery): Promise<TimedPosition[][] | undefined> => {\n const effectiveResolution = query.resolution ?? storeResolution\n const { points: stored, known } = await tracks!\n // Resolving instead of rejecting for an unknown context: a vessel\n // the store has never seen may still be in a history provider —\n // recorded before this plugin was installed, say — and 404ing\n // before asking would hide data that exists.\n .getTimed(context, query.window)\n .then((points) => ({ points, known: true }))\n .catch(() => ({ points: [] as TimedPosition[], known: false }))\n // A history provider is the finer source for as long as its retention\n // reaches; the store is what remains of everything older, and of any\n // period the provider missed. A query with no window still gets\n // history: `/self/track` with no parameters is the common case, and\n // skipping the provider there would quietly serve store-only data.\n const window = query.window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS)\n const history = await historyPositions(app, context, window, effectiveResolution, app.debug)\n const points = withHistory(history, stored)\n // 404 only for a vessel neither source knows at all. A known vessel\n // with nothing inside the window is an empty track, not a missing one.\n //\n // The provider is asked a second time here, and only here: `getValues`\n // narrowed to a window cannot distinguish an unknown vessel from one\n // whose history lies outside it. That second question is deliberately\n // not scoped to the window, for the same reason.\n if (points.length === 0) {\n // Either source knowing the vessel is enough: the store may hold it\n // with nothing inside the asked window, which is an empty track\n // rather than a missing one.\n const vesselKnown = known || (await historyKnowsContext(app, context, app.debug, knownContexts))\n if (!vesselKnown) {\n return undefined\n }\n // Known, but there is nothing to serve and the one source that might\n // have had something could not be read. Answering 200 here would\n // report an outage as an empty history.\n if (history.failed) {\n throw new HistoryUnavailableError(`History provider could not be read for ${context}`)\n }\n }\n return segment(thin(points, query.resolution), segmentGap)\n }\n\n const singleTrackHandler =\n (contextOf: (req: Request) => string): RequestHandler =>\n (req: Request, res: Response) => {\n if (!tracks) {\n notAvailable(res)\n return\n }\n const context = contextOf(req)\n let query: TrackQuery\n try {\n query = parseTrackQuery(req.query)\n } catch (err) {\n res.status(400)\n res.json({ message: err instanceof TimeWindowError ? err.message : 'Invalid query parameters' })\n return\n }\n readSegments(context, query)\n .then((segments) => {\n if (segments === undefined) {\n res.status(404)\n res.json({ message: `No track available for ${context}` })\n return\n }\n res.json({\n type: 'MultiLineString',\n coordinates: segments.map((points) => points.map(({ position }) => toLngLat(position))),\n ...(query.times ? { times: segments.map(toIsoTimes) } : {}),\n context,\n isSelf: context === app.selfContext,\n name: nameOf(context),\n })\n })\n .catch((err: unknown) => {\n // A provider outage is not a missing vessel. Reporting it as 404\n // told clients the track does not exist, which is the one thing\n // a failed read cannot establish.\n if (err instanceof HistoryUnavailableError) {\n app.error(`${err.message}`)\n res.status(503)\n res.json({ message: `Track history is temporarily unavailable for ${context}` })\n return\n }\n res.status(404)\n res.json({ message: `No track available for ${context}` })\n })\n }\n\n /**\n * One vessel's track as a GPX file.\n *\n * Serialised by `toGpx` rather than assembled here, so the export a user\n * downloads is the same document the module produces everywhere else.\n * The webapp cannot reach that module — it ships as static files with no\n * bundler — which is why the conversion lives behind a route instead of\n * being duplicated in the page.\n */\n const gpxHandler =\n (contextOf: (req: Request) => string): RequestHandler =>\n (req: Request, res: Response) => {\n if (!tracks) {\n notAvailable(res)\n return\n }\n const context = contextOf(req)\n let query: TrackQuery\n try {\n query = parseTrackQuery(req.query)\n } catch (err) {\n res.status(400)\n res.json({ message: err instanceof TimeWindowError ? err.message : 'Invalid query parameters' })\n return\n }\n readSegments(context, query)\n .then((segments) => {\n if (segments === undefined) {\n res.status(404)\n res.json({ message: `No track available for ${context}` })\n return\n }\n const label = nameOf(context)\n const filename = gpxFilename(label)\n res.type('application/gpx+xml')\n // Two forms, per RFC 5987. A header carries bytes, not text, so\n // `setHeader` throws ERR_INVALID_CHAR on a name outside Latin-1\n // -- which this route's catch would have reported as a 404, and\n // a name inside it would arrive mojibaked instead. The quoted\n // form is ASCII for readers that understand nothing else; the\n // `filename*` form carries the real name.\n res.setHeader(\n 'Content-Disposition',\n `attachment; filename=\"${asciiFilename(filename)}\"; filename*=UTF-8''${rfc8187(filename)}`,\n )\n res.send(toGpx([{ name: label, context, segments }]))\n })\n .catch((err: unknown) => {\n // Not a 404: `readSegments` already reported an unknown vessel by\n // resolving undefined, so anything reaching here is this route\n // failing -- serialisation, a header, a write. Reporting that as\n // \"no track available\" is what disguised an ERR_INVALID_CHAR\n // from a non-Latin-1 filename as a missing track.\n if (err instanceof HistoryUnavailableError) {\n app.error(`${err.message}`)\n if (!res.headersSent) {\n res.status(503)\n res.json({ message: `Track history is temporarily unavailable for ${context}` })\n }\n return\n }\n app.error(`Could not export GPX for ${context}: ${errorDetail(err)}`)\n if (!res.headersSent) {\n res.status(500)\n res.json({ message: `Could not export the track for ${context}` })\n }\n })\n }\n\n const trackHandler = singleTrackHandler((req) => resolveContext(String(req.params.vesselId), app.selfContext))\n router.get('/vessels/:vesselId/track', trackHandler)\n router.get(\n '/vessels/:vesselId/track.gpx',\n gpxHandler((req) => resolveContext(String(req.params.vesselId), app.selfContext)),\n )\n router.get(\n '/self/track.gpx',\n gpxHandler(() => app.selfContext),\n )\n\n // Freeboard-SK requests the own vessel's trail here rather than at\n // /vessels/self/track. Plugin routes are mounted before the v1 REST\n // interface, so this takes precedence over the data-tree walker.\n router.get(\n '/self/track',\n singleTrackHandler(() => app.selfContext),\n )\n\n // return all / filtered vessel tracks\n /** Only the collection route filters spatially; see AGENTS.md on v1 vs v2. */\n const SPATIAL_PARAMS = ['bbox', 'radius'] as const\n\n const allTracksHandler: RequestHandler = (req: Request, res: Response) => {\n app.debug(req.query)\n if (!tracks) {\n notAvailable(res)\n return\n }\n let query: TrackQuery\n let params: TrackParams\n try {\n query = parseTrackQuery(req.query, Date.now(), SPATIAL_PARAMS)\n // Inside the try on purpose: a malformed bbox used to reach the\n // matcher, throw there, and be caught by the result handler's\n // `.catch()`, which answers 404 -- reporting a bad query as an empty\n // one. Validating here lets it surface as the 400 it is.\n params = validateParameters(req.query, undefined)\n } catch (err) {\n res.status(400)\n res.json({\n message:\n err instanceof TimeWindowError || err instanceof QueryParameterError\n ? err.message\n : 'Invalid query parameters',\n })\n return\n }\n const selfPosition = getVesselPosition()\n\n // Two paths on purpose. Without `times` the response keeps its\n // long-standing shape exactly — one un-segmented line per vessel — so\n // existing clients are unaffected. With `times` the track is segmented\n // like the single-vessel route, because a times array can only line up\n // with coordinates if both are split the same way.\n const result = query.times\n ? tracks.getFilteredTimedTracks(params, selfPosition, app.debug, query).then((tc: TimedTrackCollection) =>\n Object.entries(tc).reduce<AllTracksResult>((acc, [context, points]) => {\n const segments = segment(points, segmentGap)\n acc[context] = {\n type: 'MultiLineString',\n coordinates: segments.map((s) => s.map(({ position }) => toLngLat(position))),\n times: segments.map(toIsoTimes),\n isSelf: context === app.selfContext,\n name: nameOf(context),\n }\n return acc\n }, {}),\n )\n : tracks.getFilteredTracks(params, selfPosition, app.debug, query).then((tc: TrackCollection) =>\n Object.entries(tc).reduce<AllTracksResult>((acc, [context, track]) => {\n acc[context] = {\n type: 'MultiLineString',\n coordinates: [track.map(toLngLat)],\n isSelf: context === app.selfContext,\n name: nameOf(context),\n }\n return acc\n }, {}),\n )\n\n result\n .then((trks) => {\n res.json(trks)\n })\n .catch((err: unknown) => {\n // A radius query the server cannot measure is not a missing track.\n // Reporting it as 404 said no vessel matched, which is the one\n // thing an unanswerable query cannot establish.\n if (err instanceof SelfPositionUnavailableError) {\n res.status(503)\n res.json({ message: 'No position for the own vessel, so radius cannot be measured' })\n return\n }\n res.status(404)\n res.json({ message: `No track available for vessels.` })\n })\n }\n router.get('/tracks', allTracksHandler)\n // Express 4 path syntax: the Signal K server mounts plugin routers on\n // express 4, where a bare `*` is the wildcard (express 5 renamed it).\n router.get('/tracks/*', allTracksHandler)\n\n return router\n },\n\n getTracks: () => tracks,\n\n id: 'tracks',\n name: 'Tracks',\n // On by default: a track recorder that records nothing until someone finds\n // and enables it loses exactly the passage the user wanted kept.\n enabledByDefault: true,\n description: 'Record vessel tracks to SQLite and serve them through the track API',\n schema: {\n type: 'object',\n properties: {\n resolution: {\n type: 'integer',\n minimum: 0,\n title: 'Track resolution (milliseconds)',\n default: DEFAULT_RESOLUTION,\n },\n aisRetentionDays: {\n type: 'integer',\n minimum: 0,\n title: 'Days to keep another vessel after its last fix',\n description:\n 'The own vessel is never removed. Other vessels are: a busy harbour puts hundreds of AIS targets past the receiver in a day, and keeping all of them forever is rarely what anyone wants. 0 keeps every vessel indefinitely.',\n default: DEFAULT_AIS_RETENTION_DAYS,\n },\n segmentGapMinutes: {\n type: 'integer',\n title: 'Split a track after this many minutes without a fix',\n description:\n 'A gap longer than this starts a new track segment, so a stop overnight or a spell out of AIS range does not draw a straight line across it. 0 (the default) returns the track as a single line, as before. Note that slow-updating AIS targets can legitimately go many minutes between fixes, so a low value will fragment their tracks.',\n default: DEFAULT_SEGMENT_GAP_MINUTES,\n },\n maxSpeedKnots: {\n type: 'integer',\n title: 'Discard positions implying a speed above this (knots)',\n description:\n 'A receiver occasionally reports a position far from the vessel. On a live map it flickers past; in a stored track it is permanent, stretching the bounding box and drawing a line across the chart. A fix that would require travelling faster than this since the previous one is discarded. The default is well above any real vessel, and glitches miss it by orders of magnitude. 0 disables the check.',\n default: DEFAULT_MAX_SPEED_KNOTS,\n },\n pauseWhenState: {\n type: 'array',\n title: 'Pause recording while navigation.state is one of',\n description:\n \"Stops recording the own vessel while it is not going anywhere, so a winter on a mooring costs no rows. Needs navigation.state to be set, by signalk-autostate or by hand. Leave empty (the default) to always record. Note that 'anchored' is offered but rarely wanted: an anchor alarm watches exactly the track a vessel makes while swinging on its rode. AIS targets are never gated, since their status comes from the transponder and is often stale.\",\n items: {\n type: 'string',\n enum: [...PAUSABLE_STATES],\n },\n uniqueItems: true,\n default: DEFAULT_PAUSE_STATES,\n },\n },\n },\n }\n}\n\nexport { fromGpx, toGpx } from './gpx.js'\nexport type { GpxTrack, GpxTrackIdentity } from './gpx.js'\nexport { Tracks, TrackAccumulator } from './tracks.js'\nexport type { TracksConfig } from './tracks.js'\nexport type { TrackStore } from './store.js'\nexport type * from './types.js'\n"],"mappings":"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"}
@@ -14,3 +14,14 @@ export interface ReconcileResult {
14
14
  * bucket.
15
15
  */
16
16
  export declare function reconcile(history: TimedPosition[], stored: TimedPosition[], resolution: number): ReconcileResult;
17
+ /**
18
+ * Merge history that is coarser than the store into it, the store first.
19
+ *
20
+ * The rule above holds while history is the finer source. A history read
21
+ * widened to stay within its bucket budget is not: one of its buckets spans
22
+ * several stored fixes, and letting it win would replace them all with one
23
+ * point. So here the store keeps every bucket it has a point in, and history
24
+ * fills only the buckets the store has nothing for — where the provider
25
+ * recorded before this plugin was installed, or while it was off.
26
+ */
27
+ export declare function fillFromHistory(history: TimedPosition[], stored: TimedPosition[], resolution: number): ReconcileResult;
@@ -127,6 +127,23 @@ retention setting, or ask how long it has been running — it asks for the windo
127
127
  and uses what comes back, so coverage that changes underneath needs no
128
128
  attention.
129
129
 
130
+ ## How much is asked of the provider
131
+
132
+ A provider has no spatial filter, so a query with a box still reads every
133
+ position in its time window, and providers produce one row per bucket of that
134
+ window whether or not anything was recorded in it. So each read is held to a
135
+ fixed number of buckets: a longer window is read at a wider resolution, so the
136
+ read stays the same size however far back it reaches.
137
+
138
+ A history read widened like that is coarser than the plugin's store, so for it
139
+ the rule below flips: the store keeps every bucket it has a position in, and
140
+ the provider only fills the buckets the store has nothing for.
141
+
142
+ A window with only an end, such as "everything older than a day", would
143
+ otherwise start at the Unix epoch. Instead the plugin first finds where the
144
+ provider's positions begin and reads from there, within the same budget; when
145
+ the provider has none in the window, it contributes nothing.
146
+
130
147
  ## Why the two never double up
131
148
 
132
149
  A provider aggregates into buckets and stamps each one on its boundary:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalk/tracks-plugin",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Plugin to keep track of tracks of vessels",
5
5
  "type": "module",
6
6
  "exports": {