@signalk/tracks-plugin 3.1.1 → 3.2.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/tracks.js CHANGED
@@ -25,6 +25,9 @@
25
25
  const WINDOW_V2 = 'P30D'
26
26
  const WINDOW_V1 = '30d'
27
27
 
28
+ /** This plugin's id, which the server stamps on its tracks as `providerId`. */
29
+ const PROVIDER_ID = 'tracks'
30
+
28
31
  /**
29
32
  * Ceiling on the list request.
30
33
  *
@@ -36,6 +39,10 @@ const REQUEST_TIMEOUT_MS = 30_000
36
39
 
37
40
  /** Relative, because the server mounts this under /@signalk/tracks-plugin. */
38
41
  const TRACKS_URL = `../../signalk/v2/api/tracks?geometry=false&duration=${WINDOW_V2}`
42
+ /** One track by its id, on a server that serves them so. */
43
+ const TRACK_URL = '../../signalk/v2/api/tracks/'
44
+ /** Every import, in the shape of the list's rows; the window above misses old and untimed ones. */
45
+ const IMPORTS_URL = '../../plugins/tracks/imports'
39
46
 
40
47
  const status = document.getElementById('status')
41
48
  const table = document.getElementById('tracks')
@@ -56,8 +63,8 @@ function label({ context, contextName, isSelf }) {
56
63
  if (contextName) {
57
64
  return `AIS ${contextName}`
58
65
  }
59
- const mmsi = /urn:mrn:imo:mmsi:(\d+)$/.exec(context)
60
- return mmsi ? `AIS ${mmsi[1]}` : context
66
+ const mmsi = /urn:mrn:imo:mmsi:(\d+)$/.exec(context ?? '')
67
+ return mmsi ? `AIS ${mmsi[1]}` : (context ?? 'No vessel')
61
68
  }
62
69
 
63
70
  /** ISO-8601 from the API, rendered in the viewer's own locale and zone. */
@@ -73,16 +80,48 @@ function row(properties) {
73
80
  if (properties.isSelf) {
74
81
  name.className = 'self'
75
82
  }
83
+ // An imported track names the vessel it belongs to and has a name of its
84
+ // own; without the second it reads exactly like that vessel's recording.
85
+ if (typeof properties.name === 'string') {
86
+ const imported = document.createElement('span')
87
+ imported.className = 'imported'
88
+ imported.textContent = ` (imported: ${properties.name})`
89
+ name.append(imported)
90
+ }
76
91
  tr.append(
77
92
  name,
78
93
  cell(when(properties.from)),
79
94
  cell(when(properties.to)),
80
95
  cell(properties.pointCount, 'num'),
81
96
  exportCell(properties),
97
+ deleteCell(properties),
82
98
  )
83
99
  return tr
84
100
  }
85
101
 
102
+ /**
103
+ * A button asking `bin.js` to delete this track.
104
+ *
105
+ * Handed over as an event rather than handled here, so the list stays a page
106
+ * that only reads; a track that cannot be addressed gets none.
107
+ */
108
+ function deleteCell(properties) {
109
+ const td = document.createElement('td')
110
+ // The server names a track `providerId:trackId`; one that hands out the
111
+ // provider's own ids has no route to delete them by.
112
+ const { id, providerId } = properties
113
+ if (typeof id === 'string' && typeof providerId === 'string' && id.startsWith(`${providerId}:`)) {
114
+ const button = document.createElement('button')
115
+ button.type = 'button'
116
+ button.textContent = 'Delete…'
117
+ button.addEventListener('click', () => {
118
+ document.dispatchEvent(new CustomEvent('tracks:delete', { detail: { ...properties, label: label(properties) } }))
119
+ })
120
+ td.append(button)
121
+ }
122
+ return td
123
+ }
124
+
86
125
  /**
87
126
  * A link that downloads this track as GPX.
88
127
  *
@@ -90,13 +129,27 @@ function row(properties) {
90
129
  * response with a Content-Disposition on it, and the server sets one. Doing it
91
130
  * by hand would mean holding a whole track in memory to build a blob.
92
131
  */
93
- function exportCell({ context, isSelf }) {
132
+ function exportCell(properties) {
133
+ const { providerId, context, isSelf } = properties
94
134
  const td = document.createElement('td')
95
135
  const link = document.createElement('a')
96
- // `self` resolves server-side; using it keeps the URL short and avoids
97
- // guessing how the own vessel's context should be spelled.
98
- const vessel = isSelf ? 'self/track.gpx' : `vessels/${encodeURIComponent(context)}/track.gpx`
99
- link.href = `../../signalk/v1/api/${vessel}?duration=${WINDOW_V1}`
136
+ const trackId = localId(properties)
137
+ // An import is exported as it was imported, never as the recording of the
138
+ // vessel it names; v1 does not know imports at all. Only this plugin's own:
139
+ // another provider's ids mean nothing to its route.
140
+ if (typeof trackId === 'string' && trackId.startsWith('imported:')) {
141
+ if (providerId !== undefined && providerId !== PROVIDER_ID) {
142
+ return td
143
+ }
144
+ link.href = `../../plugins/tracks/imports/${encodeURIComponent(trackId)}/track.gpx`
145
+ } else if (typeof context === 'string') {
146
+ // `self` resolves server-side; using it keeps the URL short and avoids
147
+ // guessing how the own vessel's context should be spelled.
148
+ const vessel = isSelf ? 'self/track.gpx' : `vessels/${encodeURIComponent(context)}/track.gpx`
149
+ link.href = `../../signalk/v1/api/${vessel}?duration=${WINDOW_V1}`
150
+ } else {
151
+ return td
152
+ }
100
153
  link.textContent = 'GPX'
101
154
  link.rel = 'nofollow'
102
155
  td.append(link)
@@ -123,14 +176,68 @@ function show(message, state) {
123
176
  }
124
177
  }
125
178
 
179
+ /**
180
+ * Every imported track, or none when the server cannot list them: one that
181
+ * predates the route, or keeps it to administrators. The list then shows what
182
+ * the Track API returns, as it always has.
183
+ */
184
+ async function allImports(signal) {
185
+ try {
186
+ const response = await fetch(IMPORTS_URL, { credentials: 'include', signal })
187
+ const body = response.ok ? await response.json() : undefined
188
+ return Array.isArray(body) ? body : []
189
+ } catch {
190
+ return []
191
+ }
192
+ }
193
+
194
+ /**
195
+ * A track's id without the provider prefix, which a server older than the
196
+ * Track API's write routes leaves off; the import listing always carries it.
197
+ */
198
+ function localId(properties) {
199
+ const { id, providerId } = properties ?? {}
200
+ return typeof id === 'string' && typeof providerId === 'string' && id.startsWith(`${providerId}:`)
201
+ ? id.slice(providerId.length + 1)
202
+ : id
203
+ }
204
+
205
+ /**
206
+ * Whether the server names tracks `providerId:trackId`. Its listing shows it;
207
+ * with nothing listed, asking it for one import by that name does.
208
+ */
209
+ async function namesByProvider(listed, unlisted, signal) {
210
+ if (listed.length > 0) {
211
+ return listed.some(
212
+ (properties) =>
213
+ typeof properties?.providerId === 'string' &&
214
+ typeof properties.id === 'string' &&
215
+ properties.id.startsWith(`${properties.providerId}:`),
216
+ )
217
+ }
218
+ if (unlisted.length === 0) {
219
+ return false
220
+ }
221
+ try {
222
+ const response = await fetch(`${TRACK_URL}${encodeURIComponent(unlisted[0].id)}`, {
223
+ credentials: 'include',
224
+ signal,
225
+ })
226
+ return response.ok
227
+ } catch {
228
+ return false
229
+ }
230
+ }
231
+
126
232
  async function load() {
127
- let response
128
233
  // Bounded, because an unsettled fetch is the one failure the page cannot
129
234
  // recover from: every other path ends in an error message, while a stalled
130
235
  // server or proxy leaves it on "Loading…" indefinitely, which reads as a
131
236
  // hang rather than a failure. The signal stays armed through json() below,
132
237
  // so a response that stops mid-body is caught too.
133
238
  const deadline = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
239
+ const imports = allImports(deadline)
240
+ let response
134
241
  try {
135
242
  response = await fetch(TRACKS_URL, { credentials: 'include', signal: deadline })
136
243
  } catch {
@@ -171,13 +278,24 @@ async function load() {
171
278
  }
172
279
  // Individual entries are still skipped rather than thrown on: one malformed
173
280
  // feature should not blank a list that is otherwise fine.
174
- const features = body.features
281
+ const listed = body.features.map((feature) => feature?.properties)
282
+ const ids = new Set(listed.map(localId))
283
+ const unlisted = (await imports).filter((properties) => !ids.has(localId(properties)))
284
+ // A server older than the Track API's write routes names tracks without
285
+ // their provider and cannot delete by id, so the imports added here follow
286
+ // suit there rather than offer a delete that cannot work.
287
+ const older = (await namesByProvider(listed, unlisted, deadline))
288
+ ? unlisted
289
+ : unlisted.map((properties) => ({ ...properties, id: localId(properties), providerId: undefined }))
175
290
  // Newest first: the track someone came to look at is almost always the one
176
291
  // that just finished.
177
- const sorted = features
178
- .map((feature) => feature?.properties)
292
+ const sorted = [...listed, ...older]
293
+ // An import may name no vessel; it is still listed, so it can be deleted.
179
294
  .filter(
180
- (properties) => properties && typeof properties.context === 'string' && Number.isFinite(properties.pointCount),
295
+ (properties) =>
296
+ properties &&
297
+ (typeof properties.context === 'string' || typeof properties.name === 'string') &&
298
+ Number.isFinite(properties.pointCount),
181
299
  )
182
300
  .sort((a, b) => String(b.to ?? '').localeCompare(String(a.to ?? '')))
183
301
 
@@ -190,7 +308,8 @@ async function load() {
190
308
 
191
309
  tbody.replaceChildren(...sorted.map(row))
192
310
  table.hidden = false
193
- show(`${sorted.length} track${sorted.length === 1 ? '' : 's'} in the last 30 days.`)
311
+ const tracks = `${sorted.length} track${sorted.length === 1 ? '' : 's'}`
312
+ show(older.length > 0 ? `${tracks}: the last 30 days, and every import.` : `${tracks} in the last 30 days.`)
194
313
  }
195
314
 
196
315
  void load()
package/dist/types.d.ts CHANGED
@@ -29,6 +29,80 @@ export interface TimedPosition {
29
29
  */
30
30
  breakBefore?: boolean;
31
31
  }
32
+ /**
33
+ * A point of an imported track. `timestamp` is absent when the import carried
34
+ * no times: a GPX without `<time>` is a shape someone sailed, not a recording.
35
+ */
36
+ export interface ImportedPoint {
37
+ position: LatLngTuple;
38
+ timestamp?: number;
39
+ }
40
+ /**
41
+ * A track a client supplied, kept apart from recorded positions.
42
+ *
43
+ * It belongs to whoever imported it rather than to the recording: it is never
44
+ * merged into a vessel's recorded track, reconciled with a history provider,
45
+ * or aged out by retention, and it stays until it is deleted by its id.
46
+ */
47
+ export interface ImportedTrack {
48
+ id: string;
49
+ /** The vessel it belongs to, when the import named one. */
50
+ context?: Context;
51
+ /** The track's own name, such as a GPX `<trk><name>`. */
52
+ name?: string;
53
+ /** The client's other properties, returned as they were posted. */
54
+ metadata: Record<string, unknown>;
55
+ /** Segments as posted; a point is either timed throughout or not at all. */
56
+ segments: ImportedPoint[][];
57
+ }
58
+ /** Which imported tracks a query can see. Every field narrows; none means all. */
59
+ export interface ImportFilter {
60
+ /** Only imports naming one of these contexts; contextless ones drop out. */
61
+ contexts?: Context[];
62
+ /**
63
+ * Only timed imports whose time span overlaps the window; untimed ones drop
64
+ * out. Like `bbox`, a match on the extent rather than on the points.
65
+ */
66
+ window?: TimeWindow;
67
+ /**
68
+ * Only imports whose extent overlaps the box. A coarse match on the extent:
69
+ * an import can overlap without a point inside, so callers that need a
70
+ * point inside test the points themselves.
71
+ */
72
+ bbox?: GeoBounds;
73
+ }
74
+ /**
75
+ * The positions recorded or imported between two instants, both inclusive. An
76
+ * absent `from` reaches back to the start of the track.
77
+ */
78
+ export interface DeletedSpan {
79
+ from?: number;
80
+ to: number;
81
+ }
82
+ /** A recorded vessel's deleted span, which hides history as well as the store. */
83
+ export interface RecordedSpan extends DeletedSpan {
84
+ context: Context;
85
+ }
86
+ /**
87
+ * One delete waiting in the recycle bin: a span of a vessel's recording, a
88
+ * span of an imported track, or a whole imported track (`whole`, no span).
89
+ */
90
+ export interface BinEntry {
91
+ id: number;
92
+ /** The recorded vessel, or the vessel an import names. */
93
+ context?: Context;
94
+ importId?: string;
95
+ /** An import's own name. */
96
+ name?: string;
97
+ whole: boolean;
98
+ from?: number;
99
+ to?: number;
100
+ deletedAt: number;
101
+ /** Points held in the bin for this entry. */
102
+ pointCount: number;
103
+ }
104
+ /** What restoring a recycle bin entry came to. */
105
+ export type RestoreResult = 'restored' | 'missing' | 'conflict';
32
106
  /**
33
107
  * A time window in epoch milliseconds, half-open as `[from, to)`.
34
108
  *
@@ -0,0 +1,19 @@
1
+ import { Request } from 'express';
2
+ /** The largest GPX file the upload route reads, so one request cannot exhaust memory. */
3
+ export declare const MAX_UPLOAD_BYTES: number;
4
+ export declare class UploadTooLargeError extends Error {
5
+ constructor(limit: number);
6
+ }
7
+ /** A body the server already parsed as something other than a file. */
8
+ export declare class UploadTypeError extends Error {
9
+ constructor();
10
+ }
11
+ /**
12
+ * A request's body as UTF-8 text, refused past `limit` bytes.
13
+ *
14
+ * Read here rather than by a body parser because the server parses only JSON
15
+ * and form bodies, and a GPX file arrives as XML. A body some other middleware
16
+ * has already read as text is taken as it is; one it read as anything else is
17
+ * refused, since the stream it came from is spent.
18
+ */
19
+ export declare function readText(req: Request, limit?: number): Promise<string>;
@@ -576,7 +576,37 @@ function asciiFilename(filename) {
576
576
  function rfc8187(filename) {
577
577
  return encodeURIComponent(filename).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
578
578
  }
579
+ /**
580
+ * The narrower of the two longitude intervals covering these points.
581
+ *
582
+ * A plain min/max describes a track spanning the antimeridian as almost the
583
+ * whole globe: two fixes at 179 and -179 are two degrees apart, but min/max
584
+ * reports 358. RFC 7946 says a box crossing the antimeridian is written with
585
+ * west greater than east, so the wrap-around interval is expressible — pick it
586
+ * whenever it is the shorter of the two.
587
+ *
588
+ * The widest gap between adjacent longitudes is the part of the globe the track
589
+ * does not cover, so the complement of that gap is the tightest interval that
590
+ * does.
591
+ */
592
+ var longitudeSpan = (longitudes) => {
593
+ const sorted = [...longitudes].sort((a, b) => a - b);
594
+ const min = sorted[0];
595
+ const max = sorted[sorted.length - 1];
596
+ let widestGap = 360 - (max - min);
597
+ let west = min;
598
+ let east = max;
599
+ for (let i = 1; i < sorted.length; i++) {
600
+ const gap = sorted[i] - sorted[i - 1];
601
+ if (gap > widestGap) {
602
+ widestGap = gap;
603
+ west = sorted[i];
604
+ east = sorted[i - 1];
605
+ }
606
+ }
607
+ return [west, east];
608
+ };
579
609
  //#endregion
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 };
610
+ export { thinToBudget as C, thin as S, unwrapString as _, contextName as a, parseTrackQuery as b, createMatcher as c, longitudeSpan as d, resolveContext as f, trackLabel as g, toIsoTimes as h, clipToBounds as i, gpxFilename as l, splitAtAntimeridian as m, SelfPositionUnavailableError as n, createDistanceTo as o, rfc8187 as p, asciiFilename as r, createInBounds as s, QueryParameterError as t, historyRowPosition as u, validateParameters as v, uncoveredBetween as w, segment as x, TimeWindowError as y };
581
611
 
582
- //# sourceMappingURL=utils-CGIGp4Xf.js.map
612
+ //# sourceMappingURL=utils-C0VkiidM.js.map
@@ -1 +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":";;;;;;;;;;;;;;;;;AAkBA,IAAM,cAAc,KAAK,OAAO;CAC9B,MAAM,KAAK,SAAS,KAAK,OAAO,CAAC;CACjC,IAAI,KAAK,SAAS,KAAK,OAAO,CAAC;CAC/B,UAAU,KAAK,SAAS,KAAK,OAAO,CAAC;CACrC,UAAU,KAAK,SAAS,KAAK,OAAO,CAAC;CACrC,gBAAgB,KAAK,SAAS,KAAK,OAAO,CAAC;CAC3C,YAAY,KAAK,SAAS,KAAK,OAAO,CAAC;CACvC,OAAO,KAAK,SAAS,KAAK,OAAO,CAAC;AACpC,CAAC;AAED,IAAM,aAAgC,OAAO,KAAK,YAAY,UAAU;AAExE,IAAa,kBAAb,cAAqC,MAAM,CAAC;;AAG5C,IAAM,mBAAmB;AAEzB,IAAM,UAAkC;CACtC,GAAG;CACH,GAAG;CACH,GAAG;CACH,GAAG;AACL;AAEA,SAAgB,cAAc,OAAuB;CACnD,MAAM,QAAQ,iBAAiB,KAAK,MAAM,KAAK,CAAC;CAChD,MAAM,SAAS,QAAQ;CACvB,IAAI,CAAC,QACH,MAAM,IAAI,gBAAgB,qBAAqB,MAAM,2DAA2D;CAElH,MAAM,OAAO,MAAM,MAAM;CACzB,MAAM,QAAQ,QAAQ;CACtB,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,gBAAgB,0BAA0B,KAAK,EAAE;CAE7D,OAAO,OAAO,MAAM,IAAI;AAC1B;AAEA,SAAS,aAAa,OAAe,MAAsB;CACzD,MAAM,KAAK,KAAK,MAAM,KAAK;CAC3B,IAAI,OAAO,MAAM,EAAE,GACjB,MAAM,IAAI,gBAAgB,WAAW,KAAK,IAAI,MAAM,kCAAkC;CAExF,OAAO;AACT;AAEA,IAAM,aAAa;CAAC;CAAQ;CAAK;AAAK;AACtC,IAAM,cAAc;CAAC;CAAS;CAAK;AAAI;;;;;AAMvC,SAAS,UAAU,OAA2B,MAAuB;CACnE,IAAI,UAAU,KAAA,GACZ,OAAO;CAET,MAAM,OAAO,MAAM,KAAK,CAAC,CAAC,YAAY;CACtC,IAAI,WAAW,SAAS,IAAI,GAC1B,OAAO;CAET,IAAI,YAAY,SAAS,IAAI,GAC3B,OAAO;CAET,MAAM,IAAI,gBAAgB,WAAW,KAAK,IAAI,MAAM,0BAA0B;AAChF;;AAGA,SAAS,YAAY,OAAoC;CACvD,MAAM,SAAkB,MAAM,QAAQ,KAAK,IAAK,MAAoB,KAAK;CACzE,OAAO,OAAO,WAAW,YAAY,OAAO,KAAK,MAAM,KAAK,SAAS,KAAA;AACvE;;;;;;;;AAyBA,SAAgB,gBACd,OACA,MAAc,KAAK,IAAI,GACvB,cAAiC,CAAC,GACtB;CASZ,MAAM,UAAU,OAAO,KAAK,KAAK,CAAC,CAAC,QAAQ,QAAQ,CAAC,WAAW,SAAS,GAAG,KAAK,CAAC,YAAY,SAAS,GAAG,CAAC;CAC1G,IAAI,QAAQ,SAAS,GAAG;EACtB,MAAM,UAAU,CAAC,GAAG,YAAY,GAAG,WAAW,CAAC,CAAC,KAAK,IAAI;EACzD,MAAM,IAAI,gBACR,0BAA0B,QAAQ,SAAS,IAAI,MAAM,GAAG,GAAG,QAAQ,KAAK,IAAI,EAAE,cAAc,SAC9F;CACF;CACA,MAAM,MAAM,OAAO,YAAY,WAAW,KAAK,QAAQ,CAAC,KAAK,YAAY,MAAM,IAAI,CAAC,CAAC,CAAC;CAEtF,IAAI,CAAC,MAAM,MAAM,aAAa,GAAG,GAC/B,MAAM,IAAI,gBAAgB,gCAAgC;CAG5D,MAAM,SAAqB,CAAC;CAK5B,IAAI,OAAO,UAAU,eAAe,KAAK,OAAO,OAAO,GACrD,OAAO,QAAQ,UAAU,IAAI,OAAO,OAAO;CAG7C,IAAI,IAAI,eAAe,KAAA,GAAW;EAChC,MAAM,aAAa,cAAc,IAAI,UAAU;EAC/C,IAAI,aAAa,GACf,OAAO,aAAa;CAExB;CAKA,IAAI,IAAI,SAAS,KAAA,KAAa,IAAI,OAAO,KAAA,GAAW;EAClD,MAAM,OAAO,IAAI,SAAS,KAAA,IAAY,aAAa,IAAI,MAAM,MAAM,IAAI,OAAO;EAC9E,MAAM,KAAK,IAAI,OAAO,KAAA,IAAY,aAAa,IAAI,IAAI,IAAI,IAAI;EAC/D,IAAI,QAAQ,IACV,MAAM,IAAI,gBAAgB,4BAA4B;EAExD,OAAO;GAAE,GAAG;GAAQ,QAAQ;IAAE;IAAM;IAAI,cAAc,IAAI,OAAO,KAAA;GAAU;EAAE;CAC/E;CAEA,IAAI,IAAI,aAAa,KAAA,GAAW;EAC9B,MAAM,WAAW,cAAc,IAAI,QAAQ;EAC3C,OAAO;GAAE,GAAG;GAAQ,QAAQ;IAAE,MAAM,MAAM;IAAU,IAAI;IAAK,cAAc;GAAK;EAAE;CACpF;CAEA,IAAI,IAAI,aAAa,KAAA,GAAW;EAG9B,MAAM,OAAO,cAAc,IAAI,QAAQ;EACvC,MAAM,SAAS,IAAI,mBAAmB,KAAA,IAAY,cAAc,GAAG,IAAI,eAAe,EAAE,IAAI;EAC5F,MAAM,KAAK,MAAM;EACjB,OAAO;GAAE,GAAG;GAAQ,QAAQ;IAAE,MAAM,KAAK;IAAM;IAAI,cAAc,WAAW;GAAE;EAAE;CAClF;CAEA,OAAO;AACT;;;;;;;;;;;;AAaA,SAAgB,iBAAiB,UAAyB,OAA8B;CACtF,OAAO,MAAM,YAAY,SAAS,YAAY,KAAK,IAAI,SAAS,QAAQ,GAAG,MAAM,QAAQ,CAAC;AAC5F;;;;;;AAOA,SAAgB,aAAa,UAAyB,OAA8B;CAClF,OAAO,MAAM,eAAe,iBAAiB,UAAU,KAAK;AAC9D;;;;;;;;;;AAWA,SAAgB,KAAK,QAAyB,YAAiD;CAC7F,IAAI,CAAC,cAAc,OAAO,UAAU,GAClC,OAAO;CAIT,MAAM,YAAY,cAAc,MAAM;CACtC,OAAO,UAAU,WAAW,IAAI,YAAY,QAAQ,UAAU,IAAI,UAAU,SAAS,MAAM,YAAY,GAAG,UAAU,CAAC;AACvH;;AAGA,SAAS,cAAc,QAA4C;CACjE,IAAI,CAAC,OAAO,MAAM,EAAE,kBAAkB,WAAW,GAC/C,OAAO,CAAC,MAAM;CAEhB,MAAM,YAA+B,CAAC;CACtC,IAAI,UAA2B,CAAC;CAChC,KAAK,MAAM,SAAS,QAAQ;EAC1B,IAAI,MAAM,eAAe,QAAQ,SAAS,GAAG;GAC3C,UAAU,KAAK,OAAO;GACtB,UAAU,CAAC;EACb;EACA,QAAQ,KAAK,KAAK;CACpB;CACA,IAAI,QAAQ,SAAS,GACnB,UAAU,KAAK,OAAO;CAExB,OAAO;AACT;AAEA,SAAS,YAAY,QAAyB,YAAqC;CACjF,IAAI,OAAO,UAAU,GACnB,OAAO;CAET,MAAM,SAA0B,CAAC;CACjC,IAAI,WAAW,OAAO;CACtB,IAAI;CAEJ,IAAI;CACJ,IAAI,WAAW;CACf,KAAK,MAAM,SAAS,QAAQ;EAC1B,MAAM,QAAQ,WAAW,aAAa,UAAU,KAAK,IAAI;EACzD,WAAW,MAAM,YAAY,YAAY;EACzC,IAAI,UAAU;GACZ,OAAO,KAAK,iBAAiB,KAAA,IAAY,QAAQ;IAAE,GAAG;IAAO,aAAa,KAAK,IAAI,cAAc,KAAK;GAAE,CAAC;GACzG,WAAW,MAAM;GACjB,eAAe,KAAA;EACjB,OACE,eAAe,KAAK,IAAI,gBAAgB,OAAO,KAAK;EAEtD,WAAW;CACb;CACA,MAAM,OAAO,OAAO,OAAO,SAAS;CACpC,IAAI,QAAQ,CAAC,UACX,OAAO,KAAK;EAAE,GAAG;EAAM,aAAa,gBAAgB;CAAE,CAAC;CAEzD,OAAO;AACT;;;;;;;;;;;;;;;AAgBA,SAAgB,aACd,QACA,WACA,YAC6D;CAC7D,MAAM,QAAQ,KAAK,QAAQ,UAAU;CACrC,IAAI,MAAM,UAAU,WAClB,OAAO;EAAE,QAAQ;EAAO;CAAW;CAMrC,IAAI,YAAY,GACd,OAAO;EAAE,QAAQ,MAAM,MAAM,GAAG,KAAK,IAAI,GAAG,SAAS,CAAC;EAAG;CAAW;CAEtE,MAAM,OAAO,MAAM,MAAM,SAAS,EAAE,CAAE,YAAY,MAAM,EAAE,CAAE;CAG5D,IAAI,UAAU,KAAK,IAAI,GAAG,KAAK,KAAK,QAAQ,YAAY,EAAE,GAAG,cAAc,CAAC;CAC5E,IAAI,SAAS,KAAK,QAAQ,OAAO;CAMjC,OAAO,OAAO,SAAS,aAAa,WAAW,MAAM;EACnD,WAAW;EACX,SAAS,KAAK,QAAQ,OAAO;CAC/B;CACA,OAAO;EAAE,QAAQ;EAAQ,YAAY;CAAQ;AAC/C;;;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,QAAQ,QAAyB,KAA4C;CAC3F,IAAI,OAAO,WAAW,GACpB,OAAO,CAAC;CAEV,IAAI,CAAC,OAAO,OAAO,GACjB,OAAO,cAAc,MAAM;CAE7B,MAAM,WAA8B,CAAC;CACrC,IAAI,UAA2B,CAAC;CAChC,IAAI;CACJ,KAAK,MAAM,SAAS,QAAQ;EAC1B,IAAI,aAAa,KAAA,MAAc,MAAM,eAAe,aAAa,UAAU,KAAK,IAAI,MAAM;GACxF,SAAS,KAAK,OAAO;GACrB,UAAU,CAAC;EACb;EACA,QAAQ,KAAK,KAAK;EAClB,WAAW;CACb;CACA,IAAI,QAAQ,SAAS,GACnB,SAAS,KAAK,OAAO;CAEvB,OAAO;AACT;;;AC9WA,IAAM,MAAM;AACZ,IAAM,MAAM;;;;;;;;;;AAWZ,SAAgB,WAAW,SAAoC;CAC7D,OAAO,QAAQ,KAAK,EAAE,gBAAgB,IAAI,KAAK,SAAS,CAAC,CAAC,YAAY,CAAC;AACzE;AAEA,SAAgB,eAAe,QAA8D;CAC3F,MAAM,SAAS,OAAO,GAAG;CACzB,MAAM,SAAS,OAAO,GAAG;CACzB,IAAI,SAAS,QACX,MAAM,IAAI,MAAM,6CAA6C,KAAK,UAAU,MAAM,GAAG;CAEvF,MAAM,SAAS,OAAO,GAAG;CACzB,MAAM,UAAU,OAAO,GAAG,OAAO,OAAO,GAAG,OAAO,MAAM,KAAK,OAAO,GAAG;CAEvE,QAAQ,MAAM;EACZ,OACE,MAAM,QACN,EAAE,QAAQ,UACV,EAAE,QAAQ,WACR,EAAE,QAAQ,UAAU,EAAE,QAAQ,UAAY,EAAE,OAAO,OAAO,UAAU,EAAE,OAAO,OAAO;CAE1F;AACF;;;;;;;;;;;;;AAcA,SAAgB,aAAa,QAAyB,QAAoC;CACxF,MAAM,WAAW,eAAe,MAAM;CACtC,MAAM,SAA0B,CAAC;CACjC,IAAI,SAAS;CACb,KAAK,MAAM,CAAC,GAAG,UAAU,OAAO,QAAQ,GACtC,IAAI,SAAS,MAAM,QAAQ,GAAG;EAC5B,IAAI,CAAC,QAAQ;GACX,MAAM,QAAQ,OAAO,IAAI;GACzB,MAAM,QAAQ,SAAS;GACvB,OAAO,KAAK,OAAO,SAAS,IAAI;IAAE,GAAG;IAAO,aAAa;GAAK,IAAI,KAAK;GACvE,IAAI,OACF,OAAO,KAAK,KAAK;GAEnB,SAAS;EACX,OACE,OAAO,KAAK,KAAK;CAErB,OAAO,IAAI,QAAQ;EACjB,OAAO,KAAK,KAAK;EACjB,SAAS;CACX;CAEF,OAAO;AACT;;;;;;;;;;;;;;;AAgBA,SAAgB,oBAAoB,QAAgC;CAClE,MAAM,OAAO,OAAO,GAAG;CACvB,MAAM,OAAO,OAAO,GAAG;CACvB,IAAI,QAAQ,MACV,OAAO,CAAC,MAAM;CAEhB,MAAM,QAAQ,OAAO,GAAG;CACxB,MAAM,QAAQ,OAAO,GAAG;CACxB,OAAO,CACL;EAAE,IAAI,CAAC,OAAO,IAAI;EAAG,IAAI,CAAC,OAAO,GAAG;CAAE,GACtC;EAAE,IAAI,CAAC,OAAO,IAAI;EAAG,IAAI,CAAC,OAAO,IAAI;CAAE,CACzC;AACF;;;;;AAMA,SAAS,WAAW,OAAoC;CACtD,MAAM,SAAkB,MAAM,QAAQ,KAAK,IAAK,MAAoB,KAAK;CACzE,OAAO,OAAO,WAAW,WAAW,SAAS,KAAA;AAC/C;;AAGA,SAAS,eAAe,OAAmC;CACzD,MAAM,UAAU,MAAM,KAAK;CAC3B,IAAI,YAAY,IACd;CAEF,MAAM,SAAS,OAAO,OAAO;CAC7B,OAAO,OAAO,SAAS,MAAM,IAAI,SAAS,KAAA;AAC5C;;;;;;;;;AAUA,IAAa,sBAAb,cAAyC,MAAM,CAAC;;;;;;;;AAShD,IAAa,+BAAb,cAAkD,MAAM,CAAC;AAEzD,SAAgB,mBAAmB,QAAyB,kBAAmD;CAK7G,IAAI,OAAyB;CAC7B,MAAM,UAAU,WAAW,OAAO,IAAI;CACtC,IAAI,YAAY,KAAA,GAAW;EACzB,MAAM,IAAI,QAAQ,MAAM,GAAG,CAAC,CAAC,IAAI,cAAc;EAG/C,MAAM,CAAC,MAAM,OAAO,MAAM,SAAS;EACnC,IAAI,EAAE,WAAW,KAAK,SAAS,KAAA,KAAa,UAAU,KAAA,KAAa,SAAS,KAAA,KAAa,UAAU,KAAA,GAIjG,MAAM,IAAI,oBAAoB,kEAAkE;EAElG,IAAI,QAAQ,OAGV,MAAM,IAAI,oBAAoB,eAAe,MAAM,oCAAoC,MAAM,EAAE;EAEjG,OAAO;GAAE,IAAI,CAAC,OAAO,IAAI;GAAG,IAAI,CAAC,OAAO,IAAI;EAAE;CAChD;CAEA,IAAI,SAAwB;CAE5B,MAAM,YAAY,WAAW,OAAO,MAAM;CAC1C,IAAI,cAAc,KAAA,GAAW;EAC3B,MAAM,SAAS,eAAe,SAAS;EACvC,IAAI,WAAW,KAAA,GAIb,MAAM,IAAI,oBAAoB,2CAA2C,UAAU,EAAE;EAEvF,IAAI,SAAS,GAGX,MAAM,IAAI,oBAAoB,oCAAoC,QAAQ;EAE5E,SAAS;CACX,OAAO,IAAI,kBACT,SAAS;CAEX,OAAO;EAAE;EAAM;CAAO;AACxB;AAEA,SAAgB,iBAAiB,CAAC,OAAO,QAAqB,OAAkD;CAC9G,MAAM,KAAK;CAGX,MAAM,OAAO,iBAAiB,KAAK;CACnC,MAAM,OAAO,iBAAiB,KAAK;CAEnC,QAAQ,SAAS;EACf,IAAI,CAAC,MACH,OAAO,OAAO;EAEhB,MAAM,CAAC,OAAO,SAAS;EACvB,MAAM,OAAO,iBAAiB,KAAK;EACnC,MAAM,OAAO,iBAAiB,KAAK;EAGnC,MAAM,OAAO,OAAO;EACpB,MAAM,OAAO,OAAO;EAGpB,MAAM,IAAI,KAAK,IAAI,KAAK,IAAI,OAAO,CAAC,GAAG,CAAC,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,KAAK,IAAI,OAAO,CAAC,GAAG,CAAC;EAE5G,MAAM,KADI,IAAI,KAAK,MAAM,KAAK,KAAK,CAAC,GAAG,KAAK,KAAK,IAAI,CAAC,CAAC,IACxC,KAAK;EACpB,IAAI,OAAO,SACT,MAAM,GAAG,MAAM,GAAG,MAAM,MAAM,IAAI;EAEpC,OAAO;CACT;AACF;AAEA,IAAM,oBAAoB,UAAkB;CAC1C,OAAQ,KAAK,KAAK,MAAO;AAC3B;AAEA,SAAS,UAAU,OAA0C;CAC3D,OAAO,MAAM,GAAG,EAAE,KAAK;AACzB;AAMA,SAAgB,eAAe,UAAkB,aAA8B;CAC7E,KAAK,aAAa,UAAU,aAAa,mBAAmB,aAC1D,OAAO;CAET,OAAO,SAAS,WAAW,UAAU,IAAI,WAAW,WAAW;AACjE;AAEA,SAAgB,cACd,QACA,cACA,OACmC;CACnC,IAAI,OAAO,MAAM;EACf,MAAM,WAAW,eAAe,OAAO,IAAI;EAC3C,OAAO,OAAO,cACT,UAAyB,MAAM,KAAK,QAAQ,KAC5C,UAAyB,SAAS,UAAU,KAAK,CAAC;CACzD,OAAO,IAAI,OAAO,WAAW,MAAM;EACjC,IAAI,CAAC,cACH,MAAM,IAAI,6BAA6B,6CAA6C;EAEtF,MAAM,SAAS,OAAO;EACtB,MAAM,mBAAmB,iBAAiB,cAAc,KAAK;EAC7D,QAAQ,UAAyB,iBAAiB,UAAU,KAAK,CAAC,IAAI;CACxE;CACA,aAAa;AACf;;;;;;;;;AAUA,IAAa,sBAAsB,QAA0C;CAC3E,IAAI,CAAC,MAAM,QAAQ,GAAG,KAAK,IAAI,SAAS,GACtC;CAEF,MAAM,QAAiB,IAAI;CAI3B,IAAI,MAAM,QAAQ,KAAK,KAAK,MAAM,WAAW,KAAK,OAAO,SAAS,MAAM,EAAE,KAAK,OAAO,SAAS,MAAM,EAAE,GAAG;EACxG,MAAM,CAAC,WAAW,YAAY;EAC9B,OAAO,CAAC,UAAU,SAAS;CAC7B;CACA,IAAI,SAAS,OAAO,UAAU,YAAY,cAAc,SAAS,eAAe,OAAO;EACrF,MAAM,EAAE,UAAU,cAAc;EAChC,IAAI,OAAO,SAAS,QAAQ,KAAK,OAAO,SAAS,SAAS,GACxD,OAAO,CAAC,UAAoB,SAAmB;CAEnD;AAEF;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,YAAY,SAAiB,QAAuD;CAClG,OAAO,aAAa,OAAO,GAAG,QAAQ,MAAM,CAAC;AAC/C;;;;;;;;;;;;AAaA,SAAgB,WACd,SACA,aACA,QACQ;CACR,IAAI,YAAY,aACd,OAAO;CAET,MAAM,WAAW,YAAY,SAAS,MAAM,KAAK,aAAa,OAAO,GAAG,QAAQ,MAAM,CAAC,KAAK,gBAAgB,OAAO;CACnH,OAAO,aAAa,KAAA,IAAY,UAAU,OAAO;AACnD;;AAGA,SAAgB,aAAa,KAAkC;CAC7D,MAAM,QAAiB,OAAO,OAAO,QAAQ,YAAY,WAAW,MAAM,IAAI,QAAQ;CACtF,IAAI,OAAO,UAAU,UACnB;CAEF,MAAM,UAAU,MAAM,KAAK;CAC3B,OAAO,YAAY,KAAK,KAAA,IAAY;AACtC;;AAGA,SAAS,gBAAgB,SAAqC;CAE5D,OADc,0BAA0B,KAAK,OACtC,CAAA,GAAQ;AACjB;;;;;;;;;AAUA,SAAgB,YAAY,OAAuB;CAIjD,MAAM,OAAO,MACV,QAAQ,gCAAgC,GAAG,CAAC,CAC5C,QAAQ,QAAQ,GAAG,CAAC,CACpB,QAAQ,OAAO,GAAG,CAAC,CACnB,QAAQ,kBAAkB,EAAE;CAC/B,OAAO,GAAG,SAAS,KAAK,UAAU,KAAK;AACzC;;;;;;;;;;AAWA,SAAgB,cAAc,UAA0B;CAOtD,MAAM,UADQ,CAAC,GAAG,QAAQ,CAAC,CAAC,KAAK,cAAe,YAAY,MAAW,YAAY,GAAI,CAAC,CAAC,KAAK,EAC9E,CAAA,CAAM,QAAQ,UAAU,GAAG,CAAC,CAAC,QAAQ,OAAO,EAAE;CAC9D,OAAO,YAAY,UAAU,YAAY,KAAK,cAAc;AAC9D;;;;;;;;;AAUA,SAAgB,QAAQ,UAA0B;CAChD,OAAO,mBAAmB,QAAQ,CAAC,CAAC,QAClC,aACC,cAAc,IAAI,UAAU,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,YAAY,GACtE;AACF"}
1
+ {"version":3,"file":"utils-C0VkiidM.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\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 */\nexport const 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"],"mappings":"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"}
package/dist/utils.d.ts CHANGED
@@ -132,3 +132,17 @@ export declare function asciiFilename(filename: string): string;
132
132
  * not a valid extended value.
133
133
  */
134
134
  export declare function rfc8187(filename: string): string;
135
+ /**
136
+ * The narrower of the two longitude intervals covering these points.
137
+ *
138
+ * A plain min/max describes a track spanning the antimeridian as almost the
139
+ * whole globe: two fixes at 179 and -179 are two degrees apart, but min/max
140
+ * reports 358. RFC 7946 says a box crossing the antimeridian is written with
141
+ * west greater than east, so the wrap-around interval is expressible — pick it
142
+ * whenever it is the shorter of the two.
143
+ *
144
+ * The widest gap between adjacent longitudes is the part of the globe the track
145
+ * does not cover, so the complement of that gap is the tightest interval that
146
+ * does.
147
+ */
148
+ export declare const longitudeSpan: (longitudes: number[]) => [number, number];
@@ -58,6 +58,22 @@ open, so History and v1 queries can still read committed tracks, including when
58
58
  storage is full. Startup, worker exit or an uncertain rollback fail the whole
59
59
  store. These errors remain visible instead of being replaced by healthy status.
60
60
 
61
+ Imported tracks are kept in tables of their own and written differently from
62
+ positions. Storing or deleting one waits for its commit, so the id it is given
63
+ reads back at once. It runs in a transaction of its own, so an import that cannot
64
+ be stored fails alone and recording carries on. It is admitted outside the limits
65
+ above, so a large import neither pauses recording nor is dropped. Once recording
66
+ writes have failed, imports are refused too. Retention never removes an imported
67
+ track: it stays until it is deleted.
68
+
69
+ Deleting a track, or part of one, moves its points into recycle bin tables in the
70
+ same database, and restoring moves them back; both are written as imports are.
71
+ The retention settings never reach into the bin. Its own retention, 180 days by
72
+ default, purges an entry during the regular prune. A recorded span keeps its
73
+ entry after its points are purged, because it is also what hides that span in a
74
+ history provider's answer: the History API has no delete, so a provider still
75
+ holds the positions, and the plugin filters them out of every v1 and v2 query.
76
+
61
77
  Stopping unsubscribes input immediately, then returns a Promise that resolves
62
78
  only after accepted work drains, SQLite closes and the worker exits. Plugin
63
79
  lifecycle callers must await `stop()` before restarting or removing its data
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalk/tracks-plugin",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
4
4
  "description": "Plugin to keep track of tracks of vessels",
5
5
  "type": "module",
6
6
  "exports": {