@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/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { C as
|
|
1
|
+
import { C as thinToBudget, S as thin, _ as unwrapString, a as contextName, b as parseTrackQuery, c as createMatcher, d as longitudeSpan, f as resolveContext, g as trackLabel, h as toIsoTimes, i as clipToBounds, l as gpxFilename, n as SelfPositionUnavailableError, o as createDistanceTo, p as rfc8187, r as asciiFilename, s as createInBounds, t as QueryParameterError, u as historyRowPosition, v as validateParameters, w as uncoveredBetween, x as segment, y as TimeWindowError } from "./utils-C0VkiidM.js";
|
|
2
2
|
import { Temporal } from "@js-temporal/polyfill";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { Worker } from "node:worker_threads";
|
|
5
6
|
import { DOMParser } from "@xmldom/xmldom";
|
|
6
7
|
import { BehaviorSubject, ReplaySubject, Subject, combineLatest, connectable, firstValueFrom } from "rxjs";
|
|
@@ -116,6 +117,173 @@ function nearestLongitude(lng, origin) {
|
|
|
116
117
|
return lng - 360 * Math.round((lng - origin) / 360);
|
|
117
118
|
}
|
|
118
119
|
//#endregion
|
|
120
|
+
//#region src/trackApi.ts
|
|
121
|
+
/**
|
|
122
|
+
* Thrown by `storeTrack` when this provider will not keep the track, which
|
|
123
|
+
* the server answers with a 400 rather than a 500.
|
|
124
|
+
*
|
|
125
|
+
* The server recognises it by `isTrackRejected` rather than by class, since
|
|
126
|
+
* this is a copy of its own.
|
|
127
|
+
*/
|
|
128
|
+
var TrackRejectedError = class extends Error {
|
|
129
|
+
isTrackRejected = true;
|
|
130
|
+
constructor(message) {
|
|
131
|
+
super(message);
|
|
132
|
+
this.name = "TrackRejectedError";
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/importedTracks.ts
|
|
137
|
+
/**
|
|
138
|
+
* Prefix of every imported track's id. It keeps imported and recorded tracks
|
|
139
|
+
* in separate id spaces, so no import can ever address a recorded track.
|
|
140
|
+
*/
|
|
141
|
+
var IMPORTED_PREFIX = "imported:";
|
|
142
|
+
var isImportedId = (id) => id.startsWith(IMPORTED_PREFIX);
|
|
143
|
+
/**
|
|
144
|
+
* The track the server handed over, as this plugin stores it.
|
|
145
|
+
*
|
|
146
|
+
* Times are sorted within each segment, the way `fromGpx` reads a file: the
|
|
147
|
+
* thinning and window code assume time order, and a segment boundary is a gap
|
|
148
|
+
* in recording, so segments are never merged or reordered.
|
|
149
|
+
*
|
|
150
|
+
* Refuses a track with neither times nor a vessel. Such a track answers no
|
|
151
|
+
* time window, and the server refuses a query across all vessels without one,
|
|
152
|
+
* so it could only ever be found again by an id the client would have to keep.
|
|
153
|
+
*/
|
|
154
|
+
function toImportedTrack(track, id = `${IMPORTED_PREFIX}${randomUUID()}`) {
|
|
155
|
+
const { coordTimes, name, context, ...metadata } = track.properties;
|
|
156
|
+
if (coordTimes === void 0 && context === void 0) throw new TrackRejectedError("A track with neither coordTimes nor a context could not be listed again; send one of them");
|
|
157
|
+
const segments = track.geometry.coordinates.map((segment, i) => {
|
|
158
|
+
const points = segment.map(([lon, lat], j) => {
|
|
159
|
+
const time = coordTimes?.[i]?.[j];
|
|
160
|
+
return time === void 0 ? { position: [lat, lon] } : {
|
|
161
|
+
position: [lat, lon],
|
|
162
|
+
timestamp: Date.parse(time)
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
return coordTimes === void 0 ? points : points.sort((a, b) => a.timestamp - b.timestamp);
|
|
166
|
+
});
|
|
167
|
+
return {
|
|
168
|
+
id,
|
|
169
|
+
...context === void 0 ? {} : { context },
|
|
170
|
+
...name === void 0 ? {} : { name },
|
|
171
|
+
metadata,
|
|
172
|
+
segments
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* A track read from a GPX file, as this plugin stores it, with the number of
|
|
177
|
+
* points left out.
|
|
178
|
+
*
|
|
179
|
+
* `fromGpx` dates a point with no `<time>` to 0. A file with no times at all
|
|
180
|
+
* becomes an untimed import; a file with some keeps only its timed points,
|
|
181
|
+
* because an import is timed throughout or not at all, and its times are what
|
|
182
|
+
* let a query find it. `context` names the vessel in place of the file's own.
|
|
183
|
+
* Refused, like any import, when it ends up with neither times nor a vessel.
|
|
184
|
+
*/
|
|
185
|
+
function fromGpxTrack(gpx, context = gpx.context, id = `${IMPORTED_PREFIX}${randomUUID()}`) {
|
|
186
|
+
const all = gpx.segments.flat();
|
|
187
|
+
const timed = all.some(({ timestamp }) => timestamp !== 0);
|
|
188
|
+
if (!timed && context === void 0) throw new TrackRejectedError(`Track '${gpx.name}' has neither times nor a vessel, so it could not be listed again; choose a vessel for it`);
|
|
189
|
+
const segments = gpx.segments.map((points) => points.filter(({ timestamp }) => !timed || timestamp !== 0).map(({ position, timestamp }) => timed ? {
|
|
190
|
+
position,
|
|
191
|
+
timestamp
|
|
192
|
+
} : { position })).filter((points) => points.length > 0);
|
|
193
|
+
const kept = segments.reduce((sum, points) => sum + points.length, 0);
|
|
194
|
+
return {
|
|
195
|
+
track: {
|
|
196
|
+
id,
|
|
197
|
+
...context === void 0 ? {} : { context },
|
|
198
|
+
name: gpx.name,
|
|
199
|
+
metadata: {},
|
|
200
|
+
segments
|
|
201
|
+
},
|
|
202
|
+
skippedPoints: all.length - kept
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** Whether the track's points carry times; an import is timed throughout or not at all. */
|
|
206
|
+
var isTimed = (track) => track.segments[0]?.[0]?.timestamp !== void 0;
|
|
207
|
+
/**
|
|
208
|
+
* The track as one list of points for the shared pipeline, each posted segment
|
|
209
|
+
* after the first starting a new line.
|
|
210
|
+
*
|
|
211
|
+
* Narrowed to the window and clipped per segment, so a line never joins two
|
|
212
|
+
* segments: not where the window empties the start of one, nor through the
|
|
213
|
+
* point just outside the box that a clipped stretch keeps, which must come
|
|
214
|
+
* from its own segment. An untimed point gets timestamp 0, which only the
|
|
215
|
+
* time-free paths of the pipeline ever see.
|
|
216
|
+
*/
|
|
217
|
+
function positionsOf(track, clipTo, window) {
|
|
218
|
+
const result = [];
|
|
219
|
+
for (const segment of track.segments) {
|
|
220
|
+
let points = segment.map(({ position, timestamp }) => ({
|
|
221
|
+
position,
|
|
222
|
+
timestamp: timestamp ?? 0
|
|
223
|
+
}));
|
|
224
|
+
if (window) points = points.filter(({ timestamp }) => window.inclusiveEnd ? timestamp >= window.from && timestamp <= window.to : timestamp >= window.from && timestamp < window.to);
|
|
225
|
+
const kept = clipTo ? clipToBounds(points, clipTo) : points;
|
|
226
|
+
for (const [i, point] of kept.entries()) result.push(i === 0 && result.length > 0 ? {
|
|
227
|
+
...point,
|
|
228
|
+
breakBefore: true
|
|
229
|
+
} : point);
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* At most `maxPoints` evenly chosen points of a track without times, keeping
|
|
235
|
+
* the first and the last. A point that starts a new line passes that on to
|
|
236
|
+
* the next point kept, so dropping it does not join two segments.
|
|
237
|
+
*/
|
|
238
|
+
function decimate(points, maxPoints) {
|
|
239
|
+
if (points.length <= maxPoints) return points;
|
|
240
|
+
if (maxPoints < 2) return points.slice(0, Math.max(0, maxPoints));
|
|
241
|
+
const step = (points.length - 1) / (maxPoints - 1);
|
|
242
|
+
const result = [];
|
|
243
|
+
let previous = -1;
|
|
244
|
+
for (let k = 0; k < maxPoints; k++) {
|
|
245
|
+
const index = Math.round(k * step);
|
|
246
|
+
const breaks = points.slice(previous + 1, index + 1).some(({ breakBefore }) => breakBefore);
|
|
247
|
+
const point = points[index];
|
|
248
|
+
result.push(breaks && result.length > 0 ? {
|
|
249
|
+
...point,
|
|
250
|
+
breakBefore: true
|
|
251
|
+
} : point);
|
|
252
|
+
previous = index;
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region src/recycleBin.ts
|
|
258
|
+
/**
|
|
259
|
+
* Prefix of a recorded track's id. The rest is the vessel's context: a vessel
|
|
260
|
+
* has one recording, so its context is all an id needs, and a provider may
|
|
261
|
+
* derive the id from it, as the server's Track API foresees.
|
|
262
|
+
*/
|
|
263
|
+
var RECORDED_PREFIX = "recorded:";
|
|
264
|
+
var recordedId = (context) => `${RECORDED_PREFIX}${context}`;
|
|
265
|
+
/** The context a recorded track's id names, or undefined for any other id. */
|
|
266
|
+
var recordedContext = (id) => id.startsWith("recorded:") && id.length > 9 ? id.slice(9) : void 0;
|
|
267
|
+
/**
|
|
268
|
+
* The points outside every deleted span.
|
|
269
|
+
*
|
|
270
|
+
* Applied to what a history provider returns, which this plugin cannot delete
|
|
271
|
+
* from: without it the next query would fill a deleted span back in. A
|
|
272
|
+
* history point stands for a bucket starting at its timestamp, and the fix it
|
|
273
|
+
* carries can lie anywhere in that bucket, so a bucket that overlaps a span at
|
|
274
|
+
* all is dropped; the store's own points answer for the rest of it.
|
|
275
|
+
*/
|
|
276
|
+
function outsideSpans(points, spans) {
|
|
277
|
+
if (spans.length === 0) return points;
|
|
278
|
+
return points.filter(({ timestamp, span = 0 }) => !spans.some((deleted) => overlaps(timestamp, span, deleted)));
|
|
279
|
+
}
|
|
280
|
+
/** Whether `[start, start + width)`, or the instant `start` when `width` is 0, meets `[from, to]`. */
|
|
281
|
+
function overlaps(start, width, { from, to }) {
|
|
282
|
+
if (start > to) return false;
|
|
283
|
+
if (from === void 0) return true;
|
|
284
|
+
return width > 0 ? start + width > from : start >= from;
|
|
285
|
+
}
|
|
286
|
+
//#endregion
|
|
119
287
|
//#region src/trackProvider.ts
|
|
120
288
|
/** v2 sends `[west, south, east, north]`; a bounds here is `[lat, lng]` corners. */
|
|
121
289
|
var toGeoBounds = (bbox) => {
|
|
@@ -202,36 +370,6 @@ var boundsOf = (points) => {
|
|
|
202
370
|
north
|
|
203
371
|
];
|
|
204
372
|
};
|
|
205
|
-
/**
|
|
206
|
-
* The narrower of the two longitude intervals covering these points.
|
|
207
|
-
*
|
|
208
|
-
* A plain min/max describes a track spanning the antimeridian as almost the
|
|
209
|
-
* whole globe: two fixes at 179 and -179 are two degrees apart, but min/max
|
|
210
|
-
* reports 358. RFC 7946 says a box crossing the antimeridian is written with
|
|
211
|
-
* west greater than east, so the wrap-around interval is expressible — pick it
|
|
212
|
-
* whenever it is the shorter of the two.
|
|
213
|
-
*
|
|
214
|
-
* The widest gap between adjacent longitudes is the part of the globe the track
|
|
215
|
-
* does not cover, so the complement of that gap is the tightest interval that
|
|
216
|
-
* does.
|
|
217
|
-
*/
|
|
218
|
-
var longitudeSpan = (longitudes) => {
|
|
219
|
-
const sorted = [...longitudes].sort((a, b) => a - b);
|
|
220
|
-
const min = sorted[0];
|
|
221
|
-
const max = sorted[sorted.length - 1];
|
|
222
|
-
let widestGap = 360 - (max - min);
|
|
223
|
-
let west = min;
|
|
224
|
-
let east = max;
|
|
225
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
226
|
-
const gap = sorted[i] - sorted[i - 1];
|
|
227
|
-
if (gap > widestGap) {
|
|
228
|
-
widestGap = gap;
|
|
229
|
-
west = sorted[i];
|
|
230
|
-
east = sorted[i - 1];
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
return [west, east];
|
|
234
|
-
};
|
|
235
373
|
function createTrackProvider(deps) {
|
|
236
374
|
const matching = async (query) => {
|
|
237
375
|
const store = deps.store();
|
|
@@ -274,42 +412,100 @@ function createTrackProvider(deps) {
|
|
|
274
412
|
resolution
|
|
275
413
|
};
|
|
276
414
|
};
|
|
415
|
+
/**
|
|
416
|
+
* Imported tracks the query matches, each with the points it returns.
|
|
417
|
+
*
|
|
418
|
+
* The same filters as a recorded track, applied to the import's own points:
|
|
419
|
+
* a context names the vessel it was imported for, a window needs times, a
|
|
420
|
+
* box needs a point inside it and clips like a recorded track. Never
|
|
421
|
+
* reconciled with history, which knows nothing of an import.
|
|
422
|
+
*/
|
|
423
|
+
const matchingImports = async (query) => {
|
|
424
|
+
const store = deps.store();
|
|
425
|
+
if (!store?.findImports) return [];
|
|
426
|
+
const window = toTimeWindow(query);
|
|
427
|
+
const resolution = query.resolution ? totalMilliseconds(query.resolution) : void 0;
|
|
428
|
+
const bounds = toGeoBounds(query.bbox);
|
|
429
|
+
const clipTo = query.clip === true ? bounds : null;
|
|
430
|
+
const inBounds = bounds ? createInBounds(bounds) : void 0;
|
|
431
|
+
const imports = await store.findImports({
|
|
432
|
+
...query.contexts?.length ? { contexts: [...new Set(query.contexts.map(resolveSelf(deps.selfContext())))] } : {},
|
|
433
|
+
...window ? { window } : {},
|
|
434
|
+
...bounds ? { bbox: bounds } : {}
|
|
435
|
+
});
|
|
436
|
+
const result = [];
|
|
437
|
+
for (const track of imports) {
|
|
438
|
+
const timed = isTimed(track);
|
|
439
|
+
if (inBounds && !positionsOf(track, null, window).some(({ position }) => inBounds(position))) continue;
|
|
440
|
+
let points = positionsOf(track, clipTo, window);
|
|
441
|
+
if (timed) points = thin(points, resolution);
|
|
442
|
+
if (points.length > 0) result.push({
|
|
443
|
+
track,
|
|
444
|
+
points,
|
|
445
|
+
timed
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
return result;
|
|
449
|
+
};
|
|
450
|
+
/** What names an imported track: its id, name and vessel, over what it was posted with. */
|
|
451
|
+
const importIdentity = (track) => {
|
|
452
|
+
const contextName = track.context === void 0 ? void 0 : deps.contextName(track.context);
|
|
453
|
+
return {
|
|
454
|
+
...track.metadata,
|
|
455
|
+
id: track.id,
|
|
456
|
+
...track.name === void 0 ? {} : { name: track.name },
|
|
457
|
+
...track.context === void 0 ? {} : {
|
|
458
|
+
context: track.context,
|
|
459
|
+
isSelf: track.context === deps.selfContext(),
|
|
460
|
+
...contextName === void 0 ? {} : { contextName }
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
};
|
|
464
|
+
/** The store, when it can keep and delete tracks; an ordinary failure otherwise. */
|
|
465
|
+
const writableStore = () => {
|
|
466
|
+
const store = deps.store();
|
|
467
|
+
if (!store?.storeImport || !store.getImport || !store.binImport || !store.binRecorded) throw new Error("Track storage is not available");
|
|
468
|
+
return store;
|
|
469
|
+
};
|
|
470
|
+
/** The recorded feature's identity, which `getTracks` and `getTrack` share. */
|
|
471
|
+
const recordedIdentity = (context) => {
|
|
472
|
+
const name = deps.contextName(context);
|
|
473
|
+
return {
|
|
474
|
+
id: recordedId(context),
|
|
475
|
+
context,
|
|
476
|
+
isSelf: context === deps.selfContext(),
|
|
477
|
+
...name === void 0 ? {} : { contextName: name }
|
|
478
|
+
};
|
|
479
|
+
};
|
|
480
|
+
/**
|
|
481
|
+
* Move a vessel's recording within the span to the recycle bin, resolving
|
|
482
|
+
* false for a vessel neither the store nor history knows.
|
|
483
|
+
*
|
|
484
|
+
* `to` is the moment of the delete, never the end of time: recording goes
|
|
485
|
+
* on, and what it records afterwards is a new track, not a deleted one.
|
|
486
|
+
*/
|
|
487
|
+
const deleteRecorded = async (context, from, to) => {
|
|
488
|
+
const now = Date.now();
|
|
489
|
+
const end = Math.min(to ?? now, now);
|
|
490
|
+
const historyKnown = await deps.historyKnows(context);
|
|
491
|
+
const keepEmpty = historyKnown && (from === void 0 || from <= end);
|
|
492
|
+
const { known } = await writableStore().binRecorded(context, from, end, now, keepEmpty);
|
|
493
|
+
deps.deleted();
|
|
494
|
+
return known || historyKnown;
|
|
495
|
+
};
|
|
277
496
|
return {
|
|
278
497
|
async getTracks(query) {
|
|
279
498
|
const { tracks: matched, resolution: requested } = await matching(query);
|
|
280
499
|
const gap = deps.segmentGap();
|
|
281
|
-
const selfContext = deps.selfContext();
|
|
282
500
|
const features = [];
|
|
283
501
|
for (const [context, all] of matched) {
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
const shaped = segments.flat();
|
|
292
|
-
const bbox = boundsOf(shaped);
|
|
293
|
-
const name = deps.contextName(context);
|
|
294
|
-
features.push({
|
|
295
|
-
type: "Feature",
|
|
296
|
-
geometry: query.geometry === false ? null : {
|
|
297
|
-
type: "MultiLineString",
|
|
298
|
-
coordinates: segments.map((s) => s.map(({ position }) => toLngLat$1(position)))
|
|
299
|
-
},
|
|
300
|
-
properties: {
|
|
301
|
-
context,
|
|
302
|
-
isSelf: context === selfContext,
|
|
303
|
-
...name === void 0 ? {} : { contextName: name },
|
|
304
|
-
from: new Date(shaped[0].timestamp).toISOString(),
|
|
305
|
-
to: new Date(shaped[shaped.length - 1].timestamp).toISOString(),
|
|
306
|
-
...bbox ? { bbox } : {},
|
|
307
|
-
pointCount: shaped.length,
|
|
308
|
-
...appliedMs === void 0 ? {} : { resolution: msToDuration(appliedMs).toString() },
|
|
309
|
-
...chosenEpsilon === void 0 ? {} : { epsilon: chosenEpsilon },
|
|
310
|
-
...query.times ? { coordTimes: segments.map(toIsoTimes) } : {}
|
|
311
|
-
}
|
|
312
|
-
});
|
|
502
|
+
const feature = shapeFeature(all, gap, query, requested, recordedIdentity(context));
|
|
503
|
+
if (feature) features.push(feature);
|
|
504
|
+
}
|
|
505
|
+
const requestedForImports = query.resolution ? totalMilliseconds(query.resolution) : void 0;
|
|
506
|
+
for (const { track, points, timed } of await matchingImports(query)) {
|
|
507
|
+
const feature = shapeFeature(points, 0, query, timed ? requestedForImports : void 0, importIdentity(track), timed);
|
|
508
|
+
if (feature) features.push(feature);
|
|
313
509
|
}
|
|
314
510
|
return {
|
|
315
511
|
type: "FeatureCollection",
|
|
@@ -317,7 +513,95 @@ function createTrackProvider(deps) {
|
|
|
317
513
|
};
|
|
318
514
|
},
|
|
319
515
|
async getTrackContexts(query) {
|
|
320
|
-
|
|
516
|
+
const [recorded, imported] = await Promise.all([matching(query), matchingImports(query)]);
|
|
517
|
+
const contexts = new Set(recorded.tracks.keys());
|
|
518
|
+
for (const { track } of imported) if (track.context !== void 0) contexts.add(track.context);
|
|
519
|
+
return [...contexts];
|
|
520
|
+
},
|
|
521
|
+
async getTrack(id) {
|
|
522
|
+
const recorded = recordedContext(id);
|
|
523
|
+
if (recorded !== void 0) {
|
|
524
|
+
const context = resolveSelf(deps.selfContext())(recorded);
|
|
525
|
+
const query = {
|
|
526
|
+
contexts: [context],
|
|
527
|
+
times: true
|
|
528
|
+
};
|
|
529
|
+
const { tracks } = await matching(query);
|
|
530
|
+
const points = tracks.get(context);
|
|
531
|
+
return points && shapeFeature(points, deps.segmentGap(), query, void 0, recordedIdentity(context));
|
|
532
|
+
}
|
|
533
|
+
if (!isImportedId(id)) return;
|
|
534
|
+
const track = await writableStore().getImport(id);
|
|
535
|
+
if (!track) return;
|
|
536
|
+
const timed = isTimed(track);
|
|
537
|
+
return shapeFeature(positionsOf(track, null), 0, { times: timed }, void 0, importIdentity(track), timed);
|
|
538
|
+
},
|
|
539
|
+
async storeTrack(track) {
|
|
540
|
+
const imported = toImportedTrack(track);
|
|
541
|
+
await writableStore().storeImport(imported);
|
|
542
|
+
return imported.id;
|
|
543
|
+
},
|
|
544
|
+
async deleteTrack(id) {
|
|
545
|
+
const recorded = recordedContext(id);
|
|
546
|
+
if (recorded !== void 0) return deleteRecorded(resolveSelf(deps.selfContext())(recorded), void 0, void 0);
|
|
547
|
+
if (!isImportedId(id)) return false;
|
|
548
|
+
return await writableStore().binImport(id, void 0, Date.now()) !== void 0;
|
|
549
|
+
},
|
|
550
|
+
async deleteTrackSpan(id, span) {
|
|
551
|
+
const from = span.from?.epochMilliseconds;
|
|
552
|
+
const to = span.to?.epochMilliseconds;
|
|
553
|
+
const recorded = recordedContext(id);
|
|
554
|
+
if (recorded !== void 0) return deleteRecorded(resolveSelf(deps.selfContext())(recorded), from, to);
|
|
555
|
+
if (!isImportedId(id)) return false;
|
|
556
|
+
const store = writableStore();
|
|
557
|
+
const track = await store.getImport(id);
|
|
558
|
+
if (!track) return false;
|
|
559
|
+
if (!isTimed(track)) throw new TrackRejectedError("This track has no times, so no part of it can be addressed by time");
|
|
560
|
+
await store.binImport(id, {
|
|
561
|
+
...from === void 0 ? {} : { from },
|
|
562
|
+
...to === void 0 ? {} : { to }
|
|
563
|
+
}, Date.now());
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* One track as a Feature, or undefined when the budget leaves nothing to draw.
|
|
570
|
+
*
|
|
571
|
+
* `identity` is whatever names the track — context, vessel name — and comes
|
|
572
|
+
* first, so the derived properties after it always describe the geometry
|
|
573
|
+
* actually returned.
|
|
574
|
+
*/
|
|
575
|
+
function shapeFeature(all, gap, query, requested, identity, timed = true) {
|
|
576
|
+
const { points, resolution: appliedMs } = query.maxPoints === void 0 ? {
|
|
577
|
+
points: all,
|
|
578
|
+
resolution: requested
|
|
579
|
+
} : timed ? thinToBudget(all, query.maxPoints, requested) : {
|
|
580
|
+
points: decimate(all, query.maxPoints),
|
|
581
|
+
resolution: void 0
|
|
582
|
+
};
|
|
583
|
+
if (points.length === 0) return;
|
|
584
|
+
const chosenEpsilon = chooseEpsilon(points, query);
|
|
585
|
+
const segments = segment(points, gap).map((s) => chosenEpsilon === void 0 ? s : simplify(s, chosenEpsilon));
|
|
586
|
+
const shaped = segments.flat();
|
|
587
|
+
const bbox = boundsOf(shaped);
|
|
588
|
+
return {
|
|
589
|
+
type: "Feature",
|
|
590
|
+
geometry: query.geometry === false ? null : {
|
|
591
|
+
type: "MultiLineString",
|
|
592
|
+
coordinates: segments.map((s) => s.map(({ position }) => toLngLat$1(position)))
|
|
593
|
+
},
|
|
594
|
+
properties: {
|
|
595
|
+
...identity,
|
|
596
|
+
...timed ? {
|
|
597
|
+
from: new Date(shaped[0].timestamp).toISOString(),
|
|
598
|
+
to: new Date(shaped[shaped.length - 1].timestamp).toISOString()
|
|
599
|
+
} : {},
|
|
600
|
+
...bbox ? { bbox } : {},
|
|
601
|
+
pointCount: shaped.length,
|
|
602
|
+
...appliedMs === void 0 ? {} : { resolution: msToDuration(appliedMs).toString() },
|
|
603
|
+
...chosenEpsilon === void 0 ? {} : { epsilon: chosenEpsilon },
|
|
604
|
+
...query.times && timed ? { coordTimes: segments.map(toIsoTimes) } : {}
|
|
321
605
|
}
|
|
322
606
|
};
|
|
323
607
|
}
|
|
@@ -483,8 +767,10 @@ var AsyncTrackStore = class {
|
|
|
483
767
|
this.queue = [];
|
|
484
768
|
this.pendingBytes = 0;
|
|
485
769
|
}
|
|
486
|
-
enqueue(method, args,
|
|
770
|
+
enqueue(method, args, kind = "read") {
|
|
487
771
|
this.resumeIfDrained();
|
|
772
|
+
if (kind === "acknowledged") return this.acknowledged(method, args);
|
|
773
|
+
const write = kind === "write";
|
|
488
774
|
if (this.failed || this.closing && method !== "close" || write && this.statusError) {
|
|
489
775
|
if (write && this.overloaded) this.dropped++;
|
|
490
776
|
const error = new Error(this.statusError ?? "Track store is closing");
|
|
@@ -529,6 +815,37 @@ var AsyncTrackStore = class {
|
|
|
529
815
|
this.schedule();
|
|
530
816
|
return result;
|
|
531
817
|
}
|
|
818
|
+
/**
|
|
819
|
+
* A write whose caller waits for the commit: an imported track, or its
|
|
820
|
+
* deletion. Admitted outside the recording budget, which is sized for
|
|
821
|
+
* positions; an import of a few megabytes would otherwise pause recording,
|
|
822
|
+
* and then be dropped like a position. Refused once writes have failed,
|
|
823
|
+
* since that state lasts until a restart.
|
|
824
|
+
*/
|
|
825
|
+
acknowledged(method, args) {
|
|
826
|
+
if (this.failed || this.closing || this.writeFailed) return Promise.reject(new Error(this.statusError ?? "Track store cannot write"));
|
|
827
|
+
let copy;
|
|
828
|
+
try {
|
|
829
|
+
copy = structuredClone(args);
|
|
830
|
+
} catch (error) {
|
|
831
|
+
return Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
|
832
|
+
}
|
|
833
|
+
const item = {
|
|
834
|
+
operation: {
|
|
835
|
+
id: ++this.sequence,
|
|
836
|
+
method,
|
|
837
|
+
args: copy
|
|
838
|
+
},
|
|
839
|
+
bytes: 0
|
|
840
|
+
};
|
|
841
|
+
const result = new Promise((resolve, reject) => Object.assign(item, {
|
|
842
|
+
resolve,
|
|
843
|
+
reject
|
|
844
|
+
}));
|
|
845
|
+
this.queue.push(item);
|
|
846
|
+
this.schedule();
|
|
847
|
+
return result;
|
|
848
|
+
}
|
|
532
849
|
schedule() {
|
|
533
850
|
if (this.scheduled || !this.initialized || this.active.length || this.failed) return;
|
|
534
851
|
this.scheduled = true;
|
|
@@ -614,7 +931,7 @@ var AsyncTrackStore = class {
|
|
|
614
931
|
context,
|
|
615
932
|
position,
|
|
616
933
|
timestamp
|
|
617
|
-
],
|
|
934
|
+
], "write");
|
|
618
935
|
if (this.sequence !== sequence && this.resolution > 0) this.admitted.set(context, timestamp);
|
|
619
936
|
}
|
|
620
937
|
recordName(context, name, timestamp = Date.now()) {
|
|
@@ -622,7 +939,7 @@ var AsyncTrackStore = class {
|
|
|
622
939
|
context,
|
|
623
940
|
name,
|
|
624
941
|
timestamp
|
|
625
|
-
],
|
|
942
|
+
], "write");
|
|
626
943
|
}
|
|
627
944
|
nameFor(context) {
|
|
628
945
|
return this.names.get(context);
|
|
@@ -633,7 +950,7 @@ var AsyncTrackStore = class {
|
|
|
633
950
|
context,
|
|
634
951
|
track,
|
|
635
952
|
timestamps
|
|
636
|
-
],
|
|
953
|
+
], "write");
|
|
637
954
|
if (this.sequence !== sequence) this.admitted.delete(context);
|
|
638
955
|
}
|
|
639
956
|
prune(maxAge, keep) {
|
|
@@ -643,7 +960,7 @@ var AsyncTrackStore = class {
|
|
|
643
960
|
maxAge,
|
|
644
961
|
keep,
|
|
645
962
|
now
|
|
646
|
-
],
|
|
963
|
+
], "write");
|
|
647
964
|
if (this.sequence !== sequence) {
|
|
648
965
|
for (const [context, timestamp] of this.admitted) if (context !== keep && timestamp < now - maxAge) this.admitted.delete(context);
|
|
649
966
|
}
|
|
@@ -673,6 +990,43 @@ var AsyncTrackStore = class {
|
|
|
673
990
|
query
|
|
674
991
|
]);
|
|
675
992
|
}
|
|
993
|
+
storeImport(track) {
|
|
994
|
+
return this.enqueue("storeImport", [track], "acknowledged");
|
|
995
|
+
}
|
|
996
|
+
binRecorded(context, from, to, deletedAt, keepEmpty) {
|
|
997
|
+
return this.enqueue("binRecorded", [
|
|
998
|
+
context,
|
|
999
|
+
from,
|
|
1000
|
+
to,
|
|
1001
|
+
deletedAt,
|
|
1002
|
+
keepEmpty
|
|
1003
|
+
], "acknowledged");
|
|
1004
|
+
}
|
|
1005
|
+
binImport(importId, span, deletedAt) {
|
|
1006
|
+
return this.enqueue("binImport", [
|
|
1007
|
+
importId,
|
|
1008
|
+
span,
|
|
1009
|
+
deletedAt
|
|
1010
|
+
], "acknowledged");
|
|
1011
|
+
}
|
|
1012
|
+
restoreFromBin(id) {
|
|
1013
|
+
return this.enqueue("restoreFromBin", [id], "acknowledged");
|
|
1014
|
+
}
|
|
1015
|
+
purgeFromBin(id) {
|
|
1016
|
+
return this.enqueue("purgeFromBin", [id], "acknowledged");
|
|
1017
|
+
}
|
|
1018
|
+
binEntries() {
|
|
1019
|
+
return this.enqueue("binEntries", []);
|
|
1020
|
+
}
|
|
1021
|
+
deletedSpans() {
|
|
1022
|
+
return this.enqueue("deletedSpans", []);
|
|
1023
|
+
}
|
|
1024
|
+
getImport(id) {
|
|
1025
|
+
return this.enqueue("getImport", [id]);
|
|
1026
|
+
}
|
|
1027
|
+
findImports(filter) {
|
|
1028
|
+
return this.enqueue("findImports", [filter]);
|
|
1029
|
+
}
|
|
676
1030
|
close() {
|
|
677
1031
|
if (!this.closePromise) {
|
|
678
1032
|
this.closing = true;
|
|
@@ -810,6 +1164,25 @@ function reconcile(history, stored, resolution) {
|
|
|
810
1164
|
};
|
|
811
1165
|
}
|
|
812
1166
|
/**
|
|
1167
|
+
* Merge history that is coarser than the store into it, the store first.
|
|
1168
|
+
*
|
|
1169
|
+
* The rule above holds while history is the finer source. A history read
|
|
1170
|
+
* widened to stay within its bucket budget is not: one of its buckets spans
|
|
1171
|
+
* several stored fixes, and letting it win would replace them all with one
|
|
1172
|
+
* point. So here the store keeps every bucket it has a point in, and history
|
|
1173
|
+
* fills only the buckets the store has nothing for — where the provider
|
|
1174
|
+
* recorded before this plugin was installed, or while it was off.
|
|
1175
|
+
*/
|
|
1176
|
+
function fillFromHistory(history, stored, resolution) {
|
|
1177
|
+
const covered = new Set(stored.map(({ timestamp }) => bucketOf(timestamp, resolution)));
|
|
1178
|
+
const filling = history.filter(({ timestamp }) => !covered.has(bucketOf(timestamp, resolution)));
|
|
1179
|
+
return {
|
|
1180
|
+
positions: measuredFromHistory([...stored, ...filling].sort((a, b) => a.timestamp - b.timestamp), filling),
|
|
1181
|
+
fromHistory: filling.length,
|
|
1182
|
+
fromStore: stored.length
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
813
1186
|
* A stored point's `pauseBefore` was measured over the stored points thinning
|
|
814
1187
|
* dropped before it. Where a history point now comes right before it, history
|
|
815
1188
|
* covered part of that time, so the pause is at most the time since that
|
|
@@ -1124,7 +1497,12 @@ function toGpx(tracks, now = /* @__PURE__ */ new Date()) {
|
|
|
1124
1497
|
for (const points of segments) {
|
|
1125
1498
|
lines.push(" <trkseg>");
|
|
1126
1499
|
for (const { position, timestamp } of points) {
|
|
1127
|
-
|
|
1500
|
+
const at = `lat="${coordinate(position[LAT])}" lon="${coordinate(position[LNG])}"`;
|
|
1501
|
+
if (timestamp === void 0) {
|
|
1502
|
+
lines.push(` <trkpt ${at}/>`);
|
|
1503
|
+
continue;
|
|
1504
|
+
}
|
|
1505
|
+
lines.push(` <trkpt ${at}>`);
|
|
1128
1506
|
lines.push(` <time>${iso(timestamp)}</time>`);
|
|
1129
1507
|
lines.push(" </trkpt>");
|
|
1130
1508
|
}
|
|
@@ -1357,6 +1735,53 @@ function inRange(latitude, longitude) {
|
|
|
1357
1735
|
return Number.isFinite(latitude) && Number.isFinite(longitude) && latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180;
|
|
1358
1736
|
}
|
|
1359
1737
|
//#endregion
|
|
1738
|
+
//#region src/upload.ts
|
|
1739
|
+
/** The largest GPX file the upload route reads, so one request cannot exhaust memory. */
|
|
1740
|
+
var MAX_UPLOAD_BYTES = 26214400;
|
|
1741
|
+
var UploadTooLargeError = class extends Error {
|
|
1742
|
+
constructor(limit) {
|
|
1743
|
+
super(`The file is larger than ${Math.round(limit / 1048576)} MB`);
|
|
1744
|
+
}
|
|
1745
|
+
};
|
|
1746
|
+
/** A body the server already parsed as something other than a file. */
|
|
1747
|
+
var UploadTypeError = class extends Error {
|
|
1748
|
+
constructor() {
|
|
1749
|
+
super("Send the file as the request body, with Content-Type application/gpx+xml");
|
|
1750
|
+
}
|
|
1751
|
+
};
|
|
1752
|
+
/** XML allows a byte-order mark, and the GPX parser does not skip one. */
|
|
1753
|
+
var withoutBom = (text) => text.startsWith("") ? text.slice(1) : text;
|
|
1754
|
+
/**
|
|
1755
|
+
* A request's body as UTF-8 text, refused past `limit` bytes.
|
|
1756
|
+
*
|
|
1757
|
+
* Read here rather than by a body parser because the server parses only JSON
|
|
1758
|
+
* and form bodies, and a GPX file arrives as XML. A body some other middleware
|
|
1759
|
+
* has already read as text is taken as it is; one it read as anything else is
|
|
1760
|
+
* refused, since the stream it came from is spent.
|
|
1761
|
+
*/
|
|
1762
|
+
function readText(req, limit = MAX_UPLOAD_BYTES) {
|
|
1763
|
+
const parsed = req.body;
|
|
1764
|
+
if (typeof parsed === "string") return Buffer.byteLength(parsed) > limit ? Promise.reject(new UploadTooLargeError(limit)) : Promise.resolve(withoutBom(parsed));
|
|
1765
|
+
if (req.readableEnded) return Promise.reject(new UploadTypeError());
|
|
1766
|
+
if (Number(req.headers["content-length"]) > limit) return Promise.reject(new UploadTooLargeError(limit));
|
|
1767
|
+
return new Promise((resolve, reject) => {
|
|
1768
|
+
const chunks = [];
|
|
1769
|
+
let size = 0;
|
|
1770
|
+
req.on("data", (chunk) => {
|
|
1771
|
+
size += chunk.length;
|
|
1772
|
+
if (size > limit) {
|
|
1773
|
+
reject(new UploadTooLargeError(limit));
|
|
1774
|
+
req.removeAllListeners("data");
|
|
1775
|
+
req.resume();
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
chunks.push(chunk);
|
|
1779
|
+
});
|
|
1780
|
+
req.on("end", () => resolve(withoutBom(Buffer.concat(chunks).toString("utf8"))));
|
|
1781
|
+
req.on("error", reject);
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
//#endregion
|
|
1360
1785
|
//#region src/tracks.ts
|
|
1361
1786
|
/**
|
|
1362
1787
|
* The in-memory store: a bounded sliding window of positions per context, held
|
|
@@ -1520,9 +1945,13 @@ var fetchTrack = (context) => {
|
|
|
1520
1945
|
};
|
|
1521
1946
|
//#endregion
|
|
1522
1947
|
//#region src/index.ts
|
|
1948
|
+
/** The plugin's id, which the server also uses as its Track API provider id. */
|
|
1949
|
+
var PLUGIN_ID = "tracks";
|
|
1523
1950
|
var toLngLat = ([lat, lng]) => [lng, lat];
|
|
1524
1951
|
var DEFAULT_RESOLUTION = 6e4;
|
|
1525
1952
|
var DEFAULT_AIS_RETENTION_DAYS = 30;
|
|
1953
|
+
var DEFAULT_RECYCLE_BIN_DAYS = 180;
|
|
1954
|
+
var DAY_MS = 864e5;
|
|
1526
1955
|
var PRUNE_INTERVAL_MS = 36e5;
|
|
1527
1956
|
var DEFAULT_SEGMENT_GAP_MINUTES = 0;
|
|
1528
1957
|
var SOURCE_STATUS_INTERVAL_MS = 3e4;
|
|
@@ -1561,6 +1990,30 @@ var WINDOWLESS_HISTORY_SPAN_MS = 864e5;
|
|
|
1561
1990
|
*/
|
|
1562
1991
|
var EXISTENCE_PROBE_FROM_MS = 0;
|
|
1563
1992
|
/**
|
|
1993
|
+
* The most buckets one history read may ask a provider for.
|
|
1994
|
+
*
|
|
1995
|
+
* A provider has no spatial filter, so a box query reads every position in
|
|
1996
|
+
* its window, and the cost of that read grows with the window's span over the
|
|
1997
|
+
* resolution, not with what is drawn. Providers fabricate a row for every
|
|
1998
|
+
* bucket in the range — questdb fills empty ones with nulls — and refuse a
|
|
1999
|
+
* range past their own cap, which one-minute buckets over two years already
|
|
2000
|
+
* exceed. A month of one-minute buckets keeps a Pi-class server responsive;
|
|
2001
|
+
* a longer window is read at a wider resolution instead.
|
|
2002
|
+
*/
|
|
2003
|
+
var HISTORY_BUCKET_BUDGET = 43200;
|
|
2004
|
+
/**
|
|
2005
|
+
* How many buckets the read that finds where a provider's data begins asks
|
|
2006
|
+
* for.
|
|
2007
|
+
*
|
|
2008
|
+
* A window with no start would otherwise reach back to the Unix epoch, and
|
|
2009
|
+
* spread over fifty-odd years the bucket budget leaves buckets weeks wide.
|
|
2010
|
+
* One coarse read first finds the earliest bucket holding a position, so the
|
|
2011
|
+
* real read starts there. A thousand buckets over the whole epoch are about
|
|
2012
|
+
* three weeks each, which only sets how much empty time the real read starts
|
|
2013
|
+
* with.
|
|
2014
|
+
*/
|
|
2015
|
+
var START_PROBE_BUCKETS = 1e3;
|
|
2016
|
+
/**
|
|
1564
2017
|
* How long a provider's context list is reused before asking again.
|
|
1565
2018
|
*
|
|
1566
2019
|
* The probe runs only for a vessel about to 404, which is precisely the
|
|
@@ -1741,65 +2194,80 @@ async function historyContextsIn(app, window, debug, cache) {
|
|
|
1741
2194
|
* provider is the finer source where it reaches, not a required one.
|
|
1742
2195
|
*/
|
|
1743
2196
|
async function historyPositions(app, context, window, resolutionMs, debug) {
|
|
1744
|
-
const
|
|
1745
|
-
const
|
|
1746
|
-
const getHistoryApi = app.getHistoryApi;
|
|
1747
|
-
if (!getHistoryApi) return {
|
|
2197
|
+
const askedSeconds = Math.max(1, Math.round(resolutionMs / 1e3));
|
|
2198
|
+
const none = (failed) => ({
|
|
1748
2199
|
points: [],
|
|
1749
|
-
resolutionMs:
|
|
1750
|
-
|
|
1751
|
-
|
|
2200
|
+
resolutionMs: askedSeconds * 1e3,
|
|
2201
|
+
coarsened: false,
|
|
2202
|
+
failed
|
|
2203
|
+
});
|
|
2204
|
+
const getHistoryApi = app.getHistoryApi;
|
|
2205
|
+
if (!getHistoryApi) return none(false);
|
|
1752
2206
|
let historyApi;
|
|
1753
2207
|
try {
|
|
1754
2208
|
historyApi = await withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider), HISTORY_QUERY_TIMEOUT_MS);
|
|
1755
2209
|
} catch (err) {
|
|
1756
2210
|
const timedOut = err instanceof HistoryTimeoutError;
|
|
1757
2211
|
if (debug.enabled) debug(`${timedOut ? "History provider timed out" : "No history provider"} for ${context}: ${errorDetail(err)}`);
|
|
1758
|
-
return
|
|
1759
|
-
points: [],
|
|
1760
|
-
resolutionMs: applied,
|
|
1761
|
-
failed: timedOut
|
|
1762
|
-
};
|
|
2212
|
+
return none(timedOut);
|
|
1763
2213
|
}
|
|
1764
|
-
|
|
2214
|
+
const read = async (from, seconds, throughEnd = false) => {
|
|
1765
2215
|
const response = await withTimeout(historyApi.getValues({
|
|
1766
2216
|
context,
|
|
1767
|
-
from: Temporal.Instant.from(new Date(
|
|
2217
|
+
from: Temporal.Instant.from(new Date(from).toISOString()),
|
|
1768
2218
|
to: Temporal.Instant.from(new Date(window.to).toISOString()),
|
|
1769
2219
|
pathSpecs: [{
|
|
1770
2220
|
path: "navigation.position",
|
|
1771
2221
|
aggregate: "first"
|
|
1772
2222
|
}],
|
|
1773
|
-
resolution:
|
|
2223
|
+
resolution: seconds
|
|
1774
2224
|
}), HISTORY_QUERY_TIMEOUT_MS);
|
|
1775
2225
|
const points = [];
|
|
1776
2226
|
for (const row of response?.data ?? []) {
|
|
1777
2227
|
const position = historyRowPosition(row);
|
|
1778
2228
|
if (position) {
|
|
1779
2229
|
const timestamp = historyRowTimestamp(row);
|
|
1780
|
-
const withinEnd = window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to;
|
|
2230
|
+
const withinEnd = window.inclusiveEnd || throughEnd ? timestamp <= window.to : timestamp < window.to;
|
|
1781
2231
|
if (timestamp >= window.from && withinEnd) points.push({
|
|
1782
2232
|
position,
|
|
1783
2233
|
timestamp,
|
|
1784
|
-
span:
|
|
2234
|
+
span: seconds * 1e3
|
|
1785
2235
|
});
|
|
1786
2236
|
}
|
|
1787
2237
|
}
|
|
1788
|
-
|
|
2238
|
+
return points;
|
|
2239
|
+
};
|
|
2240
|
+
try {
|
|
2241
|
+
let from = Math.max(window.from, EXISTENCE_PROBE_FROM_MS);
|
|
2242
|
+
if (!Number.isFinite(window.from)) {
|
|
2243
|
+
const probeSeconds = Math.max(askedSeconds, Math.ceil((window.to - from) / 1e3 / START_PROBE_BUCKETS));
|
|
2244
|
+
const probed = await read(from, probeSeconds, true);
|
|
2245
|
+
if (probed.length === 0) return none(false);
|
|
2246
|
+
const earliest = Math.min(...probed.map(({ timestamp }) => timestamp));
|
|
2247
|
+
from = Math.max(from, earliest - probeSeconds * 1e3);
|
|
2248
|
+
}
|
|
2249
|
+
const providerSeconds = Math.max(askedSeconds, Math.ceil((window.to - from) / 1e3 / HISTORY_BUCKET_BUDGET));
|
|
2250
|
+
const points = await read(from, providerSeconds);
|
|
2251
|
+
if (debug.enabled) debug(`History supplied ${points.length} position(s) for ${context} at ${providerSeconds}s`);
|
|
1789
2252
|
return {
|
|
1790
2253
|
points,
|
|
1791
|
-
resolutionMs:
|
|
2254
|
+
resolutionMs: providerSeconds * 1e3,
|
|
2255
|
+
coarsened: providerSeconds > askedSeconds,
|
|
1792
2256
|
failed: false
|
|
1793
2257
|
};
|
|
1794
2258
|
} catch (err) {
|
|
1795
2259
|
if (debug.enabled) debug(`History unavailable for ${context}: ${errorDetail(err)}`);
|
|
1796
|
-
return
|
|
1797
|
-
points: [],
|
|
1798
|
-
resolutionMs: applied,
|
|
1799
|
-
failed: true
|
|
1800
|
-
};
|
|
2260
|
+
return none(true);
|
|
1801
2261
|
}
|
|
1802
2262
|
}
|
|
2263
|
+
/**
|
|
2264
|
+
* A track from the store and what history holds for it.
|
|
2265
|
+
*
|
|
2266
|
+
* History is the finer source where it reaches, so it wins its buckets —
|
|
2267
|
+
* unless the read was widened to stay within its budget, when it is the
|
|
2268
|
+
* coarser one and only fills what the store lacks.
|
|
2269
|
+
*/
|
|
2270
|
+
var withHistory = (history, stored) => history.points.length === 0 ? stored : (history.coarsened ? fillFromHistory : reconcile)(history.points, stored, history.resolutionMs).positions;
|
|
1803
2271
|
/** Epoch milliseconds for a history row, or 0 when the timestamp is unusable. */
|
|
1804
2272
|
var historyRowTimestamp = (row) => {
|
|
1805
2273
|
const raw = Array.isArray(row) ? row[0] : void 0;
|
|
@@ -1835,6 +2303,45 @@ function ThePlugin(app) {
|
|
|
1835
2303
|
*/
|
|
1836
2304
|
let storageError;
|
|
1837
2305
|
let storeResolution = DEFAULT_RESOLUTION;
|
|
2306
|
+
let binRetentionMs = DEFAULT_RECYCLE_BIN_DAYS * DAY_MS;
|
|
2307
|
+
/**
|
|
2308
|
+
* The deleted spans of recorded tracks by context, loaded once and dropped
|
|
2309
|
+
* whenever a delete, restore or purge changes them. Read for every history
|
|
2310
|
+
* reconciliation, which would otherwise ask the store each time.
|
|
2311
|
+
*/
|
|
2312
|
+
let deletedSpans;
|
|
2313
|
+
/** A vessel's deleted spans, purged ones included. */
|
|
2314
|
+
async function deletedSpansOf(context) {
|
|
2315
|
+
const store = tracks;
|
|
2316
|
+
if (!store?.deletedSpans) return [];
|
|
2317
|
+
const loading = deletedSpans ??= Promise.resolve(store.deletedSpans()).then((spans) => {
|
|
2318
|
+
const byContext = /* @__PURE__ */ new Map();
|
|
2319
|
+
for (const { context: owner, ...span } of spans) byContext.set(owner, [...byContext.get(owner) ?? [], span]);
|
|
2320
|
+
return byContext;
|
|
2321
|
+
});
|
|
2322
|
+
try {
|
|
2323
|
+
return (await loading).get(context) ?? [];
|
|
2324
|
+
} catch (err) {
|
|
2325
|
+
if (deletedSpans === loading) deletedSpans = void 0;
|
|
2326
|
+
throw err;
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
/**
|
|
2330
|
+
* What history holds for a context, without what was deleted from it.
|
|
2331
|
+
*
|
|
2332
|
+
* The plugin cannot delete from a history provider, so a deleted span is
|
|
2333
|
+
* cut out of every answer it gives; the store's own rows went to the
|
|
2334
|
+
* recycle bin and are not read at all.
|
|
2335
|
+
*/
|
|
2336
|
+
async function visibleHistory(context, window, resolutionMs) {
|
|
2337
|
+
const history = await historyPositions(app, context, window, resolutionMs, app.debug);
|
|
2338
|
+
if (history.points.length === 0) return history;
|
|
2339
|
+
const spans = await deletedSpansOf(context);
|
|
2340
|
+
return spans.length === 0 ? history : {
|
|
2341
|
+
...history,
|
|
2342
|
+
points: outsideSpans(history.points, spans)
|
|
2343
|
+
};
|
|
2344
|
+
}
|
|
1838
2345
|
const sourceWatch = new SourceWatch();
|
|
1839
2346
|
let glitchFilter = new GlitchFilter({ maxSpeedKnots: 100 });
|
|
1840
2347
|
let stateGate = new StateGate(app.selfContext, DEFAULT_PAUSE_STATES);
|
|
@@ -1867,6 +2374,8 @@ function ThePlugin(app) {
|
|
|
1867
2374
|
storeResolution = toNumber(config.resolution) ?? DEFAULT_RESOLUTION;
|
|
1868
2375
|
const segmentGapMinutes = toNumber(config.segmentGapMinutes) ?? DEFAULT_SEGMENT_GAP_MINUTES;
|
|
1869
2376
|
segmentGap = segmentGapMinutes > 0 ? segmentGapMinutes * 60 * 1e3 : 0;
|
|
2377
|
+
binRetentionMs = Math.max(1, toNumber(config.recycleBinDays) ?? DEFAULT_RECYCLE_BIN_DAYS) * DAY_MS;
|
|
2378
|
+
deletedSpans = void 0;
|
|
1870
2379
|
glitchFilter = new GlitchFilter({ maxSpeedKnots: toNumber(config.maxSpeedKnots) ?? 100 });
|
|
1871
2380
|
stateGate = new StateGate(app.selfContext, Array.isArray(config.pauseWhenState) ? config.pauseWhenState : DEFAULT_PAUSE_STATES);
|
|
1872
2381
|
const dataDir = app.getDataDirPath?.();
|
|
@@ -1880,6 +2389,7 @@ function ThePlugin(app) {
|
|
|
1880
2389
|
file: join(dataDir, "tracks.db"),
|
|
1881
2390
|
resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,
|
|
1882
2391
|
retention: 0,
|
|
2392
|
+
binRetention: binRetentionMs,
|
|
1883
2393
|
segmentGap
|
|
1884
2394
|
}, app.debug, (error) => {
|
|
1885
2395
|
if (tracks !== starting) return;
|
|
@@ -1933,10 +2443,13 @@ function ThePlugin(app) {
|
|
|
1933
2443
|
segmentGap: () => segmentGap,
|
|
1934
2444
|
contextName: (context) => contextName(context, remembering(() => tracks)),
|
|
1935
2445
|
historyContexts: (window) => historyContextsIn(app, window, app.debug, knownContexts),
|
|
2446
|
+
historyKnows: (context) => historyKnowsContext(app, context, app.debug, knownContexts),
|
|
2447
|
+
deleted: () => {
|
|
2448
|
+
deletedSpans = void 0;
|
|
2449
|
+
},
|
|
1936
2450
|
reconcileWithHistory: async (context, stored, window, resolutionMs) => {
|
|
1937
2451
|
const effective = resolutionMs ?? storeResolution;
|
|
1938
|
-
|
|
1939
|
-
return history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
|
|
2452
|
+
return withHistory(await visibleHistory(context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective), stored);
|
|
1940
2453
|
}
|
|
1941
2454
|
}));
|
|
1942
2455
|
return starting.ready.catch(async () => {
|
|
@@ -1986,8 +2499,8 @@ function ThePlugin(app) {
|
|
|
1986
2499
|
points: [],
|
|
1987
2500
|
known: false
|
|
1988
2501
|
}));
|
|
1989
|
-
const history = await
|
|
1990
|
-
const points =
|
|
2502
|
+
const history = await visibleHistory(context, query.window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effectiveResolution);
|
|
2503
|
+
const points = withHistory(history, stored);
|
|
1991
2504
|
if (points.length === 0) {
|
|
1992
2505
|
if (!(known || await historyKnowsContext(app, context, app.debug, knownContexts))) return;
|
|
1993
2506
|
if (history.failed) throw new HistoryUnavailableError(`History provider could not be read for ${context}`);
|
|
@@ -2145,8 +2658,193 @@ function ThePlugin(app) {
|
|
|
2145
2658
|
router.get("/tracks/*", allTracksHandler);
|
|
2146
2659
|
return router;
|
|
2147
2660
|
},
|
|
2661
|
+
registerWithRouter: function(router) {
|
|
2662
|
+
/** An import as the webapp lists it, in the shape of a v2 track's properties. */
|
|
2663
|
+
const importSummary = (track) => {
|
|
2664
|
+
const name = track.context === void 0 ? void 0 : contextName(track.context, remembering(() => tracks));
|
|
2665
|
+
let pointCount = 0;
|
|
2666
|
+
let from = Infinity;
|
|
2667
|
+
let to = -Infinity;
|
|
2668
|
+
for (const segment of track.segments) for (const { timestamp } of segment) {
|
|
2669
|
+
pointCount++;
|
|
2670
|
+
if (timestamp !== void 0) {
|
|
2671
|
+
from = Math.min(from, timestamp);
|
|
2672
|
+
to = Math.max(to, timestamp);
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
return {
|
|
2676
|
+
id: `${PLUGIN_ID}:${track.id}`,
|
|
2677
|
+
providerId: PLUGIN_ID,
|
|
2678
|
+
...track.name === void 0 ? {} : { name: track.name },
|
|
2679
|
+
...track.context === void 0 ? {} : {
|
|
2680
|
+
context: track.context,
|
|
2681
|
+
isSelf: track.context === app.selfContext
|
|
2682
|
+
},
|
|
2683
|
+
...name === void 0 ? {} : { contextName: name },
|
|
2684
|
+
...from > to ? {} : {
|
|
2685
|
+
from: new Date(from).toISOString(),
|
|
2686
|
+
to: new Date(to).toISOString()
|
|
2687
|
+
},
|
|
2688
|
+
pointCount
|
|
2689
|
+
};
|
|
2690
|
+
};
|
|
2691
|
+
const binEntryJson = (entry) => {
|
|
2692
|
+
const name = entry.context === void 0 ? void 0 : contextName(entry.context, remembering(() => tracks));
|
|
2693
|
+
const iso = (ms) => ms === void 0 ? void 0 : new Date(ms).toISOString();
|
|
2694
|
+
return {
|
|
2695
|
+
id: entry.id,
|
|
2696
|
+
trackId: entry.importId ?? recordedId(entry.context),
|
|
2697
|
+
kind: entry.importId === void 0 ? "recorded" : "imported",
|
|
2698
|
+
...entry.context === void 0 ? {} : {
|
|
2699
|
+
context: entry.context,
|
|
2700
|
+
isSelf: entry.context === app.selfContext
|
|
2701
|
+
},
|
|
2702
|
+
...name === void 0 ? {} : { contextName: name },
|
|
2703
|
+
...entry.name === void 0 ? {} : { name: entry.name },
|
|
2704
|
+
whole: entry.whole,
|
|
2705
|
+
...entry.from === void 0 ? {} : { from: iso(entry.from) },
|
|
2706
|
+
...entry.to === void 0 ? {} : { to: iso(entry.to) },
|
|
2707
|
+
deletedAt: iso(entry.deletedAt),
|
|
2708
|
+
purgeAt: iso(entry.deletedAt + binRetentionMs),
|
|
2709
|
+
pointCount: entry.pointCount
|
|
2710
|
+
};
|
|
2711
|
+
};
|
|
2712
|
+
/** The bin entry an `:id` names, or undefined after answering 400 or 404. */
|
|
2713
|
+
const binStore = (req, res) => {
|
|
2714
|
+
const store = tracks;
|
|
2715
|
+
if (!store?.binEntries || !store.restoreFromBin || !store.purgeFromBin) {
|
|
2716
|
+
notAvailable(res);
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
const id = Number(req.params.id);
|
|
2720
|
+
if (!Number.isSafeInteger(id) || id <= 0) {
|
|
2721
|
+
res.status(400).json({ message: `Recycle bin entry '${req.params.id}' is not an id` });
|
|
2722
|
+
return;
|
|
2723
|
+
}
|
|
2724
|
+
return {
|
|
2725
|
+
store,
|
|
2726
|
+
id
|
|
2727
|
+
};
|
|
2728
|
+
};
|
|
2729
|
+
const failed = (res) => (err) => {
|
|
2730
|
+
app.error(`Recycle bin: ${errorDetail(err)}`);
|
|
2731
|
+
res.status(500).json({ message: "Track storage failed" });
|
|
2732
|
+
};
|
|
2733
|
+
const readable = router.access?.("readonly") ?? router;
|
|
2734
|
+
readable.get("/imports", (_req, res) => {
|
|
2735
|
+
const store = tracks;
|
|
2736
|
+
if (!store?.findImports) {
|
|
2737
|
+
notAvailable(res);
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
Promise.resolve(store.findImports()).then((imports) => res.json(imports.map(importSummary))).catch((err) => {
|
|
2741
|
+
app.error(`Listing imports: ${errorDetail(err)}`);
|
|
2742
|
+
res.status(500).json({ message: "Track storage failed" });
|
|
2743
|
+
});
|
|
2744
|
+
});
|
|
2745
|
+
(router.access?.("readwrite") ?? router).post("/imports", (req, res) => {
|
|
2746
|
+
const store = tracks;
|
|
2747
|
+
if (!store?.storeImport) {
|
|
2748
|
+
notAvailable(res);
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
const storeImport = store.storeImport.bind(store);
|
|
2752
|
+
const context = req.query.self === "true" ? app.selfContext : void 0;
|
|
2753
|
+
(async () => {
|
|
2754
|
+
let text;
|
|
2755
|
+
try {
|
|
2756
|
+
text = await readText(req);
|
|
2757
|
+
} catch (err) {
|
|
2758
|
+
if (err instanceof UploadTooLargeError) res.status(413).json({ message: err.message });
|
|
2759
|
+
else if (err instanceof UploadTypeError) res.status(415).json({ message: err.message });
|
|
2760
|
+
else res.status(400).json({ message: "The file could not be read" });
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
const read = fromGpx(text);
|
|
2764
|
+
if (read.length === 0) {
|
|
2765
|
+
res.status(400).json({ message: "No track found: the file is not GPX, or has no track with points" });
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2768
|
+
let converted;
|
|
2769
|
+
try {
|
|
2770
|
+
converted = read.map((gpx) => fromGpxTrack(gpx, context ?? gpx.context));
|
|
2771
|
+
} catch (err) {
|
|
2772
|
+
res.status(400).json({ message: err instanceof TrackRejectedError ? err.message : String(err) });
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
try {
|
|
2776
|
+
for (const { track } of converted) await storeImport(track);
|
|
2777
|
+
} catch (err) {
|
|
2778
|
+
app.error(`GPX import: ${errorDetail(err)}`);
|
|
2779
|
+
res.status(500).json({ message: "Track storage failed" });
|
|
2780
|
+
return;
|
|
2781
|
+
}
|
|
2782
|
+
res.status(201).json({
|
|
2783
|
+
ids: converted.map(({ track }) => `${PLUGIN_ID}:${track.id}`),
|
|
2784
|
+
skippedPoints: converted.reduce((sum, { skippedPoints }) => sum + skippedPoints, 0)
|
|
2785
|
+
});
|
|
2786
|
+
})();
|
|
2787
|
+
});
|
|
2788
|
+
readable.get("/imports/:id/track.gpx", (req, res) => {
|
|
2789
|
+
const store = tracks;
|
|
2790
|
+
if (!store?.getImport) {
|
|
2791
|
+
notAvailable(res);
|
|
2792
|
+
return;
|
|
2793
|
+
}
|
|
2794
|
+
const id = String(req.params.id);
|
|
2795
|
+
if (!isImportedId(id)) {
|
|
2796
|
+
res.status(404).json({ message: `No imported track '${id}'` });
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
Promise.resolve(store.getImport(id)).then((track) => {
|
|
2800
|
+
if (!track) {
|
|
2801
|
+
res.status(404).json({ message: `No imported track '${id}'` });
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
const label = track.name?.trim() || (track.context === void 0 ? "Imported track" : trackLabel(track.context, app.selfContext, remembering(() => tracks)));
|
|
2805
|
+
const filename = gpxFilename(label);
|
|
2806
|
+
res.type("application/gpx+xml");
|
|
2807
|
+
res.setHeader("Content-Disposition", `attachment; filename="${asciiFilename(filename)}"; filename*=UTF-8''${rfc8187(filename)}`);
|
|
2808
|
+
res.send(toGpx([{
|
|
2809
|
+
name: label,
|
|
2810
|
+
...track.context === void 0 ? {} : { context: track.context },
|
|
2811
|
+
segments: track.segments
|
|
2812
|
+
}]));
|
|
2813
|
+
}).catch((err) => {
|
|
2814
|
+
app.error(`Could not export GPX for ${id}: ${errorDetail(err)}`);
|
|
2815
|
+
if (!res.headersSent) res.status(500).json({ message: `Could not export the track ${id}` });
|
|
2816
|
+
});
|
|
2817
|
+
});
|
|
2818
|
+
router.get("/recycle-bin", (_req, res) => {
|
|
2819
|
+
const store = tracks;
|
|
2820
|
+
if (!store?.binEntries) {
|
|
2821
|
+
notAvailable(res);
|
|
2822
|
+
return;
|
|
2823
|
+
}
|
|
2824
|
+
Promise.resolve(store.binEntries()).then((entries) => res.json(entries.map(binEntryJson))).catch(failed(res));
|
|
2825
|
+
});
|
|
2826
|
+
router.post("/recycle-bin/:id/restore", (req, res) => {
|
|
2827
|
+
const found = binStore(req, res);
|
|
2828
|
+
if (!found) return;
|
|
2829
|
+
Promise.resolve(found.store.restoreFromBin(found.id)).then((result) => {
|
|
2830
|
+
deletedSpans = void 0;
|
|
2831
|
+
if (result === "restored") res.json({});
|
|
2832
|
+
else if (result === "missing") res.status(404).json({ message: "No such entry in the recycle bin" });
|
|
2833
|
+
else res.status(409).json({ message: "The whole track is in the recycle bin too; restore it first" });
|
|
2834
|
+
}).catch(failed(res));
|
|
2835
|
+
});
|
|
2836
|
+
router.delete("/recycle-bin/:id", (req, res) => {
|
|
2837
|
+
const found = binStore(req, res);
|
|
2838
|
+
if (!found) return;
|
|
2839
|
+
Promise.resolve(found.store.purgeFromBin(found.id)).then((purged) => {
|
|
2840
|
+
if (purged) res.json({});
|
|
2841
|
+
else res.status(404).json({ message: "No such entry in the recycle bin" });
|
|
2842
|
+
}).catch(failed(res));
|
|
2843
|
+
});
|
|
2844
|
+
return router;
|
|
2845
|
+
},
|
|
2148
2846
|
getTracks: () => tracks,
|
|
2149
|
-
id:
|
|
2847
|
+
id: PLUGIN_ID,
|
|
2150
2848
|
name: "Tracks",
|
|
2151
2849
|
enabledByDefault: true,
|
|
2152
2850
|
description: "Record vessel tracks to SQLite and serve them through the track API",
|
|
@@ -2172,6 +2870,13 @@ function ThePlugin(app) {
|
|
|
2172
2870
|
description: "A gap longer than this starts a new track segment, so a stop overnight or a spell out of AIS range does not draw a straight line across it. 0 (the default) returns the track as a single line, as before. Note that slow-updating AIS targets can legitimately go many minutes between fixes, so a low value will fragment their tracks.",
|
|
2173
2871
|
default: DEFAULT_SEGMENT_GAP_MINUTES
|
|
2174
2872
|
},
|
|
2873
|
+
recycleBinDays: {
|
|
2874
|
+
type: "integer",
|
|
2875
|
+
minimum: 1,
|
|
2876
|
+
title: "Days to keep a deleted track in the recycle bin",
|
|
2877
|
+
description: "A deleted track, or part of one, can be restored from the recycle bin in the Tracks webapp until it is purged after this many days.",
|
|
2878
|
+
default: DEFAULT_RECYCLE_BIN_DAYS
|
|
2879
|
+
},
|
|
2175
2880
|
maxSpeedKnots: {
|
|
2176
2881
|
type: "integer",
|
|
2177
2882
|
title: "Discard positions implying a speed above this (knots)",
|