@signalk/tracks-plugin 3.1.0 → 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/README.md +63 -0
- package/dist/asyncTrackStore.d.ts +24 -1
- package/dist/bin.js +285 -0
- package/dist/e2e.test-utils.d.ts +16 -2
- package/dist/gpx.d.ts +11 -2
- package/dist/gpxExport.test.d.ts +1 -0
- package/dist/gpxImport.test.d.ts +1 -0
- package/dist/harness.test-utils.d.ts +11 -0
- package/dist/import.js +98 -0
- package/dist/importedTracks.d.ts +54 -0
- package/dist/importedTracks.test.d.ts +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.html +52 -0
- package/dist/index.js +802 -97
- package/dist/index.js.map +1 -1
- package/dist/reconcile.d.ts +11 -0
- package/dist/recycleBin.d.ts +20 -0
- package/dist/recycleBin.test.d.ts +1 -0
- package/dist/recycleBinApi.test.d.ts +1 -0
- package/dist/sqliteStore.d.ts +58 -1
- package/dist/store.d.ts +38 -1
- package/dist/style.css +78 -0
- package/dist/trackApi.d.ts +61 -5
- package/dist/trackProvider.d.ts +4 -0
- package/dist/trackStoreProtocol.d.ts +1 -1
- package/dist/trackStoreWorker.js +371 -1
- package/dist/trackStoreWorker.js.map +1 -1
- package/dist/tracks.js +132 -13
- package/dist/types.d.ts +74 -0
- package/dist/upload.d.ts +19 -0
- package/dist/{utils-CGIGp4Xf.js → utils-C0VkiidM.js} +32 -2
- package/dist/{utils-CGIGp4Xf.js.map → utils-C0VkiidM.js.map} +1 -1
- package/dist/utils.d.ts +14 -0
- package/docs/history-and-storage.md +33 -0
- package/package.json +1 -1
- package/public/bin.js +285 -0
- package/public/import.js +98 -0
- package/public/index.html +52 -0
- package/public/style.css +78 -0
- package/public/tracks.js +132 -13
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(
|
|
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
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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 =
|
|
178
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
*
|
package/dist/upload.d.ts
ADDED
|
@@ -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 {
|
|
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-
|
|
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×panOffset=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×panOffset=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
|
|
@@ -127,6 +143,23 @@ retention setting, or ask how long it has been running — it asks for the windo
|
|
|
127
143
|
and uses what comes back, so coverage that changes underneath needs no
|
|
128
144
|
attention.
|
|
129
145
|
|
|
146
|
+
## How much is asked of the provider
|
|
147
|
+
|
|
148
|
+
A provider has no spatial filter, so a query with a box still reads every
|
|
149
|
+
position in its time window, and providers produce one row per bucket of that
|
|
150
|
+
window whether or not anything was recorded in it. So each read is held to a
|
|
151
|
+
fixed number of buckets: a longer window is read at a wider resolution, so the
|
|
152
|
+
read stays the same size however far back it reaches.
|
|
153
|
+
|
|
154
|
+
A history read widened like that is coarser than the plugin's store, so for it
|
|
155
|
+
the rule below flips: the store keeps every bucket it has a position in, and
|
|
156
|
+
the provider only fills the buckets the store has nothing for.
|
|
157
|
+
|
|
158
|
+
A window with only an end, such as "everything older than a day", would
|
|
159
|
+
otherwise start at the Unix epoch. Instead the plugin first finds where the
|
|
160
|
+
provider's positions begin and reads from there, within the same budget; when
|
|
161
|
+
the provider has none in the window, it contributes nothing.
|
|
162
|
+
|
|
130
163
|
## Why the two never double up
|
|
131
164
|
|
|
132
165
|
A provider aggregates into buckets and stamps each one on its boundary:
|