@signalk/tracks-plugin 3.0.1 → 3.0.2
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 +7 -4
- package/dist/asyncTrackStore.d.ts +62 -0
- package/dist/asyncTrackStore.test.d.ts +1 -0
- package/dist/harness.test-utils.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +449 -12160
- package/dist/index.js.map +1 -1
- package/dist/sqliteStore.d.ts +11 -1
- package/dist/store.d.ts +1 -1
- package/dist/trackStoreProtocol.d.ts +38 -0
- package/dist/trackStoreWorker.d.ts +1 -0
- package/dist/trackStoreWorker.js +11717 -0
- package/dist/trackStoreWorker.js.map +1 -0
- package/dist/utils-OGjP1B0D.js +482 -0
- package/dist/utils-OGjP1B0D.js.map +1 -0
- package/dist/workerTestSetup.d.ts +2 -0
- package/docs/history-and-storage.md +56 -3
- package/package.json +1 -1
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { Value } from "typebox/value";
|
|
3
|
+
//#region src/timeWindow.ts
|
|
4
|
+
/**
|
|
5
|
+
* Time-window and resolution query parameters.
|
|
6
|
+
*
|
|
7
|
+
* Two vocabularies are accepted:
|
|
8
|
+
*
|
|
9
|
+
* - `from` / `to` / `duration` mirror the Signal K History API and are the
|
|
10
|
+
* shape proposed for the track API in SignalK/signalk-server#2504.
|
|
11
|
+
* - `timespan` / `timespanOffset` are what Freeboard-SK sends today. They are
|
|
12
|
+
* compatibility only, not part of any spec, and are expected to be dropped
|
|
13
|
+
* once Freeboard moves to the parameters above.
|
|
14
|
+
*
|
|
15
|
+
* `resolution` is shared by both and expresses the minimum spacing between
|
|
16
|
+
* returned points, thinning the track for wide time ranges.
|
|
17
|
+
*/
|
|
18
|
+
var QuerySchema = Type.Object({
|
|
19
|
+
from: Type.Optional(Type.String()),
|
|
20
|
+
to: Type.Optional(Type.String()),
|
|
21
|
+
duration: Type.Optional(Type.String()),
|
|
22
|
+
timespan: Type.Optional(Type.String()),
|
|
23
|
+
timespanOffset: Type.Optional(Type.String()),
|
|
24
|
+
resolution: Type.Optional(Type.String()),
|
|
25
|
+
times: Type.Optional(Type.String())
|
|
26
|
+
});
|
|
27
|
+
var QUERY_KEYS = Object.keys(QuerySchema.properties);
|
|
28
|
+
var TimeWindowError = class extends Error {};
|
|
29
|
+
/** `30`, `30s`, `5m`, `2h`, `7d` — seconds unless suffixed. Returns milliseconds. */
|
|
30
|
+
var DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*([smhd])?$/;
|
|
31
|
+
var UNIT_MS = {
|
|
32
|
+
s: 1e3,
|
|
33
|
+
m: 6e4,
|
|
34
|
+
h: 36e5,
|
|
35
|
+
d: 864e5
|
|
36
|
+
};
|
|
37
|
+
function parseDuration(value) {
|
|
38
|
+
const match = DURATION_PATTERN.exec(value.trim());
|
|
39
|
+
const amount = match?.[1];
|
|
40
|
+
if (!amount) throw new TimeWindowError(`Invalid duration '${value}', expected a number optionally suffixed with s, m, h or d`);
|
|
41
|
+
const unit = match[2] ?? "s";
|
|
42
|
+
const scale = UNIT_MS[unit];
|
|
43
|
+
if (scale === void 0) throw new TimeWindowError(`Invalid duration unit '${unit}'`);
|
|
44
|
+
return Number(amount) * scale;
|
|
45
|
+
}
|
|
46
|
+
function parseInstant(value, name) {
|
|
47
|
+
const ms = Date.parse(value);
|
|
48
|
+
if (Number.isNaN(ms)) throw new TimeWindowError(`Invalid ${name} '${value}', expected an ISO-8601 timestamp`);
|
|
49
|
+
return ms;
|
|
50
|
+
}
|
|
51
|
+
var TRUE_FLAGS = [
|
|
52
|
+
"true",
|
|
53
|
+
"1",
|
|
54
|
+
"yes"
|
|
55
|
+
];
|
|
56
|
+
var FALSE_FLAGS = [
|
|
57
|
+
"false",
|
|
58
|
+
"0",
|
|
59
|
+
"no"
|
|
60
|
+
];
|
|
61
|
+
/**
|
|
62
|
+
* A boolean query parameter. Present but valueless (`?times`) reads as true,
|
|
63
|
+
* which is how a query string conventionally spells a flag.
|
|
64
|
+
*/
|
|
65
|
+
function parseFlag(value, name) {
|
|
66
|
+
if (value === void 0) return true;
|
|
67
|
+
const flag = value.trim().toLowerCase();
|
|
68
|
+
if (TRUE_FLAGS.includes(flag)) return true;
|
|
69
|
+
if (FALSE_FLAGS.includes(flag)) return false;
|
|
70
|
+
throw new TimeWindowError(`Invalid ${name} '${value}', expected true or false`);
|
|
71
|
+
}
|
|
72
|
+
/** Express repeats a query key into an array; take the first usable string. */
|
|
73
|
+
function firstString(value) {
|
|
74
|
+
const scalar = Array.isArray(value) ? value[0] : value;
|
|
75
|
+
return typeof scalar === "string" && scalar.trim() !== "" ? scalar : void 0;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the time-window parameters into an absolute `[from, to)` window.
|
|
79
|
+
*
|
|
80
|
+
* Precedence: explicit `from`/`to` win; then `duration`; then the Freeboard
|
|
81
|
+
* `timespan`/`timespanOffset` pair. Absent all of them, no window is applied
|
|
82
|
+
* and the whole retained track is returned, as before.
|
|
83
|
+
*/
|
|
84
|
+
function parseTrackQuery(query, now = Date.now(), alsoAllowed = []) {
|
|
85
|
+
const unknown = Object.keys(query).filter((key) => !QUERY_KEYS.includes(key) && !alsoAllowed.includes(key));
|
|
86
|
+
if (unknown.length > 0) {
|
|
87
|
+
const allowed = [...QUERY_KEYS, ...alsoAllowed].join(", ");
|
|
88
|
+
throw new TimeWindowError(`Unknown query parameter${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")}; accepted: ${allowed}`);
|
|
89
|
+
}
|
|
90
|
+
const raw = Object.fromEntries(QUERY_KEYS.map((key) => [key, firstString(query[key])]));
|
|
91
|
+
if (!Value.Check(QuerySchema, raw)) throw new TimeWindowError("Invalid track query parameters");
|
|
92
|
+
const result = {};
|
|
93
|
+
if (Object.prototype.hasOwnProperty.call(query, "times")) result.times = parseFlag(raw.times, "times");
|
|
94
|
+
if (raw.resolution !== void 0) {
|
|
95
|
+
const resolution = parseDuration(raw.resolution);
|
|
96
|
+
if (resolution > 0) result.resolution = resolution;
|
|
97
|
+
}
|
|
98
|
+
if (raw.from !== void 0 || raw.to !== void 0) {
|
|
99
|
+
const from = raw.from !== void 0 ? parseInstant(raw.from, "from") : Number.NEGATIVE_INFINITY;
|
|
100
|
+
const to = raw.to !== void 0 ? parseInstant(raw.to, "to") : now;
|
|
101
|
+
if (from >= to) throw new TimeWindowError(`'from' must be before 'to'`);
|
|
102
|
+
return {
|
|
103
|
+
...result,
|
|
104
|
+
window: {
|
|
105
|
+
from,
|
|
106
|
+
to,
|
|
107
|
+
inclusiveEnd: raw.to === void 0
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (raw.duration !== void 0) {
|
|
112
|
+
const duration = parseDuration(raw.duration);
|
|
113
|
+
return {
|
|
114
|
+
...result,
|
|
115
|
+
window: {
|
|
116
|
+
from: now - duration,
|
|
117
|
+
to: now,
|
|
118
|
+
inclusiveEnd: true
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (raw.timespan !== void 0) {
|
|
123
|
+
const span = parseDuration(raw.timespan);
|
|
124
|
+
const offset = raw.timespanOffset !== void 0 ? parseDuration(`${raw.timespanOffset}h`) : 0;
|
|
125
|
+
const to = now - offset;
|
|
126
|
+
return {
|
|
127
|
+
...result,
|
|
128
|
+
window: {
|
|
129
|
+
from: to - span,
|
|
130
|
+
to,
|
|
131
|
+
inclusiveEnd: offset === 0
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Drop points closer together than `resolution` milliseconds, always keeping
|
|
139
|
+
* the first and last so the track keeps its extent.
|
|
140
|
+
*/
|
|
141
|
+
function thin(points, resolution) {
|
|
142
|
+
if (!resolution || points.length <= 2) return points;
|
|
143
|
+
const result = [];
|
|
144
|
+
let lastKept = Number.NEGATIVE_INFINITY;
|
|
145
|
+
for (const point of points) if (point.timestamp - lastKept >= resolution) {
|
|
146
|
+
result.push(point);
|
|
147
|
+
lastKept = point.timestamp;
|
|
148
|
+
}
|
|
149
|
+
const last = points[points.length - 1];
|
|
150
|
+
if (last && result[result.length - 1] !== last) result.push(last);
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Thin to at most `maxPoints`, widening the spacing until the budget fits.
|
|
155
|
+
*
|
|
156
|
+
* A budget, not a fidelity contract: the caller has said how many points it can
|
|
157
|
+
* afford to transfer and draw, whatever shape the track is. Returns the
|
|
158
|
+
* resolution actually applied so the response can report it — a client that is
|
|
159
|
+
* given 29 points instead of the 546 it would otherwise have received should be
|
|
160
|
+
* able to see the spacing that produced them.
|
|
161
|
+
*
|
|
162
|
+
* Widening by doubling from the span-derived estimate rather than binary
|
|
163
|
+
* searching: `thin` keeps the first point and the last unconditionally, so the
|
|
164
|
+
* count is not a clean function of the spacing and an exact solve would be
|
|
165
|
+
* false precision. Two or three passes settle it in practice.
|
|
166
|
+
*/
|
|
167
|
+
function thinToBudget(points, maxPoints, resolution) {
|
|
168
|
+
const start = thin(points, resolution);
|
|
169
|
+
if (start.length <= maxPoints) return {
|
|
170
|
+
points: start,
|
|
171
|
+
resolution
|
|
172
|
+
};
|
|
173
|
+
if (maxPoints < 2) return {
|
|
174
|
+
points: start.slice(0, Math.max(0, maxPoints)),
|
|
175
|
+
resolution
|
|
176
|
+
};
|
|
177
|
+
const span = start[start.length - 1].timestamp - start[0].timestamp;
|
|
178
|
+
let spacing = Math.max(1, Math.ceil(span / (maxPoints - 1)), resolution ?? 0);
|
|
179
|
+
let result = thin(points, spacing);
|
|
180
|
+
while (result.length > maxPoints && spacing <= span) {
|
|
181
|
+
spacing *= 2;
|
|
182
|
+
result = thin(points, spacing);
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
points: result,
|
|
186
|
+
resolution: spacing
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Split points into segments wherever recording stopped for longer than `gap`.
|
|
191
|
+
*
|
|
192
|
+
* A track is not one continuous line: a vessel that stops for the night, or
|
|
193
|
+
* sails out of AIS range and back, leaves a hole. Joining across it draws a
|
|
194
|
+
* straight line through places the vessel never went — across a headland, or
|
|
195
|
+
* over an anchorage it actually sat still in.
|
|
196
|
+
*
|
|
197
|
+
* Operates on timestamped points rather than inside a store, so both the
|
|
198
|
+
* in-memory and sqlite stores segment identically, and so it composes with
|
|
199
|
+
* `thin()` — thinning first, then segmenting, keeps a thinned-away gap from
|
|
200
|
+
* being invented as a join.
|
|
201
|
+
*
|
|
202
|
+
* `gap` of 0 or undefined returns a single segment, preserving the old shape.
|
|
203
|
+
* An empty input returns no segments rather than one empty one, so a caller can
|
|
204
|
+
* distinguish "no track" from "a track with no points".
|
|
205
|
+
*/
|
|
206
|
+
function segment(points, gap) {
|
|
207
|
+
if (points.length === 0) return [];
|
|
208
|
+
if (!gap || gap <= 0) return [points];
|
|
209
|
+
const segments = [];
|
|
210
|
+
let current = [];
|
|
211
|
+
let previous;
|
|
212
|
+
for (const point of points) {
|
|
213
|
+
if (previous !== void 0 && point.timestamp - previous > gap) {
|
|
214
|
+
segments.push(current);
|
|
215
|
+
current = [];
|
|
216
|
+
}
|
|
217
|
+
current.push(point);
|
|
218
|
+
previous = point.timestamp;
|
|
219
|
+
}
|
|
220
|
+
if (current.length > 0) segments.push(current);
|
|
221
|
+
return segments;
|
|
222
|
+
}
|
|
223
|
+
//#endregion
|
|
224
|
+
//#region src/utils.ts
|
|
225
|
+
var LAT = 0;
|
|
226
|
+
var LNG = 1;
|
|
227
|
+
/**
|
|
228
|
+
* The recording times of a segment, as ISO-8601 UTC, positionally aligned with
|
|
229
|
+
* the segment's coordinates.
|
|
230
|
+
*
|
|
231
|
+
* UTC because a track outlives the timezone it was recorded in: a passage that
|
|
232
|
+
* crosses a zone boundary, or a database restored on a boat that has since
|
|
233
|
+
* moved, must not shift. ISO-8601 rather than the epoch milliseconds held
|
|
234
|
+
* internally because that is what the rest of Signal K puts on the wire.
|
|
235
|
+
*/
|
|
236
|
+
function toIsoTimes(segment) {
|
|
237
|
+
return segment.map(({ timestamp }) => new Date(timestamp).toISOString());
|
|
238
|
+
}
|
|
239
|
+
function createInBounds(bounds) {
|
|
240
|
+
const minLat = bounds.sw[LAT];
|
|
241
|
+
const maxLat = bounds.ne[LAT];
|
|
242
|
+
if (minLat > maxLat) throw new Error(`Bounding box south must be <= north, got ${JSON.stringify(bounds)}`);
|
|
243
|
+
const minLng = bounds.sw[LNG];
|
|
244
|
+
const maxLng = (bounds.sw[LNG] > bounds.ne[LNG] ? 360 : 0) + bounds.ne[LNG];
|
|
245
|
+
return (p) => {
|
|
246
|
+
return p !== null && p[LAT] >= minLat && p[LAT] <= maxLat && (p[LNG] >= minLng && p[LNG] <= maxLng || p[LNG] + 360 >= minLng && p[LNG] + 360 <= maxLng);
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Split a bounds that crosses the antimeridian into two that do not.
|
|
251
|
+
*
|
|
252
|
+
* A bounds wraps when its west edge is numerically greater than its east edge
|
|
253
|
+
* (sw lng 179, ne lng -179): the box runs 179 -> 180 -> -179, the short way
|
|
254
|
+
* across the Pacific. `createInBounds` handles that directly with its +360
|
|
255
|
+
* arithmetic, but anything that has to describe the box *geometrically* cannot
|
|
256
|
+
* — a GeoJSON polygon with those corners is read as a band going the long way
|
|
257
|
+
* round, and an S2 covering of it comes back empty, which turns "vessels near
|
|
258
|
+
* Fiji" into a silent no results rather than an error.
|
|
259
|
+
*
|
|
260
|
+
* Returns one bounds unchanged when there is no wrap, so callers can always
|
|
261
|
+
* iterate the result.
|
|
262
|
+
*/
|
|
263
|
+
function splitAtAntimeridian(bounds) {
|
|
264
|
+
const west = bounds.sw[LNG];
|
|
265
|
+
const east = bounds.ne[LNG];
|
|
266
|
+
if (west <= east) return [bounds];
|
|
267
|
+
const south = bounds.sw[LAT];
|
|
268
|
+
const north = bounds.ne[LAT];
|
|
269
|
+
return [{
|
|
270
|
+
sw: [south, west],
|
|
271
|
+
ne: [north, 180]
|
|
272
|
+
}, {
|
|
273
|
+
sw: [south, -180],
|
|
274
|
+
ne: [north, east]
|
|
275
|
+
}];
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Express gives a repeated query parameter as an array. Take the first value so
|
|
279
|
+
* `?radius=1&radius=2` degrades to a usable number rather than a parse failure.
|
|
280
|
+
*/
|
|
281
|
+
function firstValue(value) {
|
|
282
|
+
const scalar = Array.isArray(value) ? value[0] : value;
|
|
283
|
+
return typeof scalar === "string" ? scalar : void 0;
|
|
284
|
+
}
|
|
285
|
+
/** Finite-only parse: rejects '', 'abc' and 'Infinity', all of which Number() lets through or maps to NaN. */
|
|
286
|
+
function toFiniteNumber(value) {
|
|
287
|
+
const trimmed = value.trim();
|
|
288
|
+
if (trimmed === "") return;
|
|
289
|
+
const parsed = Number(trimmed);
|
|
290
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* A query parameter was present but malformed.
|
|
294
|
+
*
|
|
295
|
+
* Distinct from absent: a filter nobody asked for is not an error, but one
|
|
296
|
+
* that cannot be honoured must not quietly widen the query. Carries its own
|
|
297
|
+
* message so the handler can hand it to the client, the way `TimeWindowError`
|
|
298
|
+
* already does for the time window.
|
|
299
|
+
*/
|
|
300
|
+
var QueryParameterError = class extends Error {};
|
|
301
|
+
/**
|
|
302
|
+
* A well-formed query the server cannot currently answer.
|
|
303
|
+
*
|
|
304
|
+
* `radius` measures from the own vessel, so it needs a self position. Not
|
|
305
|
+
* having one is an outage, not a client mistake -- the same distinction the
|
|
306
|
+
* single-vessel route already draws for a history provider it cannot read.
|
|
307
|
+
*/
|
|
308
|
+
var SelfPositionUnavailableError = class extends Error {};
|
|
309
|
+
function validateParameters(params, defaultMaxRadius) {
|
|
310
|
+
let bbox = null;
|
|
311
|
+
const rawBbox = firstValue(params.bbox);
|
|
312
|
+
if (rawBbox !== void 0) {
|
|
313
|
+
const b = rawBbox.split(",").map(toFiniteNumber);
|
|
314
|
+
const [west, south, east, north] = b;
|
|
315
|
+
if (b.length !== 4 || west === void 0 || south === void 0 || east === void 0 || north === void 0) throw new QueryParameterError("bbox must be four comma-separated numbers: west,south,east,north");
|
|
316
|
+
if (south > north) throw new QueryParameterError(`bbox south (${south}) must not be greater than north (${north})`);
|
|
317
|
+
bbox = {
|
|
318
|
+
sw: [south, west],
|
|
319
|
+
ne: [north, east]
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
let radius = null;
|
|
323
|
+
const rawRadius = firstValue(params.radius);
|
|
324
|
+
if (rawRadius !== void 0) {
|
|
325
|
+
const parsed = toFiniteNumber(rawRadius);
|
|
326
|
+
if (parsed === void 0) throw new QueryParameterError(`radius must be a number in meters, got '${rawRadius}'`);
|
|
327
|
+
if (parsed < 0) throw new QueryParameterError(`radius must not be negative, got ${parsed}`);
|
|
328
|
+
radius = parsed;
|
|
329
|
+
} else if (defaultMaxRadius) radius = defaultMaxRadius;
|
|
330
|
+
return {
|
|
331
|
+
bbox,
|
|
332
|
+
radius
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function createDistanceTo([lat1d, lon1d], debug) {
|
|
336
|
+
const Rk = 6371;
|
|
337
|
+
const lat1 = degreesToRadians(lat1d);
|
|
338
|
+
const lon1 = degreesToRadians(lon1d);
|
|
339
|
+
return (dest) => {
|
|
340
|
+
if (!dest) return Number.MAX_SAFE_INTEGER;
|
|
341
|
+
const [lat2d, lon2d] = dest;
|
|
342
|
+
const lat2 = degreesToRadians(lat2d);
|
|
343
|
+
const lon2 = degreesToRadians(lon2d);
|
|
344
|
+
const dlat = lat2 - lat1;
|
|
345
|
+
const dlon = lon2 - lon1;
|
|
346
|
+
const a = Math.pow(Math.sin(dlat / 2), 2) + Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin(dlon / 2), 2);
|
|
347
|
+
const dk = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * Rk * 1e3;
|
|
348
|
+
if (debug?.enabled) debug(`${lat2d},${lon2d} => ${dk}`);
|
|
349
|
+
return dk;
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
var degreesToRadians = (value) => {
|
|
353
|
+
return Math.PI / 180 * value;
|
|
354
|
+
};
|
|
355
|
+
function lastPoint(track) {
|
|
356
|
+
return track.at(-1) ?? null;
|
|
357
|
+
}
|
|
358
|
+
function resolveContext(vesselId, selfContext) {
|
|
359
|
+
if ((vesselId === "self" || vesselId === "vessels.self") && selfContext) return selfContext;
|
|
360
|
+
return vesselId.startsWith("vessels.") ? vesselId : `vessels.${vesselId}`;
|
|
361
|
+
}
|
|
362
|
+
function createMatcher(params, selfPosition, debug) {
|
|
363
|
+
if (params.bbox) {
|
|
364
|
+
const inBounds = createInBounds(params.bbox);
|
|
365
|
+
return params.intersects ? (track) => track.some(inBounds) : (track) => inBounds(lastPoint(track));
|
|
366
|
+
} else if (params.radius !== null) {
|
|
367
|
+
if (!selfPosition) throw new SelfPositionUnavailableError("No self position to calculate radius values");
|
|
368
|
+
const radius = params.radius;
|
|
369
|
+
const distanceFromSelf = createDistanceTo(selfPosition, debug);
|
|
370
|
+
return (track) => distanceFromSelf(lastPoint(track)) < radius;
|
|
371
|
+
}
|
|
372
|
+
return () => true;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* A history row is `[timestamp, position]`. Both encodings are legal: the
|
|
376
|
+
* History API types a value as `unknown` and its own comment names "objects
|
|
377
|
+
* (like navigation.position)" alongside primitives, so signalk-questdb returns
|
|
378
|
+
* `{latitude, longitude}` and signalk-to-influxdb2 returns `[lon, lat]`.
|
|
379
|
+
* Accept both — neither is an error, so rejecting one silently yields an empty
|
|
380
|
+
* track rather than a failure anybody can see.
|
|
381
|
+
*/
|
|
382
|
+
var historyRowPosition = (row) => {
|
|
383
|
+
if (!Array.isArray(row) || row.length < 2) return;
|
|
384
|
+
const value = row[1];
|
|
385
|
+
if (Array.isArray(value) && value.length === 2 && Number.isFinite(value[0]) && Number.isFinite(value[1])) {
|
|
386
|
+
const [longitude, latitude] = value;
|
|
387
|
+
return [latitude, longitude];
|
|
388
|
+
}
|
|
389
|
+
if (value && typeof value === "object" && "latitude" in value && "longitude" in value) {
|
|
390
|
+
const { latitude, longitude } = value;
|
|
391
|
+
if (Number.isFinite(latitude) && Number.isFinite(longitude)) return [latitude, longitude];
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
/**
|
|
395
|
+
* The name of the vessel (or aircraft, or aton) a context refers to.
|
|
396
|
+
*
|
|
397
|
+
* Deliberately bare — `Ariadne`, not `AIS Ariadne` and not `Own Ship`. This is
|
|
398
|
+
* the v2 Track API's `contextName`, whose spec says "Name of the vessel,
|
|
399
|
+
* aircraft or other context, where known. Not a name for the track itself."
|
|
400
|
+
* Decoration belongs to whoever is drawing a list; see `trackLabel`.
|
|
401
|
+
*
|
|
402
|
+
* `lookup` reads the data model (`app.getPath`). Note the shape: a vessel's
|
|
403
|
+
* `name` is a bare string in the full model, not the `{value}` wrapper that
|
|
404
|
+
* `navigation.position` and friends carry, so both are accepted rather than
|
|
405
|
+
* guessed at.
|
|
406
|
+
*
|
|
407
|
+
* Resolves `name` and nothing else, matching the server's own `findContextName`
|
|
408
|
+
* (`packages/server-admin-ui/src/utils/pathKeys.ts`), which returns undefined
|
|
409
|
+
* rather than substituting an identifier. An MMSI is how a vessel is addressed,
|
|
410
|
+
* not what it is called, so it belongs in `trackLabel` where something has to
|
|
411
|
+
* render — putting it here would make "where known" untrue.
|
|
412
|
+
*/
|
|
413
|
+
function contextName(context, lookup) {
|
|
414
|
+
return unwrapString(lookup(`${context}.name`));
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* A display label for a track, as a chart plotter would show it.
|
|
418
|
+
*
|
|
419
|
+
* TimeZero's GPX exports label tracks `Own Ship` and `AIS <shipname>` — never a
|
|
420
|
+
* serial number — and that is what makes a list of tracks readable. A UI
|
|
421
|
+
* listing `vessels.urn:mrn:imo:mmsi:211234567` is unusable.
|
|
422
|
+
*
|
|
423
|
+
* Distinct from `contextName`: this one *is* a name for the track, so it says
|
|
424
|
+
* whose track it is and where the identity came from. Falls back to the raw
|
|
425
|
+
* context, because a label has to render something.
|
|
426
|
+
*/
|
|
427
|
+
function trackLabel(context, selfContext, lookup) {
|
|
428
|
+
if (context === selfContext) return "Own Ship";
|
|
429
|
+
const identity = contextName(context, lookup) ?? unwrapString(lookup(`${context}.mmsi`)) ?? mmsiFromContext(context);
|
|
430
|
+
return identity === void 0 ? context : `AIS ${identity}`;
|
|
431
|
+
}
|
|
432
|
+
/** Accepts both a bare string and the `{value}` wrapper deltas arrive in. */
|
|
433
|
+
function unwrapString(raw) {
|
|
434
|
+
const value = raw && typeof raw === "object" && "value" in raw ? raw.value : raw;
|
|
435
|
+
if (typeof value !== "string") return;
|
|
436
|
+
const trimmed = value.trim();
|
|
437
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
438
|
+
}
|
|
439
|
+
/** `vessels.urn:mrn:imo:mmsi:211234567` -> `211234567`. */
|
|
440
|
+
function mmsiFromContext(context) {
|
|
441
|
+
return /urn:mrn:imo:mmsi:(\d+)$/.exec(context)?.[1];
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* A track label as a filename a browser can save.
|
|
445
|
+
*
|
|
446
|
+
* A vessel name arrives from an AIS transmission and is not ours to trust in a
|
|
447
|
+
* path, so the characters that could break out of one — separators, quotes,
|
|
448
|
+
* controls — are replaced. Everything else is kept, including non-ASCII: a
|
|
449
|
+
* boat called `Ärger` keeps its name.
|
|
450
|
+
*/
|
|
451
|
+
function gpxFilename(label) {
|
|
452
|
+
const safe = label.replace(/[\p{Cc}\p{Cf}"'\\/:*?<>|]+/gu, "-").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
|
453
|
+
return `${safe === "" ? "track" : safe}.gpx`;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* The ASCII-only form of a filename, for the quoted `Content-Disposition`
|
|
457
|
+
* parameter.
|
|
458
|
+
*
|
|
459
|
+
* An HTTP header carries bytes: anything outside Latin-1 makes `setHeader`
|
|
460
|
+
* throw, and anything outside ASCII is reinterpreted byte-wise by the client,
|
|
461
|
+
* so `Ärger.gpx` arrives as mojibake. The real name travels in the `filename*`
|
|
462
|
+
* parameter; this is the fallback for readers that do not implement it.
|
|
463
|
+
*/
|
|
464
|
+
function asciiFilename(filename) {
|
|
465
|
+
const trimmed = [...filename].map((character) => character < "" ? character : "-").join("").replace(/-{2,}/g, "-").replace(/^-+/, "");
|
|
466
|
+
return trimmed === ".gpx" || trimmed === "" ? "track.gpx" : trimmed;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* A filename as an RFC 8187 extended value, for `filename*`.
|
|
470
|
+
*
|
|
471
|
+
* `encodeURIComponent` is close but not exact: it leaves `!'()*` unescaped,
|
|
472
|
+
* and RFC 8187's `attr-char` set excludes them. A vessel called
|
|
473
|
+
* `Boat (Test)` would otherwise put raw parentheses into the header, which is
|
|
474
|
+
* not a valid extended value.
|
|
475
|
+
*/
|
|
476
|
+
function rfc8187(filename) {
|
|
477
|
+
return encodeURIComponent(filename).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
478
|
+
}
|
|
479
|
+
//#endregion
|
|
480
|
+
export { TimeWindowError as _, createDistanceTo as a, thin as b, gpxFilename as c, rfc8187 as d, splitAtAntimeridian as f, validateParameters as g, unwrapString as h, contextName as i, historyRowPosition as l, trackLabel as m, SelfPositionUnavailableError as n, createInBounds as o, toIsoTimes as p, asciiFilename as r, createMatcher as s, QueryParameterError as t, resolveContext as u, parseTrackQuery as v, thinToBudget as x, segment as y };
|
|
481
|
+
|
|
482
|
+
//# sourceMappingURL=utils-OGjP1B0D.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils-OGjP1B0D.js","names":[],"sources":["../src/timeWindow.ts","../src/utils.ts"],"sourcesContent":["import { Type } from 'typebox'\nimport { Value } from 'typebox/value'\nimport type { TimedPosition, TimeWindow } from './types.js'\n\n/**\n * Time-window and resolution query parameters.\n *\n * Two vocabularies are accepted:\n *\n * - `from` / `to` / `duration` mirror the Signal K History API and are the\n * shape proposed for the track API in SignalK/signalk-server#2504.\n * - `timespan` / `timespanOffset` are what Freeboard-SK sends today. They are\n * compatibility only, not part of any spec, and are expected to be dropped\n * once Freeboard moves to the parameters above.\n *\n * `resolution` is shared by both and expresses the minimum spacing between\n * returned points, thinning the track for wide time ranges.\n */\nconst QuerySchema = Type.Object({\n from: Type.Optional(Type.String()),\n to: Type.Optional(Type.String()),\n duration: Type.Optional(Type.String()),\n timespan: Type.Optional(Type.String()),\n timespanOffset: Type.Optional(Type.String()),\n resolution: Type.Optional(Type.String()),\n times: Type.Optional(Type.String()),\n})\n\nconst QUERY_KEYS: readonly string[] = Object.keys(QuerySchema.properties)\n\nexport class TimeWindowError extends Error {}\n\n/** `30`, `30s`, `5m`, `2h`, `7d` — seconds unless suffixed. Returns milliseconds. */\nconst DURATION_PATTERN = /^(\\d+(?:\\.\\d+)?)\\s*([smhd])?$/\n\nconst UNIT_MS: Record<string, number> = {\n s: 1000,\n m: 60 * 1000,\n h: 60 * 60 * 1000,\n d: 24 * 60 * 60 * 1000,\n}\n\nexport function parseDuration(value: string): number {\n const match = DURATION_PATTERN.exec(value.trim())\n const amount = match?.[1]\n if (!amount) {\n throw new TimeWindowError(`Invalid duration '${value}', expected a number optionally suffixed with s, m, h or d`)\n }\n const unit = match[2] ?? 's'\n const scale = UNIT_MS[unit]\n if (scale === undefined) {\n throw new TimeWindowError(`Invalid duration unit '${unit}'`)\n }\n return Number(amount) * scale\n}\n\nfunction parseInstant(value: string, name: string): number {\n const ms = Date.parse(value)\n if (Number.isNaN(ms)) {\n throw new TimeWindowError(`Invalid ${name} '${value}', expected an ISO-8601 timestamp`)\n }\n return ms\n}\n\nconst TRUE_FLAGS = ['true', '1', 'yes']\nconst FALSE_FLAGS = ['false', '0', 'no']\n\n/**\n * A boolean query parameter. Present but valueless (`?times`) reads as true,\n * which is how a query string conventionally spells a flag.\n */\nfunction parseFlag(value: string | undefined, name: string): boolean {\n if (value === undefined) {\n return true\n }\n const flag = value.trim().toLowerCase()\n if (TRUE_FLAGS.includes(flag)) {\n return true\n }\n if (FALSE_FLAGS.includes(flag)) {\n return false\n }\n throw new TimeWindowError(`Invalid ${name} '${value}', expected true or false`)\n}\n\n/** Express repeats a query key into an array; take the first usable string. */\nfunction firstString(value: unknown): string | undefined {\n const scalar: unknown = Array.isArray(value) ? (value as unknown[])[0] : value\n return typeof scalar === 'string' && scalar.trim() !== '' ? scalar : undefined\n}\n\nexport interface TrackQuery {\n window?: TimeWindow\n /** Minimum spacing between returned points, in milliseconds. */\n resolution?: number\n /**\n * Serve the recording time of every point alongside the geometry.\n *\n * Opt-in rather than always-on: the response grows by roughly a third, and\n * the clients reading these routes today (Freeboard-SK) draw the geometry\n * and have no use for the times. Consumers that need per-point time — an\n * IMO-style AIS display drawing dots equally spaced by time, per\n * SignalK/signalk-server#2504 — ask for it.\n */\n times?: boolean\n}\n\n/**\n * Resolve the time-window parameters into an absolute `[from, to)` window.\n *\n * Precedence: explicit `from`/`to` win; then `duration`; then the Freeboard\n * `timespan`/`timespanOffset` pair. Absent all of them, no window is applied\n * and the whole retained track is returned, as before.\n */\nexport function parseTrackQuery(\n query: Record<string, unknown>,\n now: number = Date.now(),\n alsoAllowed: readonly string[] = [],\n): TrackQuery {\n // A parameter the route cannot honour is a question it is not answering, and\n // answering 200 says it did. Unknown keys were silently dropped, so `?bbox=`\n // on a single-vessel route -- reasonable, having seen bbox on /tracks -- read\n // as a filtered track when it was the whole one, and a typo like\n // `?duratoin=6h` quietly returned everything retained.\n //\n // Spelled per route rather than globally: /tracks genuinely takes bbox and\n // radius, the single-vessel routes genuinely do not.\n const unknown = Object.keys(query).filter((key) => !QUERY_KEYS.includes(key) && !alsoAllowed.includes(key))\n if (unknown.length > 0) {\n const allowed = [...QUERY_KEYS, ...alsoAllowed].join(', ')\n throw new TimeWindowError(\n `Unknown query parameter${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')}; accepted: ${allowed}`,\n )\n }\n const raw = Object.fromEntries(QUERY_KEYS.map((key) => [key, firstString(query[key])]))\n\n if (!Value.Check(QuerySchema, raw)) {\n throw new TimeWindowError('Invalid track query parameters')\n }\n\n const result: TrackQuery = {}\n\n // `?times` with no value is how a query string spells a flag, so a bare key\n // counts as true; `times=false` turns it back off for a caller building the\n // parameter list programmatically.\n if (Object.prototype.hasOwnProperty.call(query, 'times')) {\n result.times = parseFlag(raw.times, 'times')\n }\n\n if (raw.resolution !== undefined) {\n const resolution = parseDuration(raw.resolution)\n if (resolution > 0) {\n result.resolution = resolution\n }\n }\n\n // A window ending at `now` includes its final point, so the newest fix is not\n // dropped. A window ending earlier stays half-open so consecutive bands tile\n // without repeating the point they share.\n if (raw.from !== undefined || raw.to !== undefined) {\n const from = raw.from !== undefined ? parseInstant(raw.from, 'from') : Number.NEGATIVE_INFINITY\n const to = raw.to !== undefined ? parseInstant(raw.to, 'to') : now\n if (from >= to) {\n throw new TimeWindowError(`'from' must be before 'to'`)\n }\n return { ...result, window: { from, to, inclusiveEnd: raw.to === undefined } }\n }\n\n if (raw.duration !== undefined) {\n const duration = parseDuration(raw.duration)\n return { ...result, window: { from: now - duration, to: now, inclusiveEnd: true } }\n }\n\n if (raw.timespan !== undefined) {\n // Freeboard sends timespan with an optional offset back from now, so\n // `timespan=23h×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 * Drop points closer together than `resolution` milliseconds, always keeping\n * the first and last so the track keeps its extent.\n */\nexport function thin(points: TimedPosition[], resolution: number | undefined): TimedPosition[] {\n if (!resolution || points.length <= 2) {\n return points\n }\n const result: TimedPosition[] = []\n let lastKept = Number.NEGATIVE_INFINITY\n for (const point of points) {\n if (point.timestamp - lastKept >= resolution) {\n result.push(point)\n lastKept = point.timestamp\n }\n }\n const last = points[points.length - 1]\n if (last && result[result.length - 1] !== last) {\n result.push(last)\n }\n return result\n}\n\n/**\n * Thin to at most `maxPoints`, widening the spacing until the budget fits.\n *\n * A budget, not a fidelity contract: the caller has said how many points it can\n * afford to transfer and draw, whatever shape the track is. Returns the\n * resolution actually applied so the response can report it — a client that is\n * given 29 points instead of the 546 it would otherwise have received should be\n * able to see the spacing that produced them.\n *\n * Widening by doubling from the span-derived estimate rather than binary\n * searching: `thin` keeps the first point and the last unconditionally, so the\n * count is not a clean function of the spacing and an exact solve would be\n * false precision. Two or three passes settle it in practice.\n */\nexport function thinToBudget(\n points: TimedPosition[],\n maxPoints: number,\n resolution: number | undefined,\n): { points: TimedPosition[]; resolution: number | undefined } {\n const start = thin(points, resolution)\n if (start.length <= maxPoints) {\n return { points: start, resolution }\n }\n // A budget below two cannot be met by widening — `thin` keeps the first point\n // and the last unconditionally — so it is met by truncation instead. The API\n // rejects a non-positive maxPoints with a 400, so this only arises for a\n // caller reaching the helper directly, and \"at most\" has to hold for them too.\n if (maxPoints < 2) {\n return { points: start.slice(0, Math.max(0, maxPoints)), resolution }\n }\n const span = start[start.length - 1]!.timestamp - start[0]!.timestamp\n // The spacing that would fit the budget if points were evenly spaced. They\n // are not, so this is a starting guess that the loop below corrects.\n let spacing = Math.max(1, Math.ceil(span / (maxPoints - 1)), resolution ?? 0)\n let result = thin(points, spacing)\n // Widened past the span, not up to it: `thin` keeps the last point\n // unconditionally, so a track whose final fixes share a timestamp still\n // yields three points at exactly `span` — first, middle, and the appended\n // last. Doubling terminates because `thin` reaches two points once the\n // spacing exceeds the span.\n while (result.length > maxPoints && spacing <= span) {\n spacing *= 2\n result = thin(points, spacing)\n }\n return { points: result, resolution: spacing }\n}\n\n/**\n * Split points into segments wherever recording stopped for longer than `gap`.\n *\n * A track is not one continuous line: a vessel that stops for the night, or\n * sails out of AIS range and back, leaves a hole. Joining across it draws a\n * straight line through places the vessel never went — across a headland, or\n * over an anchorage it actually sat still in.\n *\n * Operates on timestamped points rather than inside a store, so both the\n * in-memory and sqlite stores segment identically, and so it composes with\n * `thin()` — thinning first, then segmenting, keeps a thinned-away gap from\n * being invented as a join.\n *\n * `gap` of 0 or undefined returns a single segment, preserving the old shape.\n * An empty input returns no segments rather than one empty one, so a caller can\n * distinguish \"no track\" from \"a track with no points\".\n */\nexport function segment(points: TimedPosition[], gap: number | undefined): TimedPosition[][] {\n if (points.length === 0) {\n return []\n }\n if (!gap || gap <= 0) {\n return [points]\n }\n const segments: TimedPosition[][] = []\n let current: TimedPosition[] = []\n let previous: number | undefined\n for (const point of points) {\n if (previous !== undefined && point.timestamp - previous > gap) {\n segments.push(current)\n current = []\n }\n current.push(point)\n previous = point.timestamp\n }\n if (current.length > 0) {\n segments.push(current)\n }\n return segments\n}\n","import type { LatLngTuple, GeoBounds, Position, QueryParameters, TimedPosition, TrackParams, Debug } from './types.js'\n\nconst LAT = 0\nconst LNG = 1\n\n/**\n * The recording times of a segment, as ISO-8601 UTC, positionally aligned with\n * the segment's coordinates.\n *\n * UTC because a track outlives the timezone it was recorded in: a passage that\n * crosses a zone boundary, or a database restored on a boat that has since\n * moved, must not shift. ISO-8601 rather than the epoch milliseconds held\n * internally because that is what the rest of Signal K puts on the wire.\n */\nexport function toIsoTimes(segment: TimedPosition[]): string[] {\n return segment.map(({ timestamp }) => new Date(timestamp).toISOString())\n}\n\nexport function createInBounds(bounds: GeoBounds): (position: LatLngTuple | null) => boolean {\n const minLat = bounds.sw[LAT]\n const maxLat = bounds.ne[LAT]\n if (minLat > maxLat) {\n throw new Error(`Bounding box south must be <= north, got ${JSON.stringify(bounds)}`)\n }\n const minLng = bounds.sw[LNG]\n const maxLng = (bounds.sw[LNG] > bounds.ne[LNG] ? 360 : 0) + bounds.ne[LNG]\n\n return (p) => {\n return (\n p !== null &&\n p[LAT] >= minLat &&\n p[LAT] <= maxLat &&\n ((p[LNG] >= minLng && p[LNG] <= maxLng) || (p[LNG] + 360 >= minLng && p[LNG] + 360 <= maxLng))\n )\n }\n}\n\n/**\n * Split a bounds that crosses the antimeridian into two that do not.\n *\n * A bounds wraps when its west edge is numerically greater than its east edge\n * (sw lng 179, ne lng -179): the box runs 179 -> 180 -> -179, the short way\n * across the Pacific. `createInBounds` handles that directly with its +360\n * arithmetic, but anything that has to describe the box *geometrically* cannot\n * — a GeoJSON polygon with those corners is read as a band going the long way\n * round, and an S2 covering of it comes back empty, which turns \"vessels near\n * Fiji\" into a silent no results rather than an error.\n *\n * Returns one bounds unchanged when there is no wrap, so callers can always\n * iterate the result.\n */\nexport function splitAtAntimeridian(bounds: GeoBounds): GeoBounds[] {\n const west = bounds.sw[LNG]\n const east = bounds.ne[LNG]\n if (west <= east) {\n return [bounds]\n }\n const south = bounds.sw[LAT]\n const north = bounds.ne[LAT]\n return [\n { sw: [south, west], ne: [north, 180] },\n { sw: [south, -180], ne: [north, east] },\n ]\n}\n\n/**\n * Express gives a repeated query parameter as an array. Take the first value so\n * `?radius=1&radius=2` degrades to a usable number rather than a parse failure.\n */\nfunction firstValue(value: unknown): string | undefined {\n const scalar: unknown = Array.isArray(value) ? (value as unknown[])[0] : value\n return typeof scalar === 'string' ? scalar : undefined\n}\n\n/** Finite-only parse: rejects '', 'abc' and 'Infinity', all of which Number() lets through or maps to NaN. */\nfunction toFiniteNumber(value: string): number | undefined {\n const trimmed = value.trim()\n if (trimmed === '') {\n return undefined\n }\n const parsed = Number(trimmed)\n return Number.isFinite(parsed) ? parsed : undefined\n}\n\n/**\n * A query parameter was present but malformed.\n *\n * Distinct from absent: a filter nobody asked for is not an error, but one\n * that cannot be honoured must not quietly widen the query. Carries its own\n * message so the handler can hand it to the client, the way `TimeWindowError`\n * already does for the time window.\n */\nexport class QueryParameterError extends Error {}\n\n/**\n * A well-formed query the server cannot currently answer.\n *\n * `radius` measures from the own vessel, so it needs a self position. Not\n * having one is an outage, not a client mistake -- the same distinction the\n * single-vessel route already draws for a history provider it cannot read.\n */\nexport class SelfPositionUnavailableError extends Error {}\n\nexport function validateParameters(params: QueryParameters, defaultMaxRadius: number | undefined): TrackParams {\n // Bounding box as west,south,east,north — GeoJSON coordinate order, the same\n // as the coordinates these endpoints return, the Resources API, and the v2\n // Track API. Internally a bounds is [lat, lng], so the pairs are swapped on\n // the way in.\n let bbox: GeoBounds | null = null\n const rawBbox = firstValue(params.bbox)\n if (rawBbox !== undefined) {\n const b = rawBbox.split(',').map(toFiniteNumber)\n // Every one of the four must have parsed; a `0` is a valid coordinate, so\n // test for undefined rather than truthiness.\n const [west, south, east, north] = b\n if (b.length !== 4 || west === undefined || south === undefined || east === undefined || north === undefined) {\n // Thrown rather than dropped. Yielding `null` here would skip the filter\n // and answer 200 with every track, so a typo'd bbox silently returns\n // more than was asked for -- the opposite of what a filter is for.\n throw new QueryParameterError('bbox must be four comma-separated numbers: west,south,east,north')\n }\n if (south > north) {\n // Wording follows the v2 Track API's own check, so the two surfaces\n // reject the same query the same way.\n throw new QueryParameterError(`bbox south (${south}) must not be greater than north (${north})`)\n }\n bbox = { sw: [south, west], ne: [north, east] }\n }\n\n let radius: number | null = null\n // radius in meters\n const rawRadius = firstValue(params.radius)\n if (rawRadius !== undefined) {\n const parsed = toFiniteNumber(rawRadius)\n if (parsed === undefined) {\n // Same reasoning as the bbox above: falling back to `null` here drops the\n // filter and answers with every track, so a typo returns more than was\n // asked for rather than failing.\n throw new QueryParameterError(`radius must be a number in meters, got '${rawRadius}'`)\n }\n if (parsed < 0) {\n // A negative radius matched nothing, silently -- an empty result for a\n // query that cannot be satisfied by construction.\n throw new QueryParameterError(`radius must not be negative, got ${parsed}`)\n }\n radius = parsed\n } else if (defaultMaxRadius) {\n radius = defaultMaxRadius\n }\n return { bbox, radius }\n}\n\nexport function createDistanceTo([lat1d, lon1d]: LatLngTuple, debug?: Debug): (d: LatLngTuple | null) => number {\n const Rk = 6371 // mean radius of the earth (km) at 39 degrees from the equator\n\n // convert coordinates to radians\n const lat1 = degreesToRadians(lat1d)\n const lon1 = degreesToRadians(lon1d)\n\n return (dest) => {\n if (!dest) {\n return Number.MAX_SAFE_INTEGER\n }\n const [lat2d, lon2d] = dest\n const lat2 = degreesToRadians(lat2d)\n const lon2 = degreesToRadians(lon2d)\n\n // find the differences between the coordinates\n const dlat = lat2 - lat1\n const dlon = lon2 - lon1\n\n //** calculate **\n const a = Math.pow(Math.sin(dlat / 2), 2) + Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin(dlon / 2), 2)\n const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))\n const dk = c * Rk * 1000 // great circle distance in m\n if (debug?.enabled) {\n debug(`${lat2d},${lon2d} => ${dk}`)\n }\n return dk\n }\n}\n\nconst degreesToRadians = (value: number) => {\n return (Math.PI / 180) * value\n}\n\nfunction lastPoint(track: LatLngTuple[]): LatLngTuple | null {\n return track.at(-1) ?? null\n}\n\n// Positions are accumulated under the context carried by the delta, which for\n// the own vessel is always the fully qualified context (vessels.urn:mrn:...),\n// never the literal `vessels.self`. Resolve the `self` alias the rest of the\n// Signal K HTTP API accepts so a lookup for it can hit.\nexport function resolveContext(vesselId: string, selfContext?: string): string {\n if ((vesselId === 'self' || vesselId === 'vessels.self') && selfContext) {\n return selfContext\n }\n return vesselId.startsWith('vessels.') ? vesselId : `vessels.${vesselId}`\n}\n\nexport function createMatcher(\n params: TrackParams,\n selfPosition?: LatLngTuple,\n debug?: Debug,\n): (track: LatLngTuple[]) => boolean {\n if (params.bbox) {\n const inBounds = createInBounds(params.bbox)\n return params.intersects\n ? (track: LatLngTuple[]) => track.some(inBounds)\n : (track: LatLngTuple[]) => inBounds(lastPoint(track))\n } else if (params.radius !== null) {\n if (!selfPosition) {\n throw new SelfPositionUnavailableError('No self position to calculate radius values')\n }\n const radius = params.radius\n const distanceFromSelf = createDistanceTo(selfPosition, debug)\n return (track: LatLngTuple[]) => distanceFromSelf(lastPoint(track)) < radius\n }\n return () => true\n}\n\n/**\n * A history row is `[timestamp, position]`. Both encodings are legal: the\n * History API types a value as `unknown` and its own comment names \"objects\n * (like navigation.position)\" alongside primitives, so signalk-questdb returns\n * `{latitude, longitude}` and signalk-to-influxdb2 returns `[lon, lat]`.\n * Accept both — neither is an error, so rejecting one silently yields an empty\n * track rather than a failure anybody can see.\n */\nexport const historyRowPosition = (row: unknown): LatLngTuple | undefined => {\n if (!Array.isArray(row) || row.length < 2) {\n return undefined\n }\n const value: unknown = row[1]\n // isFinite, not typeof number: NaN and Infinity are numbers, and one of them\n // in a coordinate reaches the store and stretches every bounding box that\n // track appears in — silently, since nothing downstream re-checks.\n if (Array.isArray(value) && value.length === 2 && Number.isFinite(value[0]) && Number.isFinite(value[1])) {\n const [longitude, latitude] = value as [number, number]\n return [latitude, longitude]\n }\n if (value && typeof value === 'object' && 'latitude' in value && 'longitude' in value) {\n const { latitude, longitude } = value as Partial<Position>\n if (Number.isFinite(latitude) && Number.isFinite(longitude)) {\n return [latitude as number, longitude as number]\n }\n }\n return undefined\n}\n\n/**\n * The name of the vessel (or aircraft, or aton) a context refers to.\n *\n * Deliberately bare — `Ariadne`, not `AIS Ariadne` and not `Own Ship`. This is\n * the v2 Track API's `contextName`, whose spec says \"Name of the vessel,\n * aircraft or other context, where known. Not a name for the track itself.\"\n * Decoration belongs to whoever is drawing a list; see `trackLabel`.\n *\n * `lookup` reads the data model (`app.getPath`). Note the shape: a vessel's\n * `name` is a bare string in the full model, not the `{value}` wrapper that\n * `navigation.position` and friends carry, so both are accepted rather than\n * guessed at.\n *\n * Resolves `name` and nothing else, matching the server's own `findContextName`\n * (`packages/server-admin-ui/src/utils/pathKeys.ts`), which returns undefined\n * rather than substituting an identifier. An MMSI is how a vessel is addressed,\n * not what it is called, so it belongs in `trackLabel` where something has to\n * render — putting it here would make \"where known\" untrue.\n */\nexport function contextName(context: string, lookup: (path: string) => unknown): string | undefined {\n return unwrapString(lookup(`${context}.name`))\n}\n\n/**\n * A display label for a track, as a chart plotter would show it.\n *\n * TimeZero's GPX exports label tracks `Own Ship` and `AIS <shipname>` — never a\n * serial number — and that is what makes a list of tracks readable. A UI\n * listing `vessels.urn:mrn:imo:mmsi:211234567` is unusable.\n *\n * Distinct from `contextName`: this one *is* a name for the track, so it says\n * whose track it is and where the identity came from. Falls back to the raw\n * context, because a label has to render something.\n */\nexport function trackLabel(\n context: string,\n selfContext: string | undefined,\n lookup: (path: string) => unknown,\n): string {\n if (context === selfContext) {\n return 'Own Ship'\n }\n const identity = contextName(context, lookup) ?? unwrapString(lookup(`${context}.mmsi`)) ?? mmsiFromContext(context)\n return identity === undefined ? context : `AIS ${identity}`\n}\n\n/** Accepts both a bare string and the `{value}` wrapper deltas arrive in. */\nexport function unwrapString(raw: unknown): string | undefined {\n const value: unknown = raw && typeof raw === 'object' && 'value' in raw ? raw.value : raw\n if (typeof value !== 'string') {\n return undefined\n }\n const trimmed = value.trim()\n return trimmed === '' ? undefined : trimmed\n}\n\n/** `vessels.urn:mrn:imo:mmsi:211234567` -> `211234567`. */\nfunction mmsiFromContext(context: string): string | undefined {\n const match = /urn:mrn:imo:mmsi:(\\d+)$/.exec(context)\n return match?.[1]\n}\n\n/**\n * A track label as a filename a browser can save.\n *\n * A vessel name arrives from an AIS transmission and is not ours to trust in a\n * path, so the characters that could break out of one — separators, quotes,\n * controls — are replaced. Everything else is kept, including non-ASCII: a\n * boat called `Ärger` keeps its name.\n */\nexport function gpxFilename(label: string): string {\n // Only the characters a path or a header could be broken by are replaced,\n // rather than everything outside ASCII: stripping those turns `Ärger` into\n // `rger` and `日本` into nothing at all, quietly renaming somebody's vessel.\n const safe = label\n .replace(/[\\p{Cc}\\p{Cf}\"'\\\\/:*?<>|]+/gu, '-')\n .replace(/\\s+/g, '-')\n .replace(/-+/g, '-')\n .replace(/^[-.]+|[-.]+$/g, '')\n return `${safe === '' ? 'track' : safe}.gpx`\n}\n\n/**\n * The ASCII-only form of a filename, for the quoted `Content-Disposition`\n * parameter.\n *\n * An HTTP header carries bytes: anything outside Latin-1 makes `setHeader`\n * throw, and anything outside ASCII is reinterpreted byte-wise by the client,\n * so `Ärger.gpx` arrives as mojibake. The real name travels in the `filename*`\n * parameter; this is the fallback for readers that do not implement it.\n */\nexport function asciiFilename(filename: string): string {\n // A rough fallback on purpose. Every current browser reads `filename*`, which\n // carries the real name; this form exists for readers that do not, and no\n // transliteration of `日本` into ASCII would be more use to them than a dash.\n // Two vessels whose names differ only outside ASCII therefore collide here,\n // which is a worse outcome than a leading dash on every such download.\n const ascii = [...filename].map((character) => (character < '\\u0080' ? character : '-')).join('')\n const trimmed = ascii.replace(/-{2,}/g, '-').replace(/^-+/, '')\n return trimmed === '.gpx' || trimmed === '' ? 'track.gpx' : trimmed\n}\n\n/**\n * A filename as an RFC 8187 extended value, for `filename*`.\n *\n * `encodeURIComponent` is close but not exact: it leaves `!'()*` unescaped,\n * and RFC 8187's `attr-char` set excludes them. A vessel called\n * `Boat (Test)` would otherwise put raw parentheses into the header, which is\n * not a valid extended value.\n */\nexport function rfc8187(filename: string): string {\n return encodeURIComponent(filename).replace(\n /[!'()*]/g,\n (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,\n )\n}\n"],"mappings":"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"}
|
|
@@ -12,15 +12,68 @@ Neither is simply better than the other, which is why both are used.
|
|
|
12
12
|
| Size | small | far larger, which is why retention exists |
|
|
13
13
|
|
|
14
14
|
The plugin writes its positions to a SQLite file in its data directory, so they
|
|
15
|
-
survive a restart.
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
survive a restart. A power cut can still lose the most recent ones, because
|
|
16
|
+
they are not forced onto the storage one at a time (on an SD card each such
|
|
17
|
+
write can take seconds), but it cannot damage the file. How long they are kept
|
|
18
|
+
depends on two settings: the own vessel's track is kept indefinitely by
|
|
19
|
+
default, and another vessel is dropped 30 days after its last fix.
|
|
18
20
|
|
|
19
21
|
So the provider is the finer record of the recent past, and the plugin's store
|
|
20
22
|
is what remains of everything older. The interval and the retention are both
|
|
21
23
|
settings on the plugin's configuration page, which is where their current
|
|
22
24
|
defaults are shown.
|
|
23
25
|
|
|
26
|
+
## Storage responsiveness and shutdown
|
|
27
|
+
|
|
28
|
+
SQLite runs in a dedicated worker, including opening the database, queries,
|
|
29
|
+
pruning and WAL checkpointing. A slow storage operation therefore does not block
|
|
30
|
+
the Signal K server's event loop. This does not make slow storage faster: a track
|
|
31
|
+
query can still wait behind earlier writes, while unrelated server requests can
|
|
32
|
+
continue. Large query results still arrive via structured cloning, and their
|
|
33
|
+
materialization and HTTP serialization take main-thread time, so the worker
|
|
34
|
+
does not remove every source of server latency.
|
|
35
|
+
|
|
36
|
+
Positions retain their arrival timestamp (or the supplied observation timestamp)
|
|
37
|
+
and coordinates are copied when accepted. Operations are ordered, with one batch
|
|
38
|
+
in flight; the entire adjacent waiting mutation run commits in one transaction,
|
|
39
|
+
bounded by the same queue byte/item limits. Positions
|
|
40
|
+
inside a context's resolution window are discarded before cloning/admission;
|
|
41
|
+
the worker also retains the store's throttle as a consistency guard. There is no
|
|
42
|
+
extra batching delay or change to the configured recording resolution, spatial
|
|
43
|
+
filters or schema. SQLite uses WAL with `synchronous=NORMAL`, so a commit does
|
|
44
|
+
not sync; the only syncs are the checkpoints, which run in the worker too.
|
|
45
|
+
Persisted vessel names are cached from
|
|
46
|
+
worker-acknowledged writes; the live data model still takes precedence.
|
|
47
|
+
|
|
48
|
+
Pending operations are bounded to 10,000 items / 8 MiB of serialized arguments,
|
|
49
|
+
including the batch in flight. If recording exceeds either limit, a plugin error
|
|
50
|
+
pauses further recording while accepted work drains. Recording automatically
|
|
51
|
+
resumes once both item and byte usage reach at most half capacity. The plugin
|
|
52
|
+
logs the gap's start time and dropped operation count (operations include names
|
|
53
|
+
and maintenance, not just positions). Dropped samples are not replayed.
|
|
54
|
+
|
|
55
|
+
A failed write transaction stops subsequent writes until a plugin restart;
|
|
56
|
+
uncertain writes are never retried. A successful rollback leaves the database
|
|
57
|
+
open, so History and v1 queries can still read committed tracks, including when
|
|
58
|
+
storage is full. Startup, worker exit or an uncertain rollback fail the whole
|
|
59
|
+
store. These errors remain visible instead of being replaced by healthy status.
|
|
60
|
+
|
|
61
|
+
Stopping unsubscribes input immediately, then returns a Promise that resolves
|
|
62
|
+
only after accepted work drains, SQLite closes and the worker exits. Plugin
|
|
63
|
+
lifecycle callers must await `stop()` before restarting or removing its data
|
|
64
|
+
directory. `start()` returns the initialization Promise; track requests submitted
|
|
65
|
+
during initialization wait behind it. This drain covers callers that actually
|
|
66
|
+
invoke and await `stop()`, such as a settings change or disabling the plugin.
|
|
67
|
+
The current server shutdown path does not guarantee that: a server restart can
|
|
68
|
+
lose accepted queued samples even on an otherwise orderly process shutdown.
|
|
69
|
+
Do not assume the plugin's async stop promise is awaited by the server.
|
|
70
|
+
|
|
71
|
+
Accepted but uncommitted data is still in RAM. Abrupt power loss or forced process
|
|
72
|
+
termination can lose pending samples; worker isolation does not promise zero data
|
|
73
|
+
loss. Committed records are not synced one by one either, so a power cut can
|
|
74
|
+
also lose the most recent of them, though never damage the file. Rollback of plugin code
|
|
75
|
+
must never replace the database with an older copy over newly recorded tracks.
|
|
76
|
+
|
|
24
77
|
## What you get with no history provider
|
|
25
78
|
|
|
26
79
|
Everything comes from the plugin's own store, at whatever interval it is
|