@signalk/tracks-plugin 3.0.0-beta.1 → 3.0.0-beta.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 +27 -26
- package/dist/e2e.test-utils.d.ts +3 -3
- package/dist/harness.test-utils.d.ts +8 -1
- package/dist/index.d.ts +5 -29
- package/dist/index.js +234 -335
- package/dist/index.js.map +1 -1
- package/dist/sqliteStore.d.ts +9 -5
- package/dist/stateGate.d.ts +0 -4
- package/dist/store.d.ts +28 -9
- package/dist/trackProvider.d.ts +15 -0
- package/dist/tracks.d.ts +7 -4
- package/dist/utils.d.ts +9 -0
- package/package.json +1 -1
- package/dist/source.test.d.ts +0 -1
- /package/dist/{bootstrap.test.d.ts → historyRow.test.d.ts} +0 -0
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Temporal } from "@js-temporal/polyfill";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { BehaviorSubject, ReplaySubject, Subject, combineLatest, connectable, firstValueFrom } from "rxjs";
|
|
4
|
-
import { map, scan, startWith, throttleTime } from "rxjs/operators";
|
|
5
3
|
import { Type } from "typebox";
|
|
6
4
|
import { Value } from "typebox/value";
|
|
7
5
|
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { BehaviorSubject, ReplaySubject, Subject, combineLatest, connectable, firstValueFrom } from "rxjs";
|
|
7
|
+
import { map, scan, startWith, throttleTime } from "rxjs/operators";
|
|
8
8
|
//#region \0rolldown/runtime.js
|
|
9
9
|
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
10
10
|
//#endregion
|
|
@@ -358,142 +358,26 @@ function createMatcher(params, selfPosition, debug) {
|
|
|
358
358
|
}
|
|
359
359
|
return () => true;
|
|
360
360
|
}
|
|
361
|
-
//#endregion
|
|
362
|
-
//#region src/tracks.ts
|
|
363
361
|
/**
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
362
|
+
* A history row is `[timestamp, position]`. Both encodings are legal: the
|
|
363
|
+
* History API types a value as `unknown` and its own comment names "objects
|
|
364
|
+
* (like navigation.position)" alongside primitives, so signalk-questdb returns
|
|
365
|
+
* `{latitude, longitude}` and signalk-to-influxdb2 returns `[lon, lat]`.
|
|
366
|
+
* Accept both — neither is an error, so rejecting one silently yields an empty
|
|
367
|
+
* track rather than a failure anybody can see.
|
|
367
368
|
*/
|
|
368
|
-
var
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
this.config = config;
|
|
375
|
-
this.debug = debug;
|
|
376
|
-
}
|
|
377
|
-
newPosition(context, position, timestamp) {
|
|
378
|
-
this.getAccumulator(context)?.nextLatLngTuple(position, timestamp);
|
|
379
|
-
}
|
|
380
|
-
initialTrack(context, track, timestamps) {
|
|
381
|
-
this.getAccumulator(context)?.setInitialTrack(track, timestamps);
|
|
382
|
-
}
|
|
383
|
-
getAccumulator(context, createIfMissing = true) {
|
|
384
|
-
if (context.indexOf("vessels.") === -1 && context.indexOf("aircraft.") === -1) return;
|
|
385
|
-
let result = this.tracks[context];
|
|
386
|
-
if (!result && createIfMissing) {
|
|
387
|
-
const accParams = this.config.fetchInitialTrack ? {
|
|
388
|
-
...this.config,
|
|
389
|
-
fetchTrackFor: context
|
|
390
|
-
} : { ...this.config };
|
|
391
|
-
result = this.tracks[context] = new TrackAccumulator(accParams);
|
|
392
|
-
}
|
|
393
|
-
return result;
|
|
394
|
-
}
|
|
395
|
-
get(context, window) {
|
|
396
|
-
return this.getTimed(context, window).then((points) => points.map(({ position }) => position));
|
|
397
|
-
}
|
|
398
|
-
/**
|
|
399
|
-
* Track points with their timestamps, optionally narrowed to a time window.
|
|
400
|
-
*
|
|
401
|
-
* Rejects with EmptyError if the stream completes without emitting; the route
|
|
402
|
-
* handlers turn that into the same 404 as an unknown context.
|
|
403
|
-
*/
|
|
404
|
-
getTimed(context, window) {
|
|
405
|
-
const accumulator = this.getAccumulator(context, false);
|
|
406
|
-
if (!accumulator) return Promise.reject(/* @__PURE__ */ new Error(`No track accumulator for ${context}`));
|
|
407
|
-
return firstValueFrom(accumulator.timedTrack).then((points) => window ? points.filter(({ timestamp }) => timestamp >= window.from && (window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to)) : points);
|
|
408
|
-
}
|
|
409
|
-
getAllTracks(query) {
|
|
410
|
-
return Promise.all(Object.keys(this.tracks).map((context) => this.getTimed(context, query?.window).then((points) => ({
|
|
411
|
-
context,
|
|
412
|
-
track: thin(points, query?.resolution).map(({ position }) => position)
|
|
413
|
-
}))));
|
|
414
|
-
}
|
|
415
|
-
async getFilteredTracks(params, selfPosition, debug, query) {
|
|
416
|
-
const timed = await this.getFilteredTimedTracks(params, selfPosition, debug, query);
|
|
417
|
-
return Object.fromEntries(Object.entries(timed).map(([context, points]) => [context, points.map(({ position }) => position)]));
|
|
418
|
-
}
|
|
419
|
-
/**
|
|
420
|
-
* The same filtering as `getFilteredTracks`, keeping each point's timestamp.
|
|
421
|
-
*
|
|
422
|
-
* The untimed form is derived from this one so the two cannot disagree about
|
|
423
|
-
* which tracks match — the filter runs once, here.
|
|
424
|
-
*/
|
|
425
|
-
async getFilteredTimedTracks(params, selfPosition, debug, query) {
|
|
426
|
-
this.debug(params);
|
|
427
|
-
this.debug("Self position", selfPosition);
|
|
428
|
-
const matcher = createMatcher(params, selfPosition, debug);
|
|
429
|
-
const contexts = Object.keys(this.tracks);
|
|
430
|
-
return (await Promise.all(contexts.map((context) => this.getTimed(context, query?.window).then((points) => ({
|
|
431
|
-
context,
|
|
432
|
-
points: thin(points, query?.resolution)
|
|
433
|
-
}))))).reduce((acc, { context, points }) => {
|
|
434
|
-
if (matcher(points.map(({ position }) => position))) acc[context] = points;
|
|
435
|
-
return acc;
|
|
436
|
-
}, {});
|
|
437
|
-
}
|
|
438
|
-
prune(maxAge) {
|
|
439
|
-
const cutoff = Date.now() - maxAge;
|
|
440
|
-
const deleted = [];
|
|
441
|
-
Object.entries(this.tracks).forEach(([key, value]) => {
|
|
442
|
-
if (value.latestLatLngTuple < cutoff) {
|
|
443
|
-
delete this.tracks[key];
|
|
444
|
-
deleted.push(key);
|
|
445
|
-
}
|
|
446
|
-
});
|
|
447
|
-
if (this.debug.enabled) this.debug(`deleted tracks for ${deleted.join(", ")}`);
|
|
448
|
-
}
|
|
449
|
-
};
|
|
450
|
-
var TrackAccumulator = class {
|
|
451
|
-
initialTrack = new BehaviorSubject([]);
|
|
452
|
-
input = new Subject();
|
|
453
|
-
latestLatLngTuple = 0;
|
|
454
|
-
accumulatedTrack;
|
|
455
|
-
/** Timestamped points, used to answer time-window queries. */
|
|
456
|
-
timedTrack;
|
|
457
|
-
/** Positions only — the long-standing public shape. */
|
|
458
|
-
track;
|
|
459
|
-
constructor({ resolution, pointsToKeep, fetchTrackFor }) {
|
|
460
|
-
this.accumulatedTrack = connectable(this.input.pipe(throttleTime(resolution), scan((acc, point) => {
|
|
461
|
-
acc.push(point);
|
|
462
|
-
return acc.slice(Math.max(0, acc.length - pointsToKeep));
|
|
463
|
-
}, []), startWith([])), {
|
|
464
|
-
connector: () => new ReplaySubject(1),
|
|
465
|
-
resetOnDisconnect: false
|
|
466
|
-
});
|
|
467
|
-
this.timedTrack = combineLatest([this.initialTrack, this.accumulatedTrack]).pipe(map(([initialTrack, accumulatedTrack]) => [...initialTrack, ...accumulatedTrack]));
|
|
468
|
-
this.track = this.timedTrack.pipe(map((points) => points.map(({ position }) => position)));
|
|
469
|
-
this.accumulatedTrack.connect();
|
|
470
|
-
if (fetchTrackFor) fetchTrack(fetchTrackFor).then((trackGEOJson) => {
|
|
471
|
-
const coordinates = trackGEOJson?.coordinates?.[0];
|
|
472
|
-
if (coordinates) this.initialTrack.next(coordinates.map((position) => ({
|
|
473
|
-
position,
|
|
474
|
-
timestamp: 0
|
|
475
|
-
})));
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
nextLatLngTuple(position, timestamp = Date.now()) {
|
|
479
|
-
this.input.next({
|
|
480
|
-
position,
|
|
481
|
-
timestamp
|
|
482
|
-
});
|
|
483
|
-
this.latestLatLngTuple = timestamp;
|
|
369
|
+
var historyRowPosition = (row) => {
|
|
370
|
+
if (!Array.isArray(row) || row.length < 2) return;
|
|
371
|
+
const value = row[1];
|
|
372
|
+
if (Array.isArray(value) && value.length === 2 && Number.isFinite(value[0]) && Number.isFinite(value[1])) {
|
|
373
|
+
const [longitude, latitude] = value;
|
|
374
|
+
return [latitude, longitude];
|
|
484
375
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
timestamp: timestamps?.[i] ?? 0
|
|
489
|
-
})));
|
|
376
|
+
if (value && typeof value === "object" && "latitude" in value && "longitude" in value) {
|
|
377
|
+
const { latitude, longitude } = value;
|
|
378
|
+
if (Number.isFinite(latitude) && Number.isFinite(longitude)) return [latitude, longitude];
|
|
490
379
|
}
|
|
491
380
|
};
|
|
492
|
-
var fetchTrack = (context) => {
|
|
493
|
-
const contextParts = context.split(".");
|
|
494
|
-
if (contextParts[0] !== "vessels") return Promise.resolve({});
|
|
495
|
-
return fetch(`/signalk/v1/api/vessels/${contextParts[1]}/track`, { credentials: "include" }).then((r) => r.status === 200 ? r.json() : Promise.resolve({}));
|
|
496
|
-
};
|
|
497
381
|
//#endregion
|
|
498
382
|
//#region src/trackProvider.ts
|
|
499
383
|
/** v2 sends `[west, south, east, north]`; a bounds here is `[lat, lng]` corners. */
|
|
@@ -614,7 +498,10 @@ var longitudeSpan = (longitudes) => {
|
|
|
614
498
|
function createTrackProvider(deps) {
|
|
615
499
|
const matching = async (query) => {
|
|
616
500
|
const store = deps.store();
|
|
617
|
-
if (!store) return
|
|
501
|
+
if (!store) return {
|
|
502
|
+
tracks: /* @__PURE__ */ new Map(),
|
|
503
|
+
resolution: void 0
|
|
504
|
+
};
|
|
618
505
|
const window = toTimeWindow(query);
|
|
619
506
|
const resolution = query.resolution ? totalMilliseconds(query.resolution) : void 0;
|
|
620
507
|
const wanted = query.contexts?.length ? new Set(query.contexts.map(resolveSelf(deps.selfContext()))) : void 0;
|
|
@@ -627,19 +514,22 @@ function createTrackProvider(deps) {
|
|
|
627
514
|
...resolution === void 0 ? {} : { resolution }
|
|
628
515
|
});
|
|
629
516
|
const result = /* @__PURE__ */ new Map();
|
|
630
|
-
|
|
631
|
-
|
|
517
|
+
const contexts = Object.entries(collection).filter(([context]) => !wanted || wanted.has(context));
|
|
518
|
+
const reconciled = await Promise.all(contexts.map(async ([context, stored]) => [context, await deps.reconcileWithHistory(context, stored, window, resolution)]));
|
|
519
|
+
for (const [context, points] of reconciled) {
|
|
632
520
|
if (points.length === 0) continue;
|
|
633
521
|
result.set(context, points);
|
|
634
522
|
}
|
|
635
|
-
return
|
|
523
|
+
return {
|
|
524
|
+
tracks: result,
|
|
525
|
+
resolution
|
|
526
|
+
};
|
|
636
527
|
};
|
|
637
528
|
return {
|
|
638
529
|
async getTracks(query) {
|
|
639
|
-
const matched = await matching(query);
|
|
530
|
+
const { tracks: matched, resolution: requested } = await matching(query);
|
|
640
531
|
const gap = deps.segmentGap();
|
|
641
532
|
const selfContext = deps.selfContext();
|
|
642
|
-
const requested = query.resolution ? totalMilliseconds(query.resolution) : void 0;
|
|
643
533
|
const features = [];
|
|
644
534
|
for (const [context, all] of matched) {
|
|
645
535
|
const { points, resolution: appliedMs } = query.maxPoints === void 0 ? {
|
|
@@ -673,7 +563,7 @@ function createTrackProvider(deps) {
|
|
|
673
563
|
};
|
|
674
564
|
},
|
|
675
565
|
async getTrackContexts(query) {
|
|
676
|
-
return [...(await matching(query)).keys()];
|
|
566
|
+
return [...(await matching(query)).tracks.keys()];
|
|
677
567
|
}
|
|
678
568
|
};
|
|
679
569
|
}
|
|
@@ -12058,16 +11948,20 @@ var SqliteTrackStore = class {
|
|
|
12058
11948
|
}))));
|
|
12059
11949
|
}
|
|
12060
11950
|
/**
|
|
12061
|
-
* Tracks
|
|
11951
|
+
* Tracks matching the spatial predicate, which depends on who is asking.
|
|
11952
|
+
*
|
|
11953
|
+
* `intersects: false` — the v1 routes — matches a track's *last* position:
|
|
11954
|
+
* "which vessels are near me now". `intersects: true` — the v2 Track API —
|
|
11955
|
+
* matches a track that passed through the box at any point in the window,
|
|
11956
|
+
* including one that has since left.
|
|
12062
11957
|
*
|
|
12063
11958
|
* Two filters run, and they are not redundant despite both testing bounds:
|
|
12064
11959
|
*
|
|
12065
11960
|
* - `contextsInBounds` asks the cell index which contexts have *any* position
|
|
12066
11961
|
* near the box. It is a cheap prefilter that keeps the query off every row
|
|
12067
11962
|
* in the table, and it decides nothing on its own.
|
|
12068
|
-
* - `matcher` — the same predicate the in-memory store uses — then
|
|
12069
|
-
*
|
|
12070
|
-
* "in this box". That test is authoritative.
|
|
11963
|
+
* - `matcher` — the same predicate the in-memory store uses — then applies
|
|
11964
|
+
* whichever rule the caller asked for. That test is authoritative.
|
|
12071
11965
|
*
|
|
12072
11966
|
* Because `matcher` has the final say, dropping the exact-bounds check inside
|
|
12073
11967
|
* `contextsInBounds` does not change any result; it only widens the candidate
|
|
@@ -12131,10 +12025,16 @@ var SqliteTrackStore = class {
|
|
|
12131
12025
|
if (current.length > 0) result.push(current);
|
|
12132
12026
|
return result;
|
|
12133
12027
|
}
|
|
12134
|
-
prune(maxAge) {
|
|
12028
|
+
prune(maxAge, keep) {
|
|
12135
12029
|
const cutoff = Date.now() - maxAge;
|
|
12136
|
-
this.db.prepare("
|
|
12137
|
-
|
|
12030
|
+
const dropped = this.db.prepare("SELECT context FROM positions GROUP BY context HAVING MAX(timestamp) < ? AND context IS NOT ?").all(cutoff, keep ?? null);
|
|
12031
|
+
this.db.prepare("DELETE FROM positions WHERE context IN (SELECT context FROM positions GROUP BY context HAVING MAX(timestamp) < ?) AND context IS NOT ?").run(cutoff, keep ?? null);
|
|
12032
|
+
for (const { context } of dropped) this.lastStored.delete(context);
|
|
12033
|
+
if (this.retention > 0) {
|
|
12034
|
+
const oldest = Date.now() - this.retention;
|
|
12035
|
+
if (keep === void 0) this.db.prepare("DELETE FROM positions WHERE timestamp < ?").run(oldest);
|
|
12036
|
+
else this.db.prepare("DELETE FROM positions WHERE timestamp < ? AND context IS ?").run(oldest, keep);
|
|
12037
|
+
}
|
|
12138
12038
|
}
|
|
12139
12039
|
close() {
|
|
12140
12040
|
this.db.close();
|
|
@@ -12368,11 +12268,11 @@ var SourceWatch = class {
|
|
|
12368
12268
|
};
|
|
12369
12269
|
//#endregion
|
|
12370
12270
|
//#region src/stateGate.ts
|
|
12371
|
-
|
|
12372
|
-
|
|
12373
|
-
|
|
12374
|
-
|
|
12375
|
-
|
|
12271
|
+
var DEFAULT_PAUSE_STATES = [
|
|
12272
|
+
"moored",
|
|
12273
|
+
"not-under-way",
|
|
12274
|
+
"aground"
|
|
12275
|
+
];
|
|
12376
12276
|
/**
|
|
12377
12277
|
* The states a boat is plausibly parked in, offered in the plugin UI.
|
|
12378
12278
|
*
|
|
@@ -12437,25 +12337,150 @@ var StateGate = class {
|
|
|
12437
12337
|
}
|
|
12438
12338
|
};
|
|
12439
12339
|
//#endregion
|
|
12440
|
-
//#region src/
|
|
12340
|
+
//#region src/tracks.ts
|
|
12441
12341
|
/**
|
|
12442
|
-
*
|
|
12342
|
+
* The in-memory store: a bounded sliding window of positions per context, held
|
|
12343
|
+
* in RxJS accumulators and lost on restart.
|
|
12443
12344
|
*
|
|
12444
|
-
*
|
|
12445
|
-
*
|
|
12446
|
-
*
|
|
12447
|
-
* `false` still means "do not touch the History API", and everything else
|
|
12448
|
-
* lands on the old default of `history`.
|
|
12345
|
+
* No longer used by the plugin, which always records to SQLite. It remains as
|
|
12346
|
+
* the client-side `TrackAccumulator` this package exports, where a browser
|
|
12347
|
+
* holding one vessel's recent track in memory is exactly the right shape.
|
|
12449
12348
|
*/
|
|
12450
|
-
var
|
|
12451
|
-
|
|
12452
|
-
|
|
12349
|
+
var Tracks = class {
|
|
12350
|
+
tracks = {};
|
|
12351
|
+
debug;
|
|
12352
|
+
config;
|
|
12353
|
+
constructor(config, debug) {
|
|
12354
|
+
if (debug.enabled) debug(JSON.stringify(config));
|
|
12355
|
+
this.config = config;
|
|
12356
|
+
this.debug = debug;
|
|
12357
|
+
}
|
|
12358
|
+
newPosition(context, position, timestamp) {
|
|
12359
|
+
this.getAccumulator(context)?.nextLatLngTuple(position, timestamp);
|
|
12360
|
+
}
|
|
12361
|
+
initialTrack(context, track, timestamps) {
|
|
12362
|
+
this.getAccumulator(context)?.setInitialTrack(track, timestamps);
|
|
12363
|
+
}
|
|
12364
|
+
getAccumulator(context, createIfMissing = true) {
|
|
12365
|
+
if (context.indexOf("vessels.") === -1 && context.indexOf("aircraft.") === -1) return;
|
|
12366
|
+
let result = this.tracks[context];
|
|
12367
|
+
if (!result && createIfMissing) {
|
|
12368
|
+
const accParams = this.config.fetchInitialTrack ? {
|
|
12369
|
+
...this.config,
|
|
12370
|
+
fetchTrackFor: context
|
|
12371
|
+
} : { ...this.config };
|
|
12372
|
+
result = this.tracks[context] = new TrackAccumulator(accParams);
|
|
12373
|
+
}
|
|
12374
|
+
return result;
|
|
12375
|
+
}
|
|
12376
|
+
get(context, window) {
|
|
12377
|
+
return this.getTimed(context, window).then((points) => points.map(({ position }) => position));
|
|
12378
|
+
}
|
|
12379
|
+
/**
|
|
12380
|
+
* Track points with their timestamps, optionally narrowed to a time window.
|
|
12381
|
+
*
|
|
12382
|
+
* Rejects with EmptyError if the stream completes without emitting; the route
|
|
12383
|
+
* handlers turn that into the same 404 as an unknown context.
|
|
12384
|
+
*/
|
|
12385
|
+
getTimed(context, window) {
|
|
12386
|
+
const accumulator = this.getAccumulator(context, false);
|
|
12387
|
+
if (!accumulator) return Promise.reject(/* @__PURE__ */ new Error(`No track accumulator for ${context}`));
|
|
12388
|
+
return firstValueFrom(accumulator.timedTrack).then((points) => window ? points.filter(({ timestamp }) => timestamp >= window.from && (window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to)) : points);
|
|
12389
|
+
}
|
|
12390
|
+
getAllTracks(query) {
|
|
12391
|
+
return Promise.all(Object.keys(this.tracks).map((context) => this.getTimed(context, query?.window).then((points) => ({
|
|
12392
|
+
context,
|
|
12393
|
+
track: thin(points, query?.resolution).map(({ position }) => position)
|
|
12394
|
+
}))));
|
|
12395
|
+
}
|
|
12396
|
+
async getFilteredTracks(params, selfPosition, debug, query) {
|
|
12397
|
+
const timed = await this.getFilteredTimedTracks(params, selfPosition, debug, query);
|
|
12398
|
+
return Object.fromEntries(Object.entries(timed).map(([context, points]) => [context, points.map(({ position }) => position)]));
|
|
12399
|
+
}
|
|
12400
|
+
/**
|
|
12401
|
+
* The same filtering as `getFilteredTracks`, keeping each point's timestamp.
|
|
12402
|
+
*
|
|
12403
|
+
* The untimed form is derived from this one so the two cannot disagree about
|
|
12404
|
+
* which tracks match — the filter runs once, here.
|
|
12405
|
+
*/
|
|
12406
|
+
async getFilteredTimedTracks(params, selfPosition, debug, query) {
|
|
12407
|
+
this.debug(params);
|
|
12408
|
+
this.debug("Self position", selfPosition);
|
|
12409
|
+
const matcher = createMatcher(params, selfPosition, debug);
|
|
12410
|
+
const contexts = Object.keys(this.tracks);
|
|
12411
|
+
return (await Promise.all(contexts.map((context) => this.getTimed(context, query?.window).then((points) => ({
|
|
12412
|
+
context,
|
|
12413
|
+
points: thin(points, query?.resolution)
|
|
12414
|
+
}))))).reduce((acc, { context, points }) => {
|
|
12415
|
+
if (matcher(points.map(({ position }) => position))) acc[context] = points;
|
|
12416
|
+
return acc;
|
|
12417
|
+
}, {});
|
|
12418
|
+
}
|
|
12419
|
+
prune(maxAge, keep) {
|
|
12420
|
+
const cutoff = Date.now() - maxAge;
|
|
12421
|
+
const deleted = [];
|
|
12422
|
+
Object.entries(this.tracks).forEach(([key, value]) => {
|
|
12423
|
+
if (key !== keep && value.latestLatLngTuple < cutoff) {
|
|
12424
|
+
delete this.tracks[key];
|
|
12425
|
+
deleted.push(key);
|
|
12426
|
+
}
|
|
12427
|
+
});
|
|
12428
|
+
if (this.debug.enabled) this.debug(`deleted tracks for ${deleted.join(", ")}`);
|
|
12429
|
+
}
|
|
12453
12430
|
};
|
|
12431
|
+
var TrackAccumulator = class {
|
|
12432
|
+
initialTrack = new BehaviorSubject([]);
|
|
12433
|
+
input = new Subject();
|
|
12434
|
+
latestLatLngTuple = 0;
|
|
12435
|
+
accumulatedTrack;
|
|
12436
|
+
/** Timestamped points, used to answer time-window queries. */
|
|
12437
|
+
timedTrack;
|
|
12438
|
+
/** Positions only — the long-standing public shape. */
|
|
12439
|
+
track;
|
|
12440
|
+
constructor({ resolution, pointsToKeep, fetchTrackFor }) {
|
|
12441
|
+
this.accumulatedTrack = connectable(this.input.pipe(throttleTime(resolution), scan((acc, point) => {
|
|
12442
|
+
acc.push(point);
|
|
12443
|
+
return acc.slice(Math.max(0, acc.length - pointsToKeep));
|
|
12444
|
+
}, []), startWith([])), {
|
|
12445
|
+
connector: () => new ReplaySubject(1),
|
|
12446
|
+
resetOnDisconnect: false
|
|
12447
|
+
});
|
|
12448
|
+
this.timedTrack = combineLatest([this.initialTrack, this.accumulatedTrack]).pipe(map(([initialTrack, accumulatedTrack]) => [...initialTrack, ...accumulatedTrack]));
|
|
12449
|
+
this.track = this.timedTrack.pipe(map((points) => points.map(({ position }) => position)));
|
|
12450
|
+
this.accumulatedTrack.connect();
|
|
12451
|
+
if (fetchTrackFor) fetchTrack(fetchTrackFor).then((trackGEOJson) => {
|
|
12452
|
+
const coordinates = trackGEOJson?.coordinates?.[0];
|
|
12453
|
+
if (coordinates) this.initialTrack.next(coordinates.map((position) => ({
|
|
12454
|
+
position,
|
|
12455
|
+
timestamp: 0
|
|
12456
|
+
})));
|
|
12457
|
+
});
|
|
12458
|
+
}
|
|
12459
|
+
nextLatLngTuple(position, timestamp = Date.now()) {
|
|
12460
|
+
this.input.next({
|
|
12461
|
+
position,
|
|
12462
|
+
timestamp
|
|
12463
|
+
});
|
|
12464
|
+
this.latestLatLngTuple = timestamp;
|
|
12465
|
+
}
|
|
12466
|
+
setInitialTrack(track, timestamps) {
|
|
12467
|
+
this.initialTrack.next(track.map((position, i) => ({
|
|
12468
|
+
position,
|
|
12469
|
+
timestamp: timestamps?.[i] ?? 0
|
|
12470
|
+
})));
|
|
12471
|
+
}
|
|
12472
|
+
};
|
|
12473
|
+
var fetchTrack = (context) => {
|
|
12474
|
+
const contextParts = context.split(".");
|
|
12475
|
+
if (contextParts[0] !== "vessels") return Promise.resolve({});
|
|
12476
|
+
return fetch(`/signalk/v1/api/vessels/${contextParts[1]}/track`, { credentials: "include" }).then((r) => r.status === 200 ? r.json() : Promise.resolve({}));
|
|
12477
|
+
};
|
|
12478
|
+
//#endregion
|
|
12479
|
+
//#region src/index.ts
|
|
12454
12480
|
var toLngLat = ([lat, lng]) => [lng, lat];
|
|
12455
12481
|
var DEFAULT_RESOLUTION = 6e4;
|
|
12456
|
-
var
|
|
12457
|
-
var
|
|
12458
|
-
var DEFAULT_MAX_RADIUS = 5e4;
|
|
12482
|
+
var DEFAULT_AIS_RETENTION_DAYS = 30;
|
|
12483
|
+
var PRUNE_INTERVAL_MS = 36e5;
|
|
12459
12484
|
var DEFAULT_SEGMENT_GAP_MINUTES = 0;
|
|
12460
12485
|
var SOURCE_STATUS_INTERVAL_MS = 3e4;
|
|
12461
12486
|
/**
|
|
@@ -12477,10 +12502,6 @@ var HISTORY_QUERY_TIMEOUT_MS = 5e3;
|
|
|
12477
12502
|
* `duration` or `timespan` reaches further.
|
|
12478
12503
|
*/
|
|
12479
12504
|
var WINDOWLESS_HISTORY_SPAN_MS = 864e5;
|
|
12480
|
-
var BOOTSTRAP_INITIAL_DELAY = 5e3;
|
|
12481
|
-
var BOOTSTRAP_RETRY_DELAY = 15e3;
|
|
12482
|
-
var BOOTSTRAP_MAX_ATTEMPTS = 18;
|
|
12483
|
-
var BOOTSTRAP_MAX_NO_PROVIDER = 3;
|
|
12484
12505
|
/**
|
|
12485
12506
|
* Config values arrive from the plugin UI as numbers, but a hand-edited
|
|
12486
12507
|
* settings file can supply strings. Accept both, reject anything non-finite so
|
|
@@ -12497,27 +12518,7 @@ var notAvailable = (res) => {
|
|
|
12497
12518
|
res.status(404);
|
|
12498
12519
|
res.json({ message: "Tracks API not available because tracks plugin is not enabled" });
|
|
12499
12520
|
};
|
|
12500
|
-
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
12501
12521
|
var errorDetail = (err) => err instanceof Error && err.stack ? err.stack : String(err);
|
|
12502
|
-
var isNoProviderError = (err) => {
|
|
12503
|
-
const text = String(err);
|
|
12504
|
-
return text.includes("No history") && text.includes("provider");
|
|
12505
|
-
};
|
|
12506
|
-
/**
|
|
12507
|
-
* A history row is `[timestamp, position]`. Providers disagree on how the
|
|
12508
|
-
* position is encoded: signalk-questdb returns `{latitude, longitude}`, while
|
|
12509
|
-
* a `[lon, lat]` pair is the shape the History API's own docs describe. Accept
|
|
12510
|
-
* both — rejecting either silently bootstraps an empty track.
|
|
12511
|
-
*/
|
|
12512
|
-
var historyRowPosition = (row) => {
|
|
12513
|
-
if (!Array.isArray(row) || row.length < 2) return;
|
|
12514
|
-
const value = row[1];
|
|
12515
|
-
if (Array.isArray(value) && value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number") return [value[1], value[0]];
|
|
12516
|
-
if (value && typeof value === "object" && "latitude" in value && "longitude" in value) {
|
|
12517
|
-
const { latitude, longitude } = value;
|
|
12518
|
-
if (typeof latitude === "number" && typeof longitude === "number") return [latitude, longitude];
|
|
12519
|
-
}
|
|
12520
|
-
};
|
|
12521
12522
|
/**
|
|
12522
12523
|
* A window covering everything the store holds, up to now.
|
|
12523
12524
|
*
|
|
@@ -12617,92 +12618,6 @@ var historyRowTimestamp = (row) => {
|
|
|
12617
12618
|
const parsed = Date.parse(raw);
|
|
12618
12619
|
return Number.isNaN(parsed) ? 0 : parsed;
|
|
12619
12620
|
};
|
|
12620
|
-
async function bootstrapSelfTrack(app, tracks, config, glitchFilter) {
|
|
12621
|
-
const { debug } = app;
|
|
12622
|
-
const getHistoryApi = app.getHistoryApi;
|
|
12623
|
-
if (!getHistoryApi) {
|
|
12624
|
-
debug("getHistoryApi not available on server, skipping track bootstrap");
|
|
12625
|
-
return;
|
|
12626
|
-
}
|
|
12627
|
-
if (!app.selfContext) {
|
|
12628
|
-
debug("selfContext not available, skipping track bootstrap");
|
|
12629
|
-
return;
|
|
12630
|
-
}
|
|
12631
|
-
const configuredProvider = app.config?.settings?.historyApi?.defaultProvider;
|
|
12632
|
-
const resolution = toNumber(config.resolution) ?? DEFAULT_RESOLUTION;
|
|
12633
|
-
const timespanMs = resolution * (toNumber(config.pointsToKeep) ?? DEFAULT_POINTS_TO_KEEP);
|
|
12634
|
-
const resolutionSecs = Math.max(1, Math.round(resolution / 1e3));
|
|
12635
|
-
const timespanMinutes = Math.round(timespanMs / 1e3 / 60);
|
|
12636
|
-
debug(`Track bootstrap: requesting ${timespanMinutes} minutes of history at ${resolutionSecs}s resolution (max ${BOOTSTRAP_MAX_ATTEMPTS} attempts)`);
|
|
12637
|
-
let noProviderCount = 0;
|
|
12638
|
-
for (let attempt = 1; attempt <= BOOTSTRAP_MAX_ATTEMPTS; attempt++) {
|
|
12639
|
-
const delay = attempt === 1 ? BOOTSTRAP_INITIAL_DELAY : BOOTSTRAP_RETRY_DELAY;
|
|
12640
|
-
debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}, waiting ${delay / 1e3}s...`);
|
|
12641
|
-
await sleep(delay);
|
|
12642
|
-
try {
|
|
12643
|
-
const historyApi = await getHistoryApi(configuredProvider);
|
|
12644
|
-
noProviderCount = 0;
|
|
12645
|
-
const to = /* @__PURE__ */ new Date();
|
|
12646
|
-
const from = new Date(to.getTime() - timespanMs);
|
|
12647
|
-
const response = await historyApi.getValues({
|
|
12648
|
-
context: app.selfContext,
|
|
12649
|
-
from: Temporal.Instant.from(from.toISOString()),
|
|
12650
|
-
to: Temporal.Instant.from(to.toISOString()),
|
|
12651
|
-
pathSpecs: [{
|
|
12652
|
-
path: "navigation.position",
|
|
12653
|
-
aggregate: "first"
|
|
12654
|
-
}],
|
|
12655
|
-
resolution: resolutionSecs
|
|
12656
|
-
});
|
|
12657
|
-
if (response?.data && response.data.length > 0) {
|
|
12658
|
-
const positions = [];
|
|
12659
|
-
const timestamps = [];
|
|
12660
|
-
let dropped = 0;
|
|
12661
|
-
for (const row of response.data) {
|
|
12662
|
-
const position = historyRowPosition(row);
|
|
12663
|
-
if (position) {
|
|
12664
|
-
const timestamp = historyRowTimestamp(row);
|
|
12665
|
-
if (timestamp === 0) {
|
|
12666
|
-
dropped++;
|
|
12667
|
-
continue;
|
|
12668
|
-
}
|
|
12669
|
-
if (!glitchFilter.accept(app.selfContext, position, timestamp)) {
|
|
12670
|
-
dropped++;
|
|
12671
|
-
continue;
|
|
12672
|
-
}
|
|
12673
|
-
positions.push(position);
|
|
12674
|
-
timestamps.push(timestamp);
|
|
12675
|
-
}
|
|
12676
|
-
}
|
|
12677
|
-
if (dropped > 0) debug(`Track bootstrap discarded ${dropped} position(s) as glitches`);
|
|
12678
|
-
if (positions.length > 0) {
|
|
12679
|
-
tracks.initialTrack(app.selfContext, positions, timestamps);
|
|
12680
|
-
debug(`Track bootstrap complete: loaded ${positions.length} positions for self (${timespanMinutes} min window) on attempt ${attempt}`);
|
|
12681
|
-
return;
|
|
12682
|
-
}
|
|
12683
|
-
}
|
|
12684
|
-
debug(`History API returned no position data on attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}; the configured provider may not have registered yet`);
|
|
12685
|
-
if (attempt === BOOTSTRAP_MAX_ATTEMPTS) {
|
|
12686
|
-
debug("History API returned no position data for bootstrap");
|
|
12687
|
-
return;
|
|
12688
|
-
}
|
|
12689
|
-
continue;
|
|
12690
|
-
} catch (err) {
|
|
12691
|
-
if (isNoProviderError(err)) {
|
|
12692
|
-
noProviderCount++;
|
|
12693
|
-
debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}: no history provider registered yet (${noProviderCount}/${BOOTSTRAP_MAX_NO_PROVIDER})`);
|
|
12694
|
-
if (noProviderCount >= BOOTSTRAP_MAX_NO_PROVIDER) {
|
|
12695
|
-
debug(`No history provider registered after ${BOOTSTRAP_MAX_NO_PROVIDER} consecutive checks — no provider plugin appears to be installed. Giving up.`);
|
|
12696
|
-
return;
|
|
12697
|
-
}
|
|
12698
|
-
} else {
|
|
12699
|
-
noProviderCount = 0;
|
|
12700
|
-
debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS} failed: ${errorDetail(err)}`);
|
|
12701
|
-
}
|
|
12702
|
-
if (attempt === BOOTSTRAP_MAX_ATTEMPTS) app.error(`Track bootstrap from History API failed after ${BOOTSTRAP_MAX_ATTEMPTS} attempts. Tracks will start empty and accumulate from live data.`);
|
|
12703
|
-
}
|
|
12704
|
-
}
|
|
12705
|
-
}
|
|
12706
12621
|
function ThePlugin(app) {
|
|
12707
12622
|
let onStop = [];
|
|
12708
12623
|
let tracks = void 0;
|
|
@@ -12715,7 +12630,6 @@ function ThePlugin(app) {
|
|
|
12715
12630
|
* own.
|
|
12716
12631
|
*/
|
|
12717
12632
|
let storeResolution = DEFAULT_RESOLUTION;
|
|
12718
|
-
let defaultMaxRadius = void 0;
|
|
12719
12633
|
const sourceWatch = new SourceWatch();
|
|
12720
12634
|
let glitchFilter = new GlitchFilter({ maxSpeedKnots: 100 });
|
|
12721
12635
|
let stateGate = new StateGate(app.selfContext, DEFAULT_PAUSE_STATES);
|
|
@@ -12736,25 +12650,28 @@ function ThePlugin(app) {
|
|
|
12736
12650
|
}
|
|
12737
12651
|
return {
|
|
12738
12652
|
start: function(config) {
|
|
12739
|
-
const { resolution
|
|
12740
|
-
defaultMaxRadius = toNumber(maxRadius);
|
|
12741
|
-
const source = resolveSource(config);
|
|
12653
|
+
const { resolution } = config;
|
|
12742
12654
|
storeResolution = toNumber(config.resolution) ?? DEFAULT_RESOLUTION;
|
|
12743
12655
|
const segmentGapMinutes = toNumber(config.segmentGapMinutes) ?? DEFAULT_SEGMENT_GAP_MINUTES;
|
|
12744
12656
|
segmentGap = segmentGapMinutes > 0 ? segmentGapMinutes * 60 * 1e3 : 0;
|
|
12745
12657
|
glitchFilter = new GlitchFilter({ maxSpeedKnots: toNumber(config.maxSpeedKnots) ?? 100 });
|
|
12746
12658
|
stateGate = new StateGate(app.selfContext, Array.isArray(config.pauseWhenState) ? config.pauseWhenState : DEFAULT_PAUSE_STATES);
|
|
12747
|
-
const dataDir =
|
|
12748
|
-
if (
|
|
12749
|
-
|
|
12750
|
-
|
|
12751
|
-
|
|
12752
|
-
|
|
12753
|
-
|
|
12754
|
-
|
|
12755
|
-
|
|
12756
|
-
|
|
12757
|
-
|
|
12659
|
+
const dataDir = app.getDataDirPath?.();
|
|
12660
|
+
if (!dataDir) {
|
|
12661
|
+
app.error("This server provides no plugin data directory, so tracks cannot be recorded.");
|
|
12662
|
+
return;
|
|
12663
|
+
}
|
|
12664
|
+
try {
|
|
12665
|
+
tracks = new SqliteTrackStore({
|
|
12666
|
+
file: join(dataDir, "tracks.db"),
|
|
12667
|
+
resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,
|
|
12668
|
+
retention: (toNumber(config.retentionDays) ?? 0) * 24 * 60 * 60 * 1e3,
|
|
12669
|
+
segmentGap
|
|
12670
|
+
}, app.debug);
|
|
12671
|
+
} catch (err) {
|
|
12672
|
+
app.error(`Could not open the track database in ${dataDir}: ${errorDetail(err)}`);
|
|
12673
|
+
return;
|
|
12674
|
+
}
|
|
12758
12675
|
onStop.push(app.streambundle.getBus("navigation.position").onValue((update) => {
|
|
12759
12676
|
if (!update.value || update.value.latitude == null || update.value.longitude == null) return;
|
|
12760
12677
|
sourceWatch.add(update.context, update.$source);
|
|
@@ -12765,8 +12682,10 @@ function ThePlugin(app) {
|
|
|
12765
12682
|
if (!glitchFilter.accept(update.context, position, timestamp ?? Date.now())) return;
|
|
12766
12683
|
tracks?.newPosition(update.context, position, timestamp);
|
|
12767
12684
|
}));
|
|
12768
|
-
const
|
|
12769
|
-
const pruneInterval = setInterval(() =>
|
|
12685
|
+
const aisRetentionMs = (toNumber(config.aisRetentionDays) ?? DEFAULT_AIS_RETENTION_DAYS) * 24 * 60 * 60 * 1e3;
|
|
12686
|
+
const pruneInterval = setInterval(() => {
|
|
12687
|
+
tracks?.prune(aisRetentionMs > 0 ? aisRetentionMs : Infinity, app.selfContext);
|
|
12688
|
+
}, PRUNE_INTERVAL_MS);
|
|
12770
12689
|
onStop.push(() => {
|
|
12771
12690
|
clearInterval(pruneInterval);
|
|
12772
12691
|
});
|
|
@@ -12784,11 +12703,13 @@ function ThePlugin(app) {
|
|
|
12784
12703
|
app.registerTrackApiProvider?.(createTrackProvider({
|
|
12785
12704
|
store: () => tracks,
|
|
12786
12705
|
selfContext: () => app.selfContext,
|
|
12787
|
-
segmentGap: () => segmentGap
|
|
12706
|
+
segmentGap: () => segmentGap,
|
|
12707
|
+
reconcileWithHistory: async (context, stored, window, resolutionMs) => {
|
|
12708
|
+
const effective = resolutionMs ?? storeResolution;
|
|
12709
|
+
const history = await historyPositions(app, context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective, app.debug);
|
|
12710
|
+
return history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
|
|
12711
|
+
}
|
|
12788
12712
|
}));
|
|
12789
|
-
if (source === "history") bootstrapSelfTrack(app, tracks, config, glitchFilter).catch((err) => {
|
|
12790
|
-
app.error(`Unexpected error in track bootstrap: ${errorDetail(err)}`);
|
|
12791
|
-
});
|
|
12792
12713
|
},
|
|
12793
12714
|
stop: function() {
|
|
12794
12715
|
onStop.forEach((f) => {
|
|
@@ -12806,6 +12727,8 @@ function ThePlugin(app) {
|
|
|
12806
12727
|
tracks?.close?.();
|
|
12807
12728
|
} catch (err) {
|
|
12808
12729
|
app.error(err);
|
|
12730
|
+
} finally {
|
|
12731
|
+
tracks = void 0;
|
|
12809
12732
|
}
|
|
12810
12733
|
},
|
|
12811
12734
|
signalKApiRoutes: function(router) {
|
|
@@ -12869,7 +12792,7 @@ function ThePlugin(app) {
|
|
|
12869
12792
|
res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
|
|
12870
12793
|
return;
|
|
12871
12794
|
}
|
|
12872
|
-
const params = validateParameters(req.query,
|
|
12795
|
+
const params = validateParameters(req.query, void 0);
|
|
12873
12796
|
const selfPosition = getVesselPosition();
|
|
12874
12797
|
(query.times ? tracks.getFilteredTimedTracks(params, selfPosition, app.debug, query).then((tc) => Object.entries(tc).reduce((acc, [context, points]) => {
|
|
12875
12798
|
const segments = segment(points, segmentGap);
|
|
@@ -12901,55 +12824,31 @@ function ThePlugin(app) {
|
|
|
12901
12824
|
getTracks: () => tracks,
|
|
12902
12825
|
id: "tracks",
|
|
12903
12826
|
name: "Tracks",
|
|
12904
|
-
|
|
12827
|
+
enabledByDefault: true,
|
|
12828
|
+
description: "Record vessel tracks to SQLite and serve them through the track API",
|
|
12905
12829
|
schema: {
|
|
12906
12830
|
type: "object",
|
|
12907
12831
|
properties: {
|
|
12908
12832
|
resolution: {
|
|
12909
12833
|
type: "integer",
|
|
12834
|
+
minimum: 0,
|
|
12910
12835
|
title: "Track resolution (milliseconds)",
|
|
12911
12836
|
default: DEFAULT_RESOLUTION
|
|
12912
12837
|
},
|
|
12913
|
-
pointsToKeep: {
|
|
12914
|
-
type: "integer",
|
|
12915
|
-
title: "Points to keep",
|
|
12916
|
-
description: "How many trackpoints to keep for each track",
|
|
12917
|
-
default: DEFAULT_POINTS_TO_KEEP
|
|
12918
|
-
},
|
|
12919
|
-
maxAge: {
|
|
12920
|
-
type: "integer",
|
|
12921
|
-
title: "Maximum idle time (seconds)",
|
|
12922
|
-
description: "Tracks with no updates longer than this are removed",
|
|
12923
|
-
default: DEFAULT_MAX_AGE
|
|
12924
|
-
},
|
|
12925
|
-
maxRadius: {
|
|
12926
|
-
type: "integer",
|
|
12927
|
-
title: "Maximum Radius (meters) ",
|
|
12928
|
-
description: "Include only vessels with position within this range. 0= all vessels",
|
|
12929
|
-
default: DEFAULT_MAX_RADIUS
|
|
12930
|
-
},
|
|
12931
|
-
source: {
|
|
12932
|
-
type: "string",
|
|
12933
|
-
title: "Where tracks come from after a restart",
|
|
12934
|
-
description: "In memory only: start empty and accumulate from live positions. History API: refill on startup from a history provider such as signalk-to-influxdb2 or signalk-questdb. SQLite: record positions to a database file so they survive a restart with no other plugin required.",
|
|
12935
|
-
enum: [
|
|
12936
|
-
"memory",
|
|
12937
|
-
"history",
|
|
12938
|
-
"sqlite"
|
|
12939
|
-
],
|
|
12940
|
-
enumNames: [
|
|
12941
|
-
"In memory only",
|
|
12942
|
-
"Load from the History API on startup",
|
|
12943
|
-
"Record to a SQLite database"
|
|
12944
|
-
],
|
|
12945
|
-
default: "history"
|
|
12946
|
-
},
|
|
12947
12838
|
retentionDays: {
|
|
12948
12839
|
type: "integer",
|
|
12949
|
-
|
|
12950
|
-
|
|
12840
|
+
minimum: 0,
|
|
12841
|
+
title: "Days of the own vessel's track to keep",
|
|
12842
|
+
description: "Positions older than this are deleted. 0, the default, keeps everything — a track is worth more the longer it goes back, and a year of one vessel at the default resolution is a few megabytes.",
|
|
12951
12843
|
default: 0
|
|
12952
12844
|
},
|
|
12845
|
+
aisRetentionDays: {
|
|
12846
|
+
type: "integer",
|
|
12847
|
+
minimum: 0,
|
|
12848
|
+
title: "Days to keep another vessel after its last fix",
|
|
12849
|
+
description: "The own vessel is never removed. Other vessels are: a busy harbour puts hundreds of AIS targets past the receiver in a day, and keeping all of them forever is rarely what anyone wants. 0 keeps every vessel indefinitely.",
|
|
12850
|
+
default: DEFAULT_AIS_RETENTION_DAYS
|
|
12851
|
+
},
|
|
12953
12852
|
segmentGapMinutes: {
|
|
12954
12853
|
type: "integer",
|
|
12955
12854
|
title: "Split a track after this many minutes without a fix",
|
|
@@ -12978,6 +12877,6 @@ function ThePlugin(app) {
|
|
|
12978
12877
|
};
|
|
12979
12878
|
}
|
|
12980
12879
|
//#endregion
|
|
12981
|
-
export { TrackAccumulator, Tracks, ThePlugin as default
|
|
12880
|
+
export { TrackAccumulator, Tracks, ThePlugin as default };
|
|
12982
12881
|
|
|
12983
12882
|
//# sourceMappingURL=index.js.map
|