@signalk/tracks-plugin 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.d.ts CHANGED
@@ -11,6 +11,23 @@ export type LngLatTuple = [number, number];
11
11
  export interface TimedPosition {
12
12
  position: LatLngTuple;
13
13
  timestamp: number;
14
+ /**
15
+ * The longest pause in the recording between the point before this one and
16
+ * this one, set where thinning dropped the points in between. Without it,
17
+ * the time between the two is itself the recorded step.
18
+ */
19
+ pauseBefore?: number;
20
+ /**
21
+ * The stretch of time this point stands for, when it is a history
22
+ * provider's bucket rather than a fix. The fixes it summarises can lie
23
+ * anywhere in that stretch.
24
+ */
25
+ span?: number;
26
+ /**
27
+ * Starts a new line here whatever the time since the point before: the
28
+ * track left a clipping box between the two and came back.
29
+ */
30
+ breakBefore?: boolean;
14
31
  }
15
32
  /**
16
33
  * A time window in epoch milliseconds, half-open as `[from, to)`.
@@ -58,6 +75,18 @@ export interface TrackParams {
58
75
  * since left still matches. Same filter, two questions.
59
76
  */
60
77
  intersects?: boolean;
78
+ /**
79
+ * Only these contexts, fully qualified; absent for every context. A store
80
+ * reads nothing for the others, so asking for one vessel does not cost a
81
+ * read of every track it holds.
82
+ */
83
+ contexts?: Context[];
84
+ /**
85
+ * The caller clips each track to `bbox`, so a store need read only the
86
+ * stretches near the box rather than every position in the window. A read
87
+ * narrowing, not a filter: a store may ignore it and return whole tracks.
88
+ */
89
+ clip?: boolean;
61
90
  }
62
91
  export interface Debug {
63
92
  (...args: unknown[]): void;
@@ -135,19 +135,81 @@ function parseTrackQuery(query, now = Date.now(), alsoAllowed = []) {
135
135
  return result;
136
136
  }
137
137
  /**
138
+ * The time between two points that no history bucket either side can account
139
+ * for.
140
+ *
141
+ * Providers disagree on whether a bucket's timestamp marks its start or its
142
+ * end, so a bucket may cover up to its width on either side of it. Two
143
+ * buckets, however, cover the time between their timestamps once between
144
+ * them, so one width is taken off, the wider of the two: exact between
145
+ * buckets under either convention, and between a fix and a bucket never more
146
+ * than the pause could be.
147
+ */
148
+ function uncoveredBetween(previous, point) {
149
+ return point.timestamp - previous.timestamp - Math.max(previous.span ?? 0, point.span ?? 0);
150
+ }
151
+ /**
152
+ * The pause in the recording between two consecutive points: where thinning
153
+ * dropped the points between them, the longest pause among those it recorded,
154
+ * and otherwise whatever of the time between them no bucket accounts for.
155
+ */
156
+ function pauseBetween(previous, point) {
157
+ return point.pauseBefore ?? uncoveredBetween(previous, point);
158
+ }
159
+ /**
138
160
  * Drop points closer together than `resolution` milliseconds, always keeping
139
161
  * the first and last so the track keeps its extent.
162
+ *
163
+ * A point kept after dropped ones records the longest pause among them as
164
+ * `pauseBefore`: the kept points are further apart than anything recorded
165
+ * between them, so without it `segment()` would read the spacing thinning
166
+ * made as a stop in the recording.
140
167
  */
141
168
  function thin(points, resolution) {
142
169
  if (!resolution || points.length <= 2) return points;
170
+ const stretches = splitAtBreaks(points);
171
+ return stretches.length === 1 ? thinStretch(points, resolution) : stretches.flatMap((s) => thinStretch(s, resolution));
172
+ }
173
+ /** Points split wherever one starts a new line (`breakBefore`). */
174
+ function splitAtBreaks(points) {
175
+ if (!points.some(({ breakBefore }) => breakBefore)) return [points];
176
+ const stretches = [];
177
+ let current = [];
178
+ for (const point of points) {
179
+ if (point.breakBefore && current.length > 0) {
180
+ stretches.push(current);
181
+ current = [];
182
+ }
183
+ current.push(point);
184
+ }
185
+ if (current.length > 0) stretches.push(current);
186
+ return stretches;
187
+ }
188
+ function thinStretch(points, resolution) {
189
+ if (points.length <= 2) return points;
143
190
  const result = [];
144
191
  let lastKept = Number.NEGATIVE_INFINITY;
145
- for (const point of points) if (point.timestamp - lastKept >= resolution) {
146
- result.push(point);
147
- lastKept = point.timestamp;
192
+ let previous;
193
+ let droppedPause;
194
+ let keptLast = false;
195
+ for (const point of points) {
196
+ const pause = previous ? pauseBetween(previous, point) : 0;
197
+ keptLast = point.timestamp - lastKept >= resolution;
198
+ if (keptLast) {
199
+ result.push(droppedPause === void 0 ? point : {
200
+ ...point,
201
+ pauseBefore: Math.max(droppedPause, pause)
202
+ });
203
+ lastKept = point.timestamp;
204
+ droppedPause = void 0;
205
+ } else droppedPause = Math.max(droppedPause ?? pause, pause);
206
+ previous = point;
148
207
  }
149
208
  const last = points[points.length - 1];
150
- if (last && result[result.length - 1] !== last) result.push(last);
209
+ if (last && !keptLast) result.push({
210
+ ...last,
211
+ pauseBefore: droppedPause ?? 0
212
+ });
151
213
  return result;
152
214
  }
153
215
  /**
@@ -195,27 +257,32 @@ function thinToBudget(points, maxPoints, resolution) {
195
257
  * over an anchorage it actually sat still in.
196
258
  *
197
259
  * Operates on timestamped points rather than inside a store, so both the
198
- * in-memory and sqlite stores segment identically, and so it composes with
199
- * `thin()` — thinning first, then segmenting, keeps a thinned-away gap from
200
- * being invented as a join.
260
+ * in-memory and sqlite stores segment identically. A gap is a pause in the
261
+ * recording, not in the points given: see `pauseBetween`, which lets a
262
+ * thinned track, or one reconciled with history buckets, segment exactly as
263
+ * the recording would.
201
264
  *
202
- * `gap` of 0 or undefined returns a single segment, preserving the old shape.
265
+ * A point marked `breakBefore` starts a new segment whatever the gap: a
266
+ * clipped track leaves the box there.
267
+ *
268
+ * `gap` of 0 or undefined otherwise returns a single segment, preserving the
269
+ * old shape.
203
270
  * An empty input returns no segments rather than one empty one, so a caller can
204
271
  * distinguish "no track" from "a track with no points".
205
272
  */
206
273
  function segment(points, gap) {
207
274
  if (points.length === 0) return [];
208
- if (!gap || gap <= 0) return [points];
275
+ if (!gap || gap <= 0) return splitAtBreaks(points);
209
276
  const segments = [];
210
277
  let current = [];
211
278
  let previous;
212
279
  for (const point of points) {
213
- if (previous !== void 0 && point.timestamp - previous > gap) {
280
+ if (previous !== void 0 && (point.breakBefore || pauseBetween(previous, point) > gap)) {
214
281
  segments.push(current);
215
282
  current = [];
216
283
  }
217
284
  current.push(point);
218
- previous = point.timestamp;
285
+ previous = point;
219
286
  }
220
287
  if (current.length > 0) segments.push(current);
221
288
  return segments;
@@ -247,6 +314,39 @@ function createInBounds(bounds) {
247
314
  };
248
315
  }
249
316
  /**
317
+ * Cut a track down to the parts inside `bounds`.
318
+ *
319
+ * Each stretch inside keeps the one point either side of it, so the line
320
+ * still reaches the box edge rather than stopping at the last fix inside, and
321
+ * each stretch after the first starts with `breakBefore`, so a track that
322
+ * leaves the box and comes back is not joined by a line across the outside.
323
+ * An outside point between two stretches is both one's exit and the next
324
+ * one's entry, and appears in each.
325
+ *
326
+ * Bounds crossing the antimeridian are handled by `createInBounds`.
327
+ */
328
+ function clipToBounds(points, bounds) {
329
+ const inBounds = createInBounds(bounds);
330
+ const result = [];
331
+ let inside = false;
332
+ for (const [i, point] of points.entries()) if (inBounds(point.position)) {
333
+ if (!inside) {
334
+ const entry = points[i - 1];
335
+ const first = entry ?? point;
336
+ result.push(result.length > 0 ? {
337
+ ...first,
338
+ breakBefore: true
339
+ } : first);
340
+ if (entry) result.push(point);
341
+ inside = true;
342
+ } else result.push(point);
343
+ } else if (inside) {
344
+ result.push(point);
345
+ inside = false;
346
+ }
347
+ return result;
348
+ }
349
+ /**
250
350
  * Split a bounds that crosses the antimeridian into two that do not.
251
351
  *
252
352
  * A bounds wraps when its west edge is numerically greater than its east edge
@@ -477,6 +577,6 @@ function rfc8187(filename) {
477
577
  return encodeURIComponent(filename).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
478
578
  }
479
579
  //#endregion
480
- export { TimeWindowError as _, createDistanceTo as a, thin as b, gpxFilename as c, rfc8187 as d, splitAtAntimeridian as f, validateParameters as g, unwrapString as h, contextName as i, historyRowPosition as l, trackLabel as m, SelfPositionUnavailableError as n, createInBounds as o, toIsoTimes as p, asciiFilename as r, createMatcher as s, QueryParameterError as t, resolveContext as u, parseTrackQuery as v, thinToBudget as x, segment as y };
580
+ export { uncoveredBetween as C, thinToBudget as S, validateParameters as _, contextName as a, segment as b, createMatcher as c, resolveContext as d, rfc8187 as f, unwrapString as g, trackLabel as h, clipToBounds as i, gpxFilename as l, toIsoTimes as m, SelfPositionUnavailableError as n, createDistanceTo as o, splitAtAntimeridian as p, asciiFilename as r, createInBounds as s, QueryParameterError as t, historyRowPosition as u, TimeWindowError as v, thin as x, parseTrackQuery as y };
481
581
 
482
- //# sourceMappingURL=utils-OGjP1B0D.js.map
582
+ //# sourceMappingURL=utils-CGIGp4Xf.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils-CGIGp4Xf.js","names":[],"sources":["../src/timeWindow.ts","../src/utils.ts"],"sourcesContent":["import { Type } from 'typebox'\nimport { Value } from 'typebox/value'\nimport type { TimedPosition, TimeWindow } from './types.js'\n\n/**\n * Time-window and resolution query parameters.\n *\n * Two vocabularies are accepted:\n *\n * - `from` / `to` / `duration` mirror the Signal K History API and are the\n * shape proposed for the track API in SignalK/signalk-server#2504.\n * - `timespan` / `timespanOffset` are what Freeboard-SK sends today. They are\n * compatibility only, not part of any spec, and are expected to be dropped\n * once Freeboard moves to the parameters above.\n *\n * `resolution` is shared by both and expresses the minimum spacing between\n * returned points, thinning the track for wide time ranges.\n */\nconst QuerySchema = Type.Object({\n from: Type.Optional(Type.String()),\n to: Type.Optional(Type.String()),\n duration: Type.Optional(Type.String()),\n timespan: Type.Optional(Type.String()),\n timespanOffset: Type.Optional(Type.String()),\n resolution: Type.Optional(Type.String()),\n times: Type.Optional(Type.String()),\n})\n\nconst QUERY_KEYS: readonly string[] = Object.keys(QuerySchema.properties)\n\nexport class TimeWindowError extends Error {}\n\n/** `30`, `30s`, `5m`, `2h`, `7d` — seconds unless suffixed. Returns milliseconds. */\nconst DURATION_PATTERN = /^(\\d+(?:\\.\\d+)?)\\s*([smhd])?$/\n\nconst UNIT_MS: Record<string, number> = {\n s: 1000,\n m: 60 * 1000,\n h: 60 * 60 * 1000,\n d: 24 * 60 * 60 * 1000,\n}\n\nexport function parseDuration(value: string): number {\n const match = DURATION_PATTERN.exec(value.trim())\n const amount = match?.[1]\n if (!amount) {\n throw new TimeWindowError(`Invalid duration '${value}', expected a number optionally suffixed with s, m, h or d`)\n }\n const unit = match[2] ?? 's'\n const scale = UNIT_MS[unit]\n if (scale === undefined) {\n throw new TimeWindowError(`Invalid duration unit '${unit}'`)\n }\n return Number(amount) * scale\n}\n\nfunction parseInstant(value: string, name: string): number {\n const ms = Date.parse(value)\n if (Number.isNaN(ms)) {\n throw new TimeWindowError(`Invalid ${name} '${value}', expected an ISO-8601 timestamp`)\n }\n return ms\n}\n\nconst TRUE_FLAGS = ['true', '1', 'yes']\nconst FALSE_FLAGS = ['false', '0', 'no']\n\n/**\n * A boolean query parameter. Present but valueless (`?times`) reads as true,\n * which is how a query string conventionally spells a flag.\n */\nfunction parseFlag(value: string | undefined, name: string): boolean {\n if (value === undefined) {\n return true\n }\n const flag = value.trim().toLowerCase()\n if (TRUE_FLAGS.includes(flag)) {\n return true\n }\n if (FALSE_FLAGS.includes(flag)) {\n return false\n }\n throw new TimeWindowError(`Invalid ${name} '${value}', expected true or false`)\n}\n\n/** Express repeats a query key into an array; take the first usable string. */\nfunction firstString(value: unknown): string | undefined {\n const scalar: unknown = Array.isArray(value) ? (value as unknown[])[0] : value\n return typeof scalar === 'string' && scalar.trim() !== '' ? scalar : undefined\n}\n\nexport interface TrackQuery {\n window?: TimeWindow\n /** Minimum spacing between returned points, in milliseconds. */\n resolution?: number\n /**\n * Serve the recording time of every point alongside the geometry.\n *\n * Opt-in rather than always-on: the response grows by roughly a third, and\n * the clients reading these routes today (Freeboard-SK) draw the geometry\n * and have no use for the times. Consumers that need per-point time — an\n * IMO-style AIS display drawing dots equally spaced by time, per\n * SignalK/signalk-server#2504 — ask for it.\n */\n times?: boolean\n}\n\n/**\n * Resolve the time-window parameters into an absolute `[from, to)` window.\n *\n * Precedence: explicit `from`/`to` win; then `duration`; then the Freeboard\n * `timespan`/`timespanOffset` pair. Absent all of them, no window is applied\n * and the whole retained track is returned, as before.\n */\nexport function parseTrackQuery(\n query: Record<string, unknown>,\n now: number = Date.now(),\n alsoAllowed: readonly string[] = [],\n): TrackQuery {\n // A parameter the route cannot honour is a question it is not answering, and\n // answering 200 says it did. Unknown keys were silently dropped, so `?bbox=`\n // on a single-vessel route -- reasonable, having seen bbox on /tracks -- read\n // as a filtered track when it was the whole one, and a typo like\n // `?duratoin=6h` quietly returned everything retained.\n //\n // Spelled per route rather than globally: /tracks genuinely takes bbox and\n // radius, the single-vessel routes genuinely do not.\n const unknown = Object.keys(query).filter((key) => !QUERY_KEYS.includes(key) && !alsoAllowed.includes(key))\n if (unknown.length > 0) {\n const allowed = [...QUERY_KEYS, ...alsoAllowed].join(', ')\n throw new TimeWindowError(\n `Unknown query parameter${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')}; accepted: ${allowed}`,\n )\n }\n const raw = Object.fromEntries(QUERY_KEYS.map((key) => [key, firstString(query[key])]))\n\n if (!Value.Check(QuerySchema, raw)) {\n throw new TimeWindowError('Invalid track query parameters')\n }\n\n const result: TrackQuery = {}\n\n // `?times` with no value is how a query string spells a flag, so a bare key\n // counts as true; `times=false` turns it back off for a caller building the\n // parameter list programmatically.\n if (Object.prototype.hasOwnProperty.call(query, 'times')) {\n result.times = parseFlag(raw.times, 'times')\n }\n\n if (raw.resolution !== undefined) {\n const resolution = parseDuration(raw.resolution)\n if (resolution > 0) {\n result.resolution = resolution\n }\n }\n\n // A window ending at `now` includes its final point, so the newest fix is not\n // dropped. A window ending earlier stays half-open so consecutive bands tile\n // without repeating the point they share.\n if (raw.from !== undefined || raw.to !== undefined) {\n const from = raw.from !== undefined ? parseInstant(raw.from, 'from') : Number.NEGATIVE_INFINITY\n const to = raw.to !== undefined ? parseInstant(raw.to, 'to') : now\n if (from >= to) {\n throw new TimeWindowError(`'from' must be before 'to'`)\n }\n return { ...result, window: { from, to, inclusiveEnd: raw.to === undefined } }\n }\n\n if (raw.duration !== undefined) {\n const duration = parseDuration(raw.duration)\n return { ...result, window: { from: now - duration, to: now, inclusiveEnd: true } }\n }\n\n if (raw.timespan !== undefined) {\n // Freeboard sends timespan with an optional offset back from now, so\n // `timespan=23h&timespanOffset=1` means \"23 hours ending an hour ago\".\n const span = parseDuration(raw.timespan)\n const offset = raw.timespanOffset !== undefined ? parseDuration(`${raw.timespanOffset}h`) : 0\n const to = now - offset\n return { ...result, window: { from: to - span, to, inclusiveEnd: offset === 0 } }\n }\n\n return result\n}\n\n/**\n * The time between two points that no history bucket either side can account\n * for.\n *\n * Providers disagree on whether a bucket's timestamp marks its start or its\n * end, so a bucket may cover up to its width on either side of it. Two\n * buckets, however, cover the time between their timestamps once between\n * them, so one width is taken off, the wider of the two: exact between\n * buckets under either convention, and between a fix and a bucket never more\n * than the pause could be.\n */\nexport function uncoveredBetween(previous: TimedPosition, point: TimedPosition): number {\n return point.timestamp - previous.timestamp - Math.max(previous.span ?? 0, point.span ?? 0)\n}\n\n/**\n * The pause in the recording between two consecutive points: where thinning\n * dropped the points between them, the longest pause among those it recorded,\n * and otherwise whatever of the time between them no bucket accounts for.\n */\nexport function pauseBetween(previous: TimedPosition, point: TimedPosition): number {\n return point.pauseBefore ?? uncoveredBetween(previous, point)\n}\n\n/**\n * Drop points closer together than `resolution` milliseconds, always keeping\n * the first and last so the track keeps its extent.\n *\n * A point kept after dropped ones records the longest pause among them as\n * `pauseBefore`: the kept points are further apart than anything recorded\n * between them, so without it `segment()` would read the spacing thinning\n * made as a stop in the recording.\n */\nexport function thin(points: TimedPosition[], resolution: number | undefined): TimedPosition[] {\n if (!resolution || points.length <= 2) {\n return points\n }\n // Each stretch of a clipped track is thinned on its own, so every one keeps\n // the points that carry it to the box edge.\n const stretches = splitAtBreaks(points)\n return stretches.length === 1 ? thinStretch(points, resolution) : stretches.flatMap((s) => thinStretch(s, resolution))\n}\n\n/** Points split wherever one starts a new line (`breakBefore`). */\nfunction splitAtBreaks(points: TimedPosition[]): TimedPosition[][] {\n if (!points.some(({ breakBefore }) => breakBefore)) {\n return [points]\n }\n const stretches: TimedPosition[][] = []\n let current: TimedPosition[] = []\n for (const point of points) {\n if (point.breakBefore && current.length > 0) {\n stretches.push(current)\n current = []\n }\n current.push(point)\n }\n if (current.length > 0) {\n stretches.push(current)\n }\n return stretches\n}\n\nfunction thinStretch(points: TimedPosition[], resolution: number): TimedPosition[] {\n if (points.length <= 2) {\n return points\n }\n const result: TimedPosition[] = []\n let lastKept = Number.NEGATIVE_INFINITY\n let previous: TimedPosition | undefined\n // The longest pause since the last kept point, while points are being dropped.\n let droppedPause: number | undefined\n let keptLast = false\n for (const point of points) {\n const pause = previous ? pauseBetween(previous, point) : 0\n keptLast = point.timestamp - lastKept >= resolution\n if (keptLast) {\n result.push(droppedPause === undefined ? point : { ...point, pauseBefore: Math.max(droppedPause, pause) })\n lastKept = point.timestamp\n droppedPause = undefined\n } else {\n droppedPause = Math.max(droppedPause ?? pause, pause)\n }\n previous = point\n }\n const last = points[points.length - 1]\n if (last && !keptLast) {\n result.push({ ...last, pauseBefore: droppedPause ?? 0 })\n }\n return result\n}\n\n/**\n * Thin to at most `maxPoints`, widening the spacing until the budget fits.\n *\n * A budget, not a fidelity contract: the caller has said how many points it can\n * afford to transfer and draw, whatever shape the track is. Returns the\n * resolution actually applied so the response can report it — a client that is\n * given 29 points instead of the 546 it would otherwise have received should be\n * able to see the spacing that produced them.\n *\n * Widening by doubling from the span-derived estimate rather than binary\n * searching: `thin` keeps the first point and the last unconditionally, so the\n * count is not a clean function of the spacing and an exact solve would be\n * false precision. Two or three passes settle it in practice.\n */\nexport function thinToBudget(\n points: TimedPosition[],\n maxPoints: number,\n resolution: number | undefined,\n): { points: TimedPosition[]; resolution: number | undefined } {\n const start = thin(points, resolution)\n if (start.length <= maxPoints) {\n return { points: start, resolution }\n }\n // A budget below two cannot be met by widening — `thin` keeps the first point\n // and the last unconditionally — so it is met by truncation instead. The API\n // rejects a non-positive maxPoints with a 400, so this only arises for a\n // caller reaching the helper directly, and \"at most\" has to hold for them too.\n if (maxPoints < 2) {\n return { points: start.slice(0, Math.max(0, maxPoints)), resolution }\n }\n const span = start[start.length - 1]!.timestamp - start[0]!.timestamp\n // The spacing that would fit the budget if points were evenly spaced. They\n // are not, so this is a starting guess that the loop below corrects.\n let spacing = Math.max(1, Math.ceil(span / (maxPoints - 1)), resolution ?? 0)\n let result = thin(points, spacing)\n // Widened past the span, not up to it: `thin` keeps the last point\n // unconditionally, so a track whose final fixes share a timestamp still\n // yields three points at exactly `span` — first, middle, and the appended\n // last. Doubling terminates because `thin` reaches two points once the\n // spacing exceeds the span.\n while (result.length > maxPoints && spacing <= span) {\n spacing *= 2\n result = thin(points, spacing)\n }\n return { points: result, resolution: spacing }\n}\n\n/**\n * Split points into segments wherever recording stopped for longer than `gap`.\n *\n * A track is not one continuous line: a vessel that stops for the night, or\n * sails out of AIS range and back, leaves a hole. Joining across it draws a\n * straight line through places the vessel never went — across a headland, or\n * over an anchorage it actually sat still in.\n *\n * Operates on timestamped points rather than inside a store, so both the\n * in-memory and sqlite stores segment identically. A gap is a pause in the\n * recording, not in the points given: see `pauseBetween`, which lets a\n * thinned track, or one reconciled with history buckets, segment exactly as\n * the recording would.\n *\n * A point marked `breakBefore` starts a new segment whatever the gap: a\n * clipped track leaves the box there.\n *\n * `gap` of 0 or undefined otherwise returns a single segment, preserving the\n * old shape.\n * An empty input returns no segments rather than one empty one, so a caller can\n * distinguish \"no track\" from \"a track with no points\".\n */\nexport function segment(points: TimedPosition[], gap: number | undefined): TimedPosition[][] {\n if (points.length === 0) {\n return []\n }\n if (!gap || gap <= 0) {\n return splitAtBreaks(points)\n }\n const segments: TimedPosition[][] = []\n let current: TimedPosition[] = []\n let previous: TimedPosition | undefined\n for (const point of points) {\n if (previous !== undefined && (point.breakBefore || pauseBetween(previous, point) > gap)) {\n segments.push(current)\n current = []\n }\n current.push(point)\n previous = point\n }\n if (current.length > 0) {\n segments.push(current)\n }\n return segments\n}\n","import type { LatLngTuple, GeoBounds, Position, QueryParameters, TimedPosition, TrackParams, Debug } from './types.js'\n\nconst LAT = 0\nconst LNG = 1\n\n/**\n * The recording times of a segment, as ISO-8601 UTC, positionally aligned with\n * the segment's coordinates.\n *\n * UTC because a track outlives the timezone it was recorded in: a passage that\n * crosses a zone boundary, or a database restored on a boat that has since\n * moved, must not shift. ISO-8601 rather than the epoch milliseconds held\n * internally because that is what the rest of Signal K puts on the wire.\n */\nexport function toIsoTimes(segment: TimedPosition[]): string[] {\n return segment.map(({ timestamp }) => new Date(timestamp).toISOString())\n}\n\nexport function createInBounds(bounds: GeoBounds): (position: LatLngTuple | null) => boolean {\n const minLat = bounds.sw[LAT]\n const maxLat = bounds.ne[LAT]\n if (minLat > maxLat) {\n throw new Error(`Bounding box south must be <= north, got ${JSON.stringify(bounds)}`)\n }\n const minLng = bounds.sw[LNG]\n const maxLng = (bounds.sw[LNG] > bounds.ne[LNG] ? 360 : 0) + bounds.ne[LNG]\n\n return (p) => {\n return (\n p !== null &&\n p[LAT] >= minLat &&\n p[LAT] <= maxLat &&\n ((p[LNG] >= minLng && p[LNG] <= maxLng) || (p[LNG] + 360 >= minLng && p[LNG] + 360 <= maxLng))\n )\n }\n}\n\n/**\n * Cut a track down to the parts inside `bounds`.\n *\n * Each stretch inside keeps the one point either side of it, so the line\n * still reaches the box edge rather than stopping at the last fix inside, and\n * each stretch after the first starts with `breakBefore`, so a track that\n * leaves the box and comes back is not joined by a line across the outside.\n * An outside point between two stretches is both one's exit and the next\n * one's entry, and appears in each.\n *\n * Bounds crossing the antimeridian are handled by `createInBounds`.\n */\nexport function clipToBounds(points: TimedPosition[], bounds: GeoBounds): TimedPosition[] {\n const inBounds = createInBounds(bounds)\n const result: TimedPosition[] = []\n let inside = false\n for (const [i, point] of points.entries()) {\n if (inBounds(point.position)) {\n if (!inside) {\n const entry = points[i - 1]\n const first = entry ?? point\n result.push(result.length > 0 ? { ...first, breakBefore: true } : first)\n if (entry) {\n result.push(point)\n }\n inside = true\n } else {\n result.push(point)\n }\n } else if (inside) {\n result.push(point)\n inside = false\n }\n }\n return result\n}\n\n/**\n * Split a bounds that crosses the antimeridian into two that do not.\n *\n * A bounds wraps when its west edge is numerically greater than its east edge\n * (sw lng 179, ne lng -179): the box runs 179 -> 180 -> -179, the short way\n * across the Pacific. `createInBounds` handles that directly with its +360\n * arithmetic, but anything that has to describe the box *geometrically* cannot\n * — a GeoJSON polygon with those corners is read as a band going the long way\n * round, and an S2 covering of it comes back empty, which turns \"vessels near\n * Fiji\" into a silent no results rather than an error.\n *\n * Returns one bounds unchanged when there is no wrap, so callers can always\n * iterate the result.\n */\nexport function splitAtAntimeridian(bounds: GeoBounds): GeoBounds[] {\n const west = bounds.sw[LNG]\n const east = bounds.ne[LNG]\n if (west <= east) {\n return [bounds]\n }\n const south = bounds.sw[LAT]\n const north = bounds.ne[LAT]\n return [\n { sw: [south, west], ne: [north, 180] },\n { sw: [south, -180], ne: [north, east] },\n ]\n}\n\n/**\n * Express gives a repeated query parameter as an array. Take the first value so\n * `?radius=1&radius=2` degrades to a usable number rather than a parse failure.\n */\nfunction firstValue(value: unknown): string | undefined {\n const scalar: unknown = Array.isArray(value) ? (value as unknown[])[0] : value\n return typeof scalar === 'string' ? scalar : undefined\n}\n\n/** Finite-only parse: rejects '', 'abc' and 'Infinity', all of which Number() lets through or maps to NaN. */\nfunction toFiniteNumber(value: string): number | undefined {\n const trimmed = value.trim()\n if (trimmed === '') {\n return undefined\n }\n const parsed = Number(trimmed)\n return Number.isFinite(parsed) ? parsed : undefined\n}\n\n/**\n * A query parameter was present but malformed.\n *\n * Distinct from absent: a filter nobody asked for is not an error, but one\n * that cannot be honoured must not quietly widen the query. Carries its own\n * message so the handler can hand it to the client, the way `TimeWindowError`\n * already does for the time window.\n */\nexport class QueryParameterError extends Error {}\n\n/**\n * A well-formed query the server cannot currently answer.\n *\n * `radius` measures from the own vessel, so it needs a self position. Not\n * having one is an outage, not a client mistake -- the same distinction the\n * single-vessel route already draws for a history provider it cannot read.\n */\nexport class SelfPositionUnavailableError extends Error {}\n\nexport function validateParameters(params: QueryParameters, defaultMaxRadius: number | undefined): TrackParams {\n // Bounding box as west,south,east,north — GeoJSON coordinate order, the same\n // as the coordinates these endpoints return, the Resources API, and the v2\n // Track API. Internally a bounds is [lat, lng], so the pairs are swapped on\n // the way in.\n let bbox: GeoBounds | null = null\n const rawBbox = firstValue(params.bbox)\n if (rawBbox !== undefined) {\n const b = rawBbox.split(',').map(toFiniteNumber)\n // Every one of the four must have parsed; a `0` is a valid coordinate, so\n // test for undefined rather than truthiness.\n const [west, south, east, north] = b\n if (b.length !== 4 || west === undefined || south === undefined || east === undefined || north === undefined) {\n // Thrown rather than dropped. Yielding `null` here would skip the filter\n // and answer 200 with every track, so a typo'd bbox silently returns\n // more than was asked for -- the opposite of what a filter is for.\n throw new QueryParameterError('bbox must be four comma-separated numbers: west,south,east,north')\n }\n if (south > north) {\n // Wording follows the v2 Track API's own check, so the two surfaces\n // reject the same query the same way.\n throw new QueryParameterError(`bbox south (${south}) must not be greater than north (${north})`)\n }\n bbox = { sw: [south, west], ne: [north, east] }\n }\n\n let radius: number | null = null\n // radius in meters\n const rawRadius = firstValue(params.radius)\n if (rawRadius !== undefined) {\n const parsed = toFiniteNumber(rawRadius)\n if (parsed === undefined) {\n // Same reasoning as the bbox above: falling back to `null` here drops the\n // filter and answers with every track, so a typo returns more than was\n // asked for rather than failing.\n throw new QueryParameterError(`radius must be a number in meters, got '${rawRadius}'`)\n }\n if (parsed < 0) {\n // A negative radius matched nothing, silently -- an empty result for a\n // query that cannot be satisfied by construction.\n throw new QueryParameterError(`radius must not be negative, got ${parsed}`)\n }\n radius = parsed\n } else if (defaultMaxRadius) {\n radius = defaultMaxRadius\n }\n return { bbox, radius }\n}\n\nexport function createDistanceTo([lat1d, lon1d]: LatLngTuple, debug?: Debug): (d: LatLngTuple | null) => number {\n const Rk = 6371 // mean radius of the earth (km) at 39 degrees from the equator\n\n // convert coordinates to radians\n const lat1 = degreesToRadians(lat1d)\n const lon1 = degreesToRadians(lon1d)\n\n return (dest) => {\n if (!dest) {\n return Number.MAX_SAFE_INTEGER\n }\n const [lat2d, lon2d] = dest\n const lat2 = degreesToRadians(lat2d)\n const lon2 = degreesToRadians(lon2d)\n\n // find the differences between the coordinates\n const dlat = lat2 - lat1\n const dlon = lon2 - lon1\n\n //** calculate **\n const a = Math.pow(Math.sin(dlat / 2), 2) + Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin(dlon / 2), 2)\n const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))\n const dk = c * Rk * 1000 // great circle distance in m\n if (debug?.enabled) {\n debug(`${lat2d},${lon2d} => ${dk}`)\n }\n return dk\n }\n}\n\nconst degreesToRadians = (value: number) => {\n return (Math.PI / 180) * value\n}\n\nfunction lastPoint(track: LatLngTuple[]): LatLngTuple | null {\n return track.at(-1) ?? null\n}\n\n// Positions are accumulated under the context carried by the delta, which for\n// the own vessel is always the fully qualified context (vessels.urn:mrn:...),\n// never the literal `vessels.self`. Resolve the `self` alias the rest of the\n// Signal K HTTP API accepts so a lookup for it can hit.\nexport function resolveContext(vesselId: string, selfContext?: string): string {\n if ((vesselId === 'self' || vesselId === 'vessels.self') && selfContext) {\n return selfContext\n }\n return vesselId.startsWith('vessels.') ? vesselId : `vessels.${vesselId}`\n}\n\nexport function createMatcher(\n params: TrackParams,\n selfPosition?: LatLngTuple,\n debug?: Debug,\n): (track: LatLngTuple[]) => boolean {\n if (params.bbox) {\n const inBounds = createInBounds(params.bbox)\n return params.intersects\n ? (track: LatLngTuple[]) => track.some(inBounds)\n : (track: LatLngTuple[]) => inBounds(lastPoint(track))\n } else if (params.radius !== null) {\n if (!selfPosition) {\n throw new SelfPositionUnavailableError('No self position to calculate radius values')\n }\n const radius = params.radius\n const distanceFromSelf = createDistanceTo(selfPosition, debug)\n return (track: LatLngTuple[]) => distanceFromSelf(lastPoint(track)) < radius\n }\n return () => true\n}\n\n/**\n * A history row is `[timestamp, position]`. Both encodings are legal: the\n * History API types a value as `unknown` and its own comment names \"objects\n * (like navigation.position)\" alongside primitives, so signalk-questdb returns\n * `{latitude, longitude}` and signalk-to-influxdb2 returns `[lon, lat]`.\n * Accept both — neither is an error, so rejecting one silently yields an empty\n * track rather than a failure anybody can see.\n */\nexport const historyRowPosition = (row: unknown): LatLngTuple | undefined => {\n if (!Array.isArray(row) || row.length < 2) {\n return undefined\n }\n const value: unknown = row[1]\n // isFinite, not typeof number: NaN and Infinity are numbers, and one of them\n // in a coordinate reaches the store and stretches every bounding box that\n // track appears in — silently, since nothing downstream re-checks.\n if (Array.isArray(value) && value.length === 2 && Number.isFinite(value[0]) && Number.isFinite(value[1])) {\n const [longitude, latitude] = value as [number, number]\n return [latitude, longitude]\n }\n if (value && typeof value === 'object' && 'latitude' in value && 'longitude' in value) {\n const { latitude, longitude } = value as Partial<Position>\n if (Number.isFinite(latitude) && Number.isFinite(longitude)) {\n return [latitude as number, longitude as number]\n }\n }\n return undefined\n}\n\n/**\n * The name of the vessel (or aircraft, or aton) a context refers to.\n *\n * Deliberately bare — `Ariadne`, not `AIS Ariadne` and not `Own Ship`. This is\n * the v2 Track API's `contextName`, whose spec says \"Name of the vessel,\n * aircraft or other context, where known. Not a name for the track itself.\"\n * Decoration belongs to whoever is drawing a list; see `trackLabel`.\n *\n * `lookup` reads the data model (`app.getPath`). Note the shape: a vessel's\n * `name` is a bare string in the full model, not the `{value}` wrapper that\n * `navigation.position` and friends carry, so both are accepted rather than\n * guessed at.\n *\n * Resolves `name` and nothing else, matching the server's own `findContextName`\n * (`packages/server-admin-ui/src/utils/pathKeys.ts`), which returns undefined\n * rather than substituting an identifier. An MMSI is how a vessel is addressed,\n * not what it is called, so it belongs in `trackLabel` where something has to\n * render — putting it here would make \"where known\" untrue.\n */\nexport function contextName(context: string, lookup: (path: string) => unknown): string | undefined {\n return unwrapString(lookup(`${context}.name`))\n}\n\n/**\n * A display label for a track, as a chart plotter would show it.\n *\n * TimeZero's GPX exports label tracks `Own Ship` and `AIS <shipname>` — never a\n * serial number — and that is what makes a list of tracks readable. A UI\n * listing `vessels.urn:mrn:imo:mmsi:211234567` is unusable.\n *\n * Distinct from `contextName`: this one *is* a name for the track, so it says\n * whose track it is and where the identity came from. Falls back to the raw\n * context, because a label has to render something.\n */\nexport function trackLabel(\n context: string,\n selfContext: string | undefined,\n lookup: (path: string) => unknown,\n): string {\n if (context === selfContext) {\n return 'Own Ship'\n }\n const identity = contextName(context, lookup) ?? unwrapString(lookup(`${context}.mmsi`)) ?? mmsiFromContext(context)\n return identity === undefined ? context : `AIS ${identity}`\n}\n\n/** Accepts both a bare string and the `{value}` wrapper deltas arrive in. */\nexport function unwrapString(raw: unknown): string | undefined {\n const value: unknown = raw && typeof raw === 'object' && 'value' in raw ? raw.value : raw\n if (typeof value !== 'string') {\n return undefined\n }\n const trimmed = value.trim()\n return trimmed === '' ? undefined : trimmed\n}\n\n/** `vessels.urn:mrn:imo:mmsi:211234567` -> `211234567`. */\nfunction mmsiFromContext(context: string): string | undefined {\n const match = /urn:mrn:imo:mmsi:(\\d+)$/.exec(context)\n return match?.[1]\n}\n\n/**\n * A track label as a filename a browser can save.\n *\n * A vessel name arrives from an AIS transmission and is not ours to trust in a\n * path, so the characters that could break out of one — separators, quotes,\n * controls — are replaced. Everything else is kept, including non-ASCII: a\n * boat called `Ärger` keeps its name.\n */\nexport function gpxFilename(label: string): string {\n // Only the characters a path or a header could be broken by are replaced,\n // rather than everything outside ASCII: stripping those turns `Ärger` into\n // `rger` and `日本` into nothing at all, quietly renaming somebody's vessel.\n const safe = label\n .replace(/[\\p{Cc}\\p{Cf}\"'\\\\/:*?<>|]+/gu, '-')\n .replace(/\\s+/g, '-')\n .replace(/-+/g, '-')\n .replace(/^[-.]+|[-.]+$/g, '')\n return `${safe === '' ? 'track' : safe}.gpx`\n}\n\n/**\n * The ASCII-only form of a filename, for the quoted `Content-Disposition`\n * parameter.\n *\n * An HTTP header carries bytes: anything outside Latin-1 makes `setHeader`\n * throw, and anything outside ASCII is reinterpreted byte-wise by the client,\n * so `Ärger.gpx` arrives as mojibake. The real name travels in the `filename*`\n * parameter; this is the fallback for readers that do not implement it.\n */\nexport function asciiFilename(filename: string): string {\n // A rough fallback on purpose. Every current browser reads `filename*`, which\n // carries the real name; this form exists for readers that do not, and no\n // transliteration of `日本` into ASCII would be more use to them than a dash.\n // Two vessels whose names differ only outside ASCII therefore collide here,\n // which is a worse outcome than a leading dash on every such download.\n const ascii = [...filename].map((character) => (character < '\\u0080' ? character : '-')).join('')\n const trimmed = ascii.replace(/-{2,}/g, '-').replace(/^-+/, '')\n return trimmed === '.gpx' || trimmed === '' ? 'track.gpx' : trimmed\n}\n\n/**\n * A filename as an RFC 8187 extended value, for `filename*`.\n *\n * `encodeURIComponent` is close but not exact: it leaves `!'()*` unescaped,\n * and RFC 8187's `attr-char` set excludes them. A vessel called\n * `Boat (Test)` would otherwise put raw parentheses into the header, which is\n * not a valid extended value.\n */\nexport function rfc8187(filename: string): string {\n return encodeURIComponent(filename).replace(\n /[!'()*]/g,\n (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,\n )\n}\n"],"mappings":"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"}
package/dist/utils.d.ts CHANGED
@@ -10,6 +10,19 @@ import { LatLngTuple, GeoBounds, QueryParameters, TimedPosition, TrackParams, De
10
10
  */
11
11
  export declare function toIsoTimes(segment: TimedPosition[]): string[];
12
12
  export declare function createInBounds(bounds: GeoBounds): (position: LatLngTuple | null) => boolean;
13
+ /**
14
+ * Cut a track down to the parts inside `bounds`.
15
+ *
16
+ * Each stretch inside keeps the one point either side of it, so the line
17
+ * still reaches the box edge rather than stopping at the last fix inside, and
18
+ * each stretch after the first starts with `breakBefore`, so a track that
19
+ * leaves the box and comes back is not joined by a line across the outside.
20
+ * An outside point between two stretches is both one's exit and the next
21
+ * one's entry, and appears in each.
22
+ *
23
+ * Bounds crossing the antimeridian are handled by `createInBounds`.
24
+ */
25
+ export declare function clipToBounds(points: TimedPosition[], bounds: GeoBounds): TimedPosition[];
13
26
  /**
14
27
  * Split a bounds that crosses the antimeridian into two that do not.
15
28
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalk/tracks-plugin",
3
- "version": "3.0.2",
3
+ "version": "3.1.0",
4
4
  "description": "Plugin to keep track of tracks of vessels",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1 +0,0 @@
1
- {"version":3,"file":"utils-OGjP1B0D.js","names":[],"sources":["../src/timeWindow.ts","../src/utils.ts"],"sourcesContent":["import { Type } from 'typebox'\nimport { Value } from 'typebox/value'\nimport type { TimedPosition, TimeWindow } from './types.js'\n\n/**\n * Time-window and resolution query parameters.\n *\n * Two vocabularies are accepted:\n *\n * - `from` / `to` / `duration` mirror the Signal K History API and are the\n * shape proposed for the track API in SignalK/signalk-server#2504.\n * - `timespan` / `timespanOffset` are what Freeboard-SK sends today. They are\n * compatibility only, not part of any spec, and are expected to be dropped\n * once Freeboard moves to the parameters above.\n *\n * `resolution` is shared by both and expresses the minimum spacing between\n * returned points, thinning the track for wide time ranges.\n */\nconst QuerySchema = Type.Object({\n from: Type.Optional(Type.String()),\n to: Type.Optional(Type.String()),\n duration: Type.Optional(Type.String()),\n timespan: Type.Optional(Type.String()),\n timespanOffset: Type.Optional(Type.String()),\n resolution: Type.Optional(Type.String()),\n times: Type.Optional(Type.String()),\n})\n\nconst QUERY_KEYS: readonly string[] = Object.keys(QuerySchema.properties)\n\nexport class TimeWindowError extends Error {}\n\n/** `30`, `30s`, `5m`, `2h`, `7d` — seconds unless suffixed. Returns milliseconds. */\nconst DURATION_PATTERN = /^(\\d+(?:\\.\\d+)?)\\s*([smhd])?$/\n\nconst UNIT_MS: Record<string, number> = {\n s: 1000,\n m: 60 * 1000,\n h: 60 * 60 * 1000,\n d: 24 * 60 * 60 * 1000,\n}\n\nexport function parseDuration(value: string): number {\n const match = DURATION_PATTERN.exec(value.trim())\n const amount = match?.[1]\n if (!amount) {\n throw new TimeWindowError(`Invalid duration '${value}', expected a number optionally suffixed with s, m, h or d`)\n }\n const unit = match[2] ?? 's'\n const scale = UNIT_MS[unit]\n if (scale === undefined) {\n throw new TimeWindowError(`Invalid duration unit '${unit}'`)\n }\n return Number(amount) * scale\n}\n\nfunction parseInstant(value: string, name: string): number {\n const ms = Date.parse(value)\n if (Number.isNaN(ms)) {\n throw new TimeWindowError(`Invalid ${name} '${value}', expected an ISO-8601 timestamp`)\n }\n return ms\n}\n\nconst TRUE_FLAGS = ['true', '1', 'yes']\nconst FALSE_FLAGS = ['false', '0', 'no']\n\n/**\n * A boolean query parameter. Present but valueless (`?times`) reads as true,\n * which is how a query string conventionally spells a flag.\n */\nfunction parseFlag(value: string | undefined, name: string): boolean {\n if (value === undefined) {\n return true\n }\n const flag = value.trim().toLowerCase()\n if (TRUE_FLAGS.includes(flag)) {\n return true\n }\n if (FALSE_FLAGS.includes(flag)) {\n return false\n }\n throw new TimeWindowError(`Invalid ${name} '${value}', expected true or false`)\n}\n\n/** Express repeats a query key into an array; take the first usable string. */\nfunction firstString(value: unknown): string | undefined {\n const scalar: unknown = Array.isArray(value) ? (value as unknown[])[0] : value\n return typeof scalar === 'string' && scalar.trim() !== '' ? scalar : undefined\n}\n\nexport interface TrackQuery {\n window?: TimeWindow\n /** Minimum spacing between returned points, in milliseconds. */\n resolution?: number\n /**\n * Serve the recording time of every point alongside the geometry.\n *\n * Opt-in rather than always-on: the response grows by roughly a third, and\n * the clients reading these routes today (Freeboard-SK) draw the geometry\n * and have no use for the times. Consumers that need per-point time — an\n * IMO-style AIS display drawing dots equally spaced by time, per\n * SignalK/signalk-server#2504 — ask for it.\n */\n times?: boolean\n}\n\n/**\n * Resolve the time-window parameters into an absolute `[from, to)` window.\n *\n * Precedence: explicit `from`/`to` win; then `duration`; then the Freeboard\n * `timespan`/`timespanOffset` pair. Absent all of them, no window is applied\n * and the whole retained track is returned, as before.\n */\nexport function parseTrackQuery(\n query: Record<string, unknown>,\n now: number = Date.now(),\n alsoAllowed: readonly string[] = [],\n): TrackQuery {\n // A parameter the route cannot honour is a question it is not answering, and\n // answering 200 says it did. Unknown keys were silently dropped, so `?bbox=`\n // on a single-vessel route -- reasonable, having seen bbox on /tracks -- read\n // as a filtered track when it was the whole one, and a typo like\n // `?duratoin=6h` quietly returned everything retained.\n //\n // Spelled per route rather than globally: /tracks genuinely takes bbox and\n // radius, the single-vessel routes genuinely do not.\n const unknown = Object.keys(query).filter((key) => !QUERY_KEYS.includes(key) && !alsoAllowed.includes(key))\n if (unknown.length > 0) {\n const allowed = [...QUERY_KEYS, ...alsoAllowed].join(', ')\n throw new TimeWindowError(\n `Unknown query parameter${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')}; accepted: ${allowed}`,\n )\n }\n const raw = Object.fromEntries(QUERY_KEYS.map((key) => [key, firstString(query[key])]))\n\n if (!Value.Check(QuerySchema, raw)) {\n throw new TimeWindowError('Invalid track query parameters')\n }\n\n const result: TrackQuery = {}\n\n // `?times` with no value is how a query string spells a flag, so a bare key\n // counts as true; `times=false` turns it back off for a caller building the\n // parameter list programmatically.\n if (Object.prototype.hasOwnProperty.call(query, 'times')) {\n result.times = parseFlag(raw.times, 'times')\n }\n\n if (raw.resolution !== undefined) {\n const resolution = parseDuration(raw.resolution)\n if (resolution > 0) {\n result.resolution = resolution\n }\n }\n\n // A window ending at `now` includes its final point, so the newest fix is not\n // dropped. A window ending earlier stays half-open so consecutive bands tile\n // without repeating the point they share.\n if (raw.from !== undefined || raw.to !== undefined) {\n const from = raw.from !== undefined ? parseInstant(raw.from, 'from') : Number.NEGATIVE_INFINITY\n const to = raw.to !== undefined ? parseInstant(raw.to, 'to') : now\n if (from >= to) {\n throw new TimeWindowError(`'from' must be before 'to'`)\n }\n return { ...result, window: { from, to, inclusiveEnd: raw.to === undefined } }\n }\n\n if (raw.duration !== undefined) {\n const duration = parseDuration(raw.duration)\n return { ...result, window: { from: now - duration, to: now, inclusiveEnd: true } }\n }\n\n if (raw.timespan !== undefined) {\n // Freeboard sends timespan with an optional offset back from now, so\n // `timespan=23h&timespanOffset=1` means \"23 hours ending an hour ago\".\n const span = parseDuration(raw.timespan)\n const offset = raw.timespanOffset !== undefined ? parseDuration(`${raw.timespanOffset}h`) : 0\n const to = now - offset\n return { ...result, window: { from: to - span, to, inclusiveEnd: offset === 0 } }\n }\n\n return result\n}\n\n/**\n * Drop points closer together than `resolution` milliseconds, always keeping\n * the first and last so the track keeps its extent.\n */\nexport function thin(points: TimedPosition[], resolution: number | undefined): TimedPosition[] {\n if (!resolution || points.length <= 2) {\n return points\n }\n const result: TimedPosition[] = []\n let lastKept = Number.NEGATIVE_INFINITY\n for (const point of points) {\n if (point.timestamp - lastKept >= resolution) {\n result.push(point)\n lastKept = point.timestamp\n }\n }\n const last = points[points.length - 1]\n if (last && result[result.length - 1] !== last) {\n result.push(last)\n }\n return result\n}\n\n/**\n * Thin to at most `maxPoints`, widening the spacing until the budget fits.\n *\n * A budget, not a fidelity contract: the caller has said how many points it can\n * afford to transfer and draw, whatever shape the track is. Returns the\n * resolution actually applied so the response can report it — a client that is\n * given 29 points instead of the 546 it would otherwise have received should be\n * able to see the spacing that produced them.\n *\n * Widening by doubling from the span-derived estimate rather than binary\n * searching: `thin` keeps the first point and the last unconditionally, so the\n * count is not a clean function of the spacing and an exact solve would be\n * false precision. Two or three passes settle it in practice.\n */\nexport function thinToBudget(\n points: TimedPosition[],\n maxPoints: number,\n resolution: number | undefined,\n): { points: TimedPosition[]; resolution: number | undefined } {\n const start = thin(points, resolution)\n if (start.length <= maxPoints) {\n return { points: start, resolution }\n }\n // A budget below two cannot be met by widening — `thin` keeps the first point\n // and the last unconditionally — so it is met by truncation instead. The API\n // rejects a non-positive maxPoints with a 400, so this only arises for a\n // caller reaching the helper directly, and \"at most\" has to hold for them too.\n if (maxPoints < 2) {\n return { points: start.slice(0, Math.max(0, maxPoints)), resolution }\n }\n const span = start[start.length - 1]!.timestamp - start[0]!.timestamp\n // The spacing that would fit the budget if points were evenly spaced. They\n // are not, so this is a starting guess that the loop below corrects.\n let spacing = Math.max(1, Math.ceil(span / (maxPoints - 1)), resolution ?? 0)\n let result = thin(points, spacing)\n // Widened past the span, not up to it: `thin` keeps the last point\n // unconditionally, so a track whose final fixes share a timestamp still\n // yields three points at exactly `span` — first, middle, and the appended\n // last. Doubling terminates because `thin` reaches two points once the\n // spacing exceeds the span.\n while (result.length > maxPoints && spacing <= span) {\n spacing *= 2\n result = thin(points, spacing)\n }\n return { points: result, resolution: spacing }\n}\n\n/**\n * Split points into segments wherever recording stopped for longer than `gap`.\n *\n * A track is not one continuous line: a vessel that stops for the night, or\n * sails out of AIS range and back, leaves a hole. Joining across it draws a\n * straight line through places the vessel never went — across a headland, or\n * over an anchorage it actually sat still in.\n *\n * Operates on timestamped points rather than inside a store, so both the\n * in-memory and sqlite stores segment identically, and so it composes with\n * `thin()` — thinning first, then segmenting, keeps a thinned-away gap from\n * being invented as a join.\n *\n * `gap` of 0 or undefined returns a single segment, preserving the old shape.\n * An empty input returns no segments rather than one empty one, so a caller can\n * distinguish \"no track\" from \"a track with no points\".\n */\nexport function segment(points: TimedPosition[], gap: number | undefined): TimedPosition[][] {\n if (points.length === 0) {\n return []\n }\n if (!gap || gap <= 0) {\n return [points]\n }\n const segments: TimedPosition[][] = []\n let current: TimedPosition[] = []\n let previous: number | undefined\n for (const point of points) {\n if (previous !== undefined && point.timestamp - previous > gap) {\n segments.push(current)\n current = []\n }\n current.push(point)\n previous = point.timestamp\n }\n if (current.length > 0) {\n segments.push(current)\n }\n return segments\n}\n","import type { LatLngTuple, GeoBounds, Position, QueryParameters, TimedPosition, TrackParams, Debug } from './types.js'\n\nconst LAT = 0\nconst LNG = 1\n\n/**\n * The recording times of a segment, as ISO-8601 UTC, positionally aligned with\n * the segment's coordinates.\n *\n * UTC because a track outlives the timezone it was recorded in: a passage that\n * crosses a zone boundary, or a database restored on a boat that has since\n * moved, must not shift. ISO-8601 rather than the epoch milliseconds held\n * internally because that is what the rest of Signal K puts on the wire.\n */\nexport function toIsoTimes(segment: TimedPosition[]): string[] {\n return segment.map(({ timestamp }) => new Date(timestamp).toISOString())\n}\n\nexport function createInBounds(bounds: GeoBounds): (position: LatLngTuple | null) => boolean {\n const minLat = bounds.sw[LAT]\n const maxLat = bounds.ne[LAT]\n if (minLat > maxLat) {\n throw new Error(`Bounding box south must be <= north, got ${JSON.stringify(bounds)}`)\n }\n const minLng = bounds.sw[LNG]\n const maxLng = (bounds.sw[LNG] > bounds.ne[LNG] ? 360 : 0) + bounds.ne[LNG]\n\n return (p) => {\n return (\n p !== null &&\n p[LAT] >= minLat &&\n p[LAT] <= maxLat &&\n ((p[LNG] >= minLng && p[LNG] <= maxLng) || (p[LNG] + 360 >= minLng && p[LNG] + 360 <= maxLng))\n )\n }\n}\n\n/**\n * Split a bounds that crosses the antimeridian into two that do not.\n *\n * A bounds wraps when its west edge is numerically greater than its east edge\n * (sw lng 179, ne lng -179): the box runs 179 -> 180 -> -179, the short way\n * across the Pacific. `createInBounds` handles that directly with its +360\n * arithmetic, but anything that has to describe the box *geometrically* cannot\n * — a GeoJSON polygon with those corners is read as a band going the long way\n * round, and an S2 covering of it comes back empty, which turns \"vessels near\n * Fiji\" into a silent no results rather than an error.\n *\n * Returns one bounds unchanged when there is no wrap, so callers can always\n * iterate the result.\n */\nexport function splitAtAntimeridian(bounds: GeoBounds): GeoBounds[] {\n const west = bounds.sw[LNG]\n const east = bounds.ne[LNG]\n if (west <= east) {\n return [bounds]\n }\n const south = bounds.sw[LAT]\n const north = bounds.ne[LAT]\n return [\n { sw: [south, west], ne: [north, 180] },\n { sw: [south, -180], ne: [north, east] },\n ]\n}\n\n/**\n * Express gives a repeated query parameter as an array. Take the first value so\n * `?radius=1&radius=2` degrades to a usable number rather than a parse failure.\n */\nfunction firstValue(value: unknown): string | undefined {\n const scalar: unknown = Array.isArray(value) ? (value as unknown[])[0] : value\n return typeof scalar === 'string' ? scalar : undefined\n}\n\n/** Finite-only parse: rejects '', 'abc' and 'Infinity', all of which Number() lets through or maps to NaN. */\nfunction toFiniteNumber(value: string): number | undefined {\n const trimmed = value.trim()\n if (trimmed === '') {\n return undefined\n }\n const parsed = Number(trimmed)\n return Number.isFinite(parsed) ? parsed : undefined\n}\n\n/**\n * A query parameter was present but malformed.\n *\n * Distinct from absent: a filter nobody asked for is not an error, but one\n * that cannot be honoured must not quietly widen the query. Carries its own\n * message so the handler can hand it to the client, the way `TimeWindowError`\n * already does for the time window.\n */\nexport class QueryParameterError extends Error {}\n\n/**\n * A well-formed query the server cannot currently answer.\n *\n * `radius` measures from the own vessel, so it needs a self position. Not\n * having one is an outage, not a client mistake -- the same distinction the\n * single-vessel route already draws for a history provider it cannot read.\n */\nexport class SelfPositionUnavailableError extends Error {}\n\nexport function validateParameters(params: QueryParameters, defaultMaxRadius: number | undefined): TrackParams {\n // Bounding box as west,south,east,north — GeoJSON coordinate order, the same\n // as the coordinates these endpoints return, the Resources API, and the v2\n // Track API. Internally a bounds is [lat, lng], so the pairs are swapped on\n // the way in.\n let bbox: GeoBounds | null = null\n const rawBbox = firstValue(params.bbox)\n if (rawBbox !== undefined) {\n const b = rawBbox.split(',').map(toFiniteNumber)\n // Every one of the four must have parsed; a `0` is a valid coordinate, so\n // test for undefined rather than truthiness.\n const [west, south, east, north] = b\n if (b.length !== 4 || west === undefined || south === undefined || east === undefined || north === undefined) {\n // Thrown rather than dropped. Yielding `null` here would skip the filter\n // and answer 200 with every track, so a typo'd bbox silently returns\n // more than was asked for -- the opposite of what a filter is for.\n throw new QueryParameterError('bbox must be four comma-separated numbers: west,south,east,north')\n }\n if (south > north) {\n // Wording follows the v2 Track API's own check, so the two surfaces\n // reject the same query the same way.\n throw new QueryParameterError(`bbox south (${south}) must not be greater than north (${north})`)\n }\n bbox = { sw: [south, west], ne: [north, east] }\n }\n\n let radius: number | null = null\n // radius in meters\n const rawRadius = firstValue(params.radius)\n if (rawRadius !== undefined) {\n const parsed = toFiniteNumber(rawRadius)\n if (parsed === undefined) {\n // Same reasoning as the bbox above: falling back to `null` here drops the\n // filter and answers with every track, so a typo returns more than was\n // asked for rather than failing.\n throw new QueryParameterError(`radius must be a number in meters, got '${rawRadius}'`)\n }\n if (parsed < 0) {\n // A negative radius matched nothing, silently -- an empty result for a\n // query that cannot be satisfied by construction.\n throw new QueryParameterError(`radius must not be negative, got ${parsed}`)\n }\n radius = parsed\n } else if (defaultMaxRadius) {\n radius = defaultMaxRadius\n }\n return { bbox, radius }\n}\n\nexport function createDistanceTo([lat1d, lon1d]: LatLngTuple, debug?: Debug): (d: LatLngTuple | null) => number {\n const Rk = 6371 // mean radius of the earth (km) at 39 degrees from the equator\n\n // convert coordinates to radians\n const lat1 = degreesToRadians(lat1d)\n const lon1 = degreesToRadians(lon1d)\n\n return (dest) => {\n if (!dest) {\n return Number.MAX_SAFE_INTEGER\n }\n const [lat2d, lon2d] = dest\n const lat2 = degreesToRadians(lat2d)\n const lon2 = degreesToRadians(lon2d)\n\n // find the differences between the coordinates\n const dlat = lat2 - lat1\n const dlon = lon2 - lon1\n\n //** calculate **\n const a = Math.pow(Math.sin(dlat / 2), 2) + Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin(dlon / 2), 2)\n const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))\n const dk = c * Rk * 1000 // great circle distance in m\n if (debug?.enabled) {\n debug(`${lat2d},${lon2d} => ${dk}`)\n }\n return dk\n }\n}\n\nconst degreesToRadians = (value: number) => {\n return (Math.PI / 180) * value\n}\n\nfunction lastPoint(track: LatLngTuple[]): LatLngTuple | null {\n return track.at(-1) ?? null\n}\n\n// Positions are accumulated under the context carried by the delta, which for\n// the own vessel is always the fully qualified context (vessels.urn:mrn:...),\n// never the literal `vessels.self`. Resolve the `self` alias the rest of the\n// Signal K HTTP API accepts so a lookup for it can hit.\nexport function resolveContext(vesselId: string, selfContext?: string): string {\n if ((vesselId === 'self' || vesselId === 'vessels.self') && selfContext) {\n return selfContext\n }\n return vesselId.startsWith('vessels.') ? vesselId : `vessels.${vesselId}`\n}\n\nexport function createMatcher(\n params: TrackParams,\n selfPosition?: LatLngTuple,\n debug?: Debug,\n): (track: LatLngTuple[]) => boolean {\n if (params.bbox) {\n const inBounds = createInBounds(params.bbox)\n return params.intersects\n ? (track: LatLngTuple[]) => track.some(inBounds)\n : (track: LatLngTuple[]) => inBounds(lastPoint(track))\n } else if (params.radius !== null) {\n if (!selfPosition) {\n throw new SelfPositionUnavailableError('No self position to calculate radius values')\n }\n const radius = params.radius\n const distanceFromSelf = createDistanceTo(selfPosition, debug)\n return (track: LatLngTuple[]) => distanceFromSelf(lastPoint(track)) < radius\n }\n return () => true\n}\n\n/**\n * A history row is `[timestamp, position]`. Both encodings are legal: the\n * History API types a value as `unknown` and its own comment names \"objects\n * (like navigation.position)\" alongside primitives, so signalk-questdb returns\n * `{latitude, longitude}` and signalk-to-influxdb2 returns `[lon, lat]`.\n * Accept both — neither is an error, so rejecting one silently yields an empty\n * track rather than a failure anybody can see.\n */\nexport const historyRowPosition = (row: unknown): LatLngTuple | undefined => {\n if (!Array.isArray(row) || row.length < 2) {\n return undefined\n }\n const value: unknown = row[1]\n // isFinite, not typeof number: NaN and Infinity are numbers, and one of them\n // in a coordinate reaches the store and stretches every bounding box that\n // track appears in — silently, since nothing downstream re-checks.\n if (Array.isArray(value) && value.length === 2 && Number.isFinite(value[0]) && Number.isFinite(value[1])) {\n const [longitude, latitude] = value as [number, number]\n return [latitude, longitude]\n }\n if (value && typeof value === 'object' && 'latitude' in value && 'longitude' in value) {\n const { latitude, longitude } = value as Partial<Position>\n if (Number.isFinite(latitude) && Number.isFinite(longitude)) {\n return [latitude as number, longitude as number]\n }\n }\n return undefined\n}\n\n/**\n * The name of the vessel (or aircraft, or aton) a context refers to.\n *\n * Deliberately bare — `Ariadne`, not `AIS Ariadne` and not `Own Ship`. This is\n * the v2 Track API's `contextName`, whose spec says \"Name of the vessel,\n * aircraft or other context, where known. Not a name for the track itself.\"\n * Decoration belongs to whoever is drawing a list; see `trackLabel`.\n *\n * `lookup` reads the data model (`app.getPath`). Note the shape: a vessel's\n * `name` is a bare string in the full model, not the `{value}` wrapper that\n * `navigation.position` and friends carry, so both are accepted rather than\n * guessed at.\n *\n * Resolves `name` and nothing else, matching the server's own `findContextName`\n * (`packages/server-admin-ui/src/utils/pathKeys.ts`), which returns undefined\n * rather than substituting an identifier. An MMSI is how a vessel is addressed,\n * not what it is called, so it belongs in `trackLabel` where something has to\n * render — putting it here would make \"where known\" untrue.\n */\nexport function contextName(context: string, lookup: (path: string) => unknown): string | undefined {\n return unwrapString(lookup(`${context}.name`))\n}\n\n/**\n * A display label for a track, as a chart plotter would show it.\n *\n * TimeZero's GPX exports label tracks `Own Ship` and `AIS <shipname>` — never a\n * serial number — and that is what makes a list of tracks readable. A UI\n * listing `vessels.urn:mrn:imo:mmsi:211234567` is unusable.\n *\n * Distinct from `contextName`: this one *is* a name for the track, so it says\n * whose track it is and where the identity came from. Falls back to the raw\n * context, because a label has to render something.\n */\nexport function trackLabel(\n context: string,\n selfContext: string | undefined,\n lookup: (path: string) => unknown,\n): string {\n if (context === selfContext) {\n return 'Own Ship'\n }\n const identity = contextName(context, lookup) ?? unwrapString(lookup(`${context}.mmsi`)) ?? mmsiFromContext(context)\n return identity === undefined ? context : `AIS ${identity}`\n}\n\n/** Accepts both a bare string and the `{value}` wrapper deltas arrive in. */\nexport function unwrapString(raw: unknown): string | undefined {\n const value: unknown = raw && typeof raw === 'object' && 'value' in raw ? raw.value : raw\n if (typeof value !== 'string') {\n return undefined\n }\n const trimmed = value.trim()\n return trimmed === '' ? undefined : trimmed\n}\n\n/** `vessels.urn:mrn:imo:mmsi:211234567` -> `211234567`. */\nfunction mmsiFromContext(context: string): string | undefined {\n const match = /urn:mrn:imo:mmsi:(\\d+)$/.exec(context)\n return match?.[1]\n}\n\n/**\n * A track label as a filename a browser can save.\n *\n * A vessel name arrives from an AIS transmission and is not ours to trust in a\n * path, so the characters that could break out of one — separators, quotes,\n * controls — are replaced. Everything else is kept, including non-ASCII: a\n * boat called `Ärger` keeps its name.\n */\nexport function gpxFilename(label: string): string {\n // Only the characters a path or a header could be broken by are replaced,\n // rather than everything outside ASCII: stripping those turns `Ärger` into\n // `rger` and `日本` into nothing at all, quietly renaming somebody's vessel.\n const safe = label\n .replace(/[\\p{Cc}\\p{Cf}\"'\\\\/:*?<>|]+/gu, '-')\n .replace(/\\s+/g, '-')\n .replace(/-+/g, '-')\n .replace(/^[-.]+|[-.]+$/g, '')\n return `${safe === '' ? 'track' : safe}.gpx`\n}\n\n/**\n * The ASCII-only form of a filename, for the quoted `Content-Disposition`\n * parameter.\n *\n * An HTTP header carries bytes: anything outside Latin-1 makes `setHeader`\n * throw, and anything outside ASCII is reinterpreted byte-wise by the client,\n * so `Ärger.gpx` arrives as mojibake. The real name travels in the `filename*`\n * parameter; this is the fallback for readers that do not implement it.\n */\nexport function asciiFilename(filename: string): string {\n // A rough fallback on purpose. Every current browser reads `filename*`, which\n // carries the real name; this form exists for readers that do not, and no\n // transliteration of `日本` into ASCII would be more use to them than a dash.\n // Two vessels whose names differ only outside ASCII therefore collide here,\n // which is a worse outcome than a leading dash on every such download.\n const ascii = [...filename].map((character) => (character < '\\u0080' ? character : '-')).join('')\n const trimmed = ascii.replace(/-{2,}/g, '-').replace(/^-+/, '')\n return trimmed === '.gpx' || trimmed === '' ? 'track.gpx' : trimmed\n}\n\n/**\n * A filename as an RFC 8187 extended value, for `filename*`.\n *\n * `encodeURIComponent` is close but not exact: it leaves `!'()*` unescaped,\n * and RFC 8187's `attr-char` set excludes them. A vessel called\n * `Boat (Test)` would otherwise put raw parentheses into the header, which is\n * not a valid extended value.\n */\nexport function rfc8187(filename: string): string {\n return encodeURIComponent(filename).replace(\n /[!'()*]/g,\n (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,\n )\n}\n"],"mappings":"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"}