@aeroswarm/client 0.1.11 → 0.1.13
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 +571 -474
- package/dist/index-BXiTmKdS.d.ts +701 -0
- package/dist/index.d.ts +77 -2
- package/dist/index.js +448 -22
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +33 -9
- package/dist/react/index.js +488 -44
- package/dist/react/index.js.map +1 -1
- package/dist/sdk/index.d.ts +3 -582
- package/dist/sdk/index.js +134 -16
- package/dist/sdk/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,79 @@
|
|
|
1
|
-
export { AeroReplayClient, AeroSwarm, AeroSwarmOptions, AeroWsClient, DayAvailability, ReplayCallbacks, ReplayHandle, ReplayProgress, ReplayStreamOptions, SourceRow, SubscribeOptions, TrackCache, TrackCallbacks, TrackHistoryPoint, UseAeroTracksResult } from './
|
|
2
|
-
|
|
1
|
+
export { A as AeroReplayClient, a as AeroSwarm, b as AeroSwarmOptions, c as AeroWsClient, d as AftnMessageRow, D as DayAvailability, F as FlightPlanRow, L as LABEL_COLORS, e as LabelCategory, f as LabeledTrack, g as LiveTrackCallbacks, R as ReplayCallbacks, h as ReplayHandle, i as ReplayProgress, j as ReplayStreamOptions, S as SourceRow, k as StreamMeta, l as SubscribeOptions, T as TrackCache, m as TrackCallbacks, n as TrackHistoryPoint, U as UseAeroTracksResult, o as getLabelCategory, w as withLabel } from './index-BXiTmKdS.js';
|
|
2
|
+
import { CurrentTrack } from '@aeroswarm/core/types';
|
|
3
|
+
export { AeroConnectionStatus, AeroRecoverableError, Bbox, ConfidenceBudget, CurrentTrack, FlightPhase, FlightPlan, FlightPlanStatus, FlightPlanSummary, FusedTrackEvent, Position, Protocol, SourceHealthEvent, SourceStatus, StreamEnvelope, TrackSource, TrackStatus, Velocity } from '@aeroswarm/core/types';
|
|
3
4
|
export { clamp, haversineKm, isInsideBbox, isRenderableTrack, toCurrentTrack } from '@aeroswarm/core/utils';
|
|
4
5
|
import '@trpc/client';
|
|
6
|
+
|
|
7
|
+
/** What the engine reads from a track. */
|
|
8
|
+
type MotionInput = Pick<CurrentTrack, "trackId" | "lat" | "lon" | "groundSpeedKt" | "headingDeg" | "altitudeFt" | "status" | "lastSeenAt"> & {
|
|
9
|
+
targetType?: CurrentTrack["targetType"];
|
|
10
|
+
};
|
|
11
|
+
interface MotionSample {
|
|
12
|
+
lat: number;
|
|
13
|
+
lon: number;
|
|
14
|
+
/** Degrees 0..360, eased; null until a valid heading has been seen. */
|
|
15
|
+
headingDeg: number | null;
|
|
16
|
+
altitudeFt: number | null;
|
|
17
|
+
/** Speed is above `minMovingKt`. */
|
|
18
|
+
moving: boolean;
|
|
19
|
+
/** Position currently comes from dead reckoning (not frozen, limit not reached). */
|
|
20
|
+
extrapolating: boolean;
|
|
21
|
+
}
|
|
22
|
+
interface MotionFrame {
|
|
23
|
+
/** Server-aligned time of this frame (ms). */
|
|
24
|
+
at: number;
|
|
25
|
+
/** Only tracks whose picture changed since the previous frame. */
|
|
26
|
+
changed: ReadonlyMap<string, MotionSample>;
|
|
27
|
+
}
|
|
28
|
+
interface MotionScheduler {
|
|
29
|
+
request(callback: (timestamp: number) => void): unknown;
|
|
30
|
+
cancel(handle: unknown): void;
|
|
31
|
+
}
|
|
32
|
+
interface MotionOptions {
|
|
33
|
+
/** How long a correction takes to fade out. Default 1500 ms. */
|
|
34
|
+
blendMs?: number;
|
|
35
|
+
/** How long the icon takes to turn to a new heading. Default 800 ms. */
|
|
36
|
+
headingSmoothMs?: number;
|
|
37
|
+
/** Dead reckoning stops after this long without a new fix. Default 20 s in the air, 8 s on the surface. */
|
|
38
|
+
maxExtrapolateMs?: {
|
|
39
|
+
air: number;
|
|
40
|
+
surface: number;
|
|
41
|
+
};
|
|
42
|
+
/** Below this speed a track is treated as standing still. Default 5 kt. */
|
|
43
|
+
minMovingKt?: number;
|
|
44
|
+
/** A correction larger than this is a glitch or a sensor change far away: jump instead of easing. Default 5000 m. */
|
|
45
|
+
snapDistanceM?: number;
|
|
46
|
+
/** Frame rate limit for `start()`. Default 30. */
|
|
47
|
+
frameHz?: number;
|
|
48
|
+
/** Changes smaller than this are not reported. Default 0.5 m (and 0.5 degrees of heading). */
|
|
49
|
+
epsilonM?: number;
|
|
50
|
+
/** false = show raw positions (for comparing against another source). Default true. */
|
|
51
|
+
extrapolate?: boolean;
|
|
52
|
+
/** Decides what counts as "on the surface". Default: vehicles, or below 150 ft. */
|
|
53
|
+
isSurface?: (track: MotionInput) => boolean;
|
|
54
|
+
/** Browser clock. Default Date.now. Inject the replay clock to play back at another speed. */
|
|
55
|
+
clock?: () => number;
|
|
56
|
+
/** Frame timer. Default requestAnimationFrame, else setTimeout. Inject in tests. */
|
|
57
|
+
scheduler?: MotionScheduler;
|
|
58
|
+
}
|
|
59
|
+
interface TrackMotion {
|
|
60
|
+
/** `meta.serverTime` (from the WS envelope) keeps the server/browser clock offset current. */
|
|
61
|
+
update(track: MotionInput, meta?: {
|
|
62
|
+
serverTime?: number;
|
|
63
|
+
}): void;
|
|
64
|
+
remove(trackId: string): void;
|
|
65
|
+
clear(): void;
|
|
66
|
+
/** Where the track is shown at `at` (browser time; default now). Pure: does not touch frame state. */
|
|
67
|
+
sample(trackId: string, at?: number): MotionSample | undefined;
|
|
68
|
+
/** One frame: the tracks whose picture changed since the previous frame. `start()` calls this for you. */
|
|
69
|
+
tick(at?: number): ReadonlyMap<string, MotionSample>;
|
|
70
|
+
/** Runs frames until the returned function is called. Idles (no timer) while nothing moves. */
|
|
71
|
+
start(onFrame: (frame: MotionFrame) => void): () => void;
|
|
72
|
+
/** Tracks currently held. */
|
|
73
|
+
readonly size: number;
|
|
74
|
+
/** Current server minus browser clock, ms. */
|
|
75
|
+
offsetMs(): number;
|
|
76
|
+
}
|
|
77
|
+
declare function createTrackMotion(options?: MotionOptions): TrackMotion;
|
|
78
|
+
|
|
79
|
+
export { type MotionFrame, type MotionInput, type MotionOptions, type MotionSample, type MotionScheduler, type TrackMotion, createTrackMotion };
|
package/dist/index.js
CHANGED
|
@@ -19,11 +19,13 @@ var AeroWsClient = class {
|
|
|
19
19
|
lastSeq = -1;
|
|
20
20
|
lastHeartbeat = Date.now();
|
|
21
21
|
heartbeatTimer = null;
|
|
22
|
+
reconnectTimer = null;
|
|
22
23
|
status = "idle";
|
|
23
24
|
connect(opts, callbacks) {
|
|
24
25
|
this.lastSubscribe = opts;
|
|
25
26
|
this.callbacks = callbacks;
|
|
26
27
|
this.stopped = false;
|
|
28
|
+
this._clearReconnectTimer();
|
|
27
29
|
this._setStatus("connecting");
|
|
28
30
|
this._connect();
|
|
29
31
|
}
|
|
@@ -34,10 +36,27 @@ var AeroWsClient = class {
|
|
|
34
36
|
disconnect() {
|
|
35
37
|
this.stopped = true;
|
|
36
38
|
this._clearHeartbeatTimer();
|
|
37
|
-
this.
|
|
38
|
-
this.
|
|
39
|
+
this._clearReconnectTimer();
|
|
40
|
+
this._dropSocket();
|
|
39
41
|
this._setStatus("offline");
|
|
40
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Force a fresh connection now, skipping any backoff wait. Re-subscribes with
|
|
45
|
+
* the last `connect()` options (the server then sends a new snapshot).
|
|
46
|
+
* Also resumes after `disconnect()`. No-op if `connect()` was never called.
|
|
47
|
+
* Returns false in that no-op case.
|
|
48
|
+
*/
|
|
49
|
+
reconnect() {
|
|
50
|
+
if (!this.lastSubscribe) return false;
|
|
51
|
+
this.stopped = false;
|
|
52
|
+
this.attempt = 0;
|
|
53
|
+
this._clearHeartbeatTimer();
|
|
54
|
+
this._clearReconnectTimer();
|
|
55
|
+
this._dropSocket();
|
|
56
|
+
this._setStatus("reconnecting");
|
|
57
|
+
this._connect();
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
41
60
|
getStatus() {
|
|
42
61
|
return this.status;
|
|
43
62
|
}
|
|
@@ -46,8 +65,17 @@ var AeroWsClient = class {
|
|
|
46
65
|
this.status = s;
|
|
47
66
|
this.callbacks.onStatusChange?.(s);
|
|
48
67
|
}
|
|
68
|
+
/** Close the current socket without letting its onclose schedule a reconnect. */
|
|
69
|
+
_dropSocket() {
|
|
70
|
+
const ws = this.ws;
|
|
71
|
+
this.ws = null;
|
|
72
|
+
if (!ws) return;
|
|
73
|
+
ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null;
|
|
74
|
+
ws.close();
|
|
75
|
+
}
|
|
49
76
|
_connect() {
|
|
50
77
|
const wsUrl = `${this.url}?token=${encodeURIComponent(this.token)}`;
|
|
78
|
+
this._dropSocket();
|
|
51
79
|
try {
|
|
52
80
|
this.ws = new WebSocket(wsUrl);
|
|
53
81
|
this.ws.binaryType = "arraybuffer";
|
|
@@ -112,12 +140,13 @@ var AeroWsClient = class {
|
|
|
112
140
|
this._requestSnapshot();
|
|
113
141
|
}
|
|
114
142
|
this.lastSeq = msg.seq;
|
|
143
|
+
const meta = { serverTime: msg.serverTime, seq: msg.seq };
|
|
115
144
|
switch (msg.type) {
|
|
116
145
|
case "snapshot":
|
|
117
|
-
this.callbacks.onSnapshot?.(msg.payload.tracks);
|
|
146
|
+
this.callbacks.onSnapshot?.(msg.payload.tracks, meta);
|
|
118
147
|
break;
|
|
119
148
|
case "track.updated":
|
|
120
|
-
this.callbacks.onTrackUpdate?.(msg.payload.track);
|
|
149
|
+
this.callbacks.onTrackUpdate?.(msg.payload.track, meta);
|
|
121
150
|
break;
|
|
122
151
|
case "track.removed":
|
|
123
152
|
this.callbacks.onTrackRemoved?.(msg.payload);
|
|
@@ -163,10 +192,18 @@ var AeroWsClient = class {
|
|
|
163
192
|
_scheduleReconnect() {
|
|
164
193
|
const delay = Math.min(1e3 * 2 ** this.attempt, this.maxReconnectDelay);
|
|
165
194
|
this.attempt++;
|
|
166
|
-
|
|
195
|
+
this._clearReconnectTimer();
|
|
196
|
+
this.reconnectTimer = setTimeout(() => {
|
|
197
|
+
this.reconnectTimer = null;
|
|
167
198
|
if (!this.stopped) this._connect();
|
|
168
199
|
}, delay);
|
|
169
200
|
}
|
|
201
|
+
_clearReconnectTimer() {
|
|
202
|
+
if (this.reconnectTimer) {
|
|
203
|
+
clearTimeout(this.reconnectTimer);
|
|
204
|
+
this.reconnectTimer = null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
170
207
|
};
|
|
171
208
|
|
|
172
209
|
// src/sdk/websocket/replay.ts
|
|
@@ -316,6 +353,28 @@ var AeroRestClient = class {
|
|
|
316
353
|
const qs = search.size ? `?${search.toString()}` : "";
|
|
317
354
|
return this.req("GET", `/v1/tracks/current${qs}`);
|
|
318
355
|
}
|
|
356
|
+
/** Flight plans received from AFTN feeds (FPL/CHG/DLA/DEP/ARR/CNL folded per flight). */
|
|
357
|
+
listFlightPlans(params) {
|
|
358
|
+
const search = new URLSearchParams();
|
|
359
|
+
if (params?.airportId) search.set("airportId", params.airportId);
|
|
360
|
+
if (params?.status) search.set("status", params.status);
|
|
361
|
+
if (params?.callsign) search.set("callsign", params.callsign);
|
|
362
|
+
if (params?.limit != null) search.set("limit", String(params.limit));
|
|
363
|
+
const qs = search.size ? `?${search.toString()}` : "";
|
|
364
|
+
return this.req("GET", `/v1/flight-plans${qs}`);
|
|
365
|
+
}
|
|
366
|
+
getFlightPlan(id) {
|
|
367
|
+
return this.req("GET", `/v1/flight-plans/${encodeURIComponent(id)}`);
|
|
368
|
+
}
|
|
369
|
+
/** Raw AFTN messages (every parsed ATS message, newest first — the input
|
|
370
|
+
* side of the flight-plan pipeline). */
|
|
371
|
+
listAftnMessages(params) {
|
|
372
|
+
const search = new URLSearchParams();
|
|
373
|
+
if (params?.airportId) search.set("airportId", params.airportId);
|
|
374
|
+
if (params?.limit != null) search.set("limit", String(params.limit));
|
|
375
|
+
const qs = search.size ? `?${search.toString()}` : "";
|
|
376
|
+
return this.req("GET", `/v1/aftn/messages${qs}`);
|
|
377
|
+
}
|
|
319
378
|
health() {
|
|
320
379
|
return this.req("GET", "/health");
|
|
321
380
|
}
|
|
@@ -347,6 +406,38 @@ var AeroGraphQLClient = class {
|
|
|
347
406
|
|
|
348
407
|
// src/sdk/cache/index.ts
|
|
349
408
|
import { isRenderableTrack } from "@aeroswarm/core/utils";
|
|
409
|
+
|
|
410
|
+
// src/shared/label/index.ts
|
|
411
|
+
var LABEL_COLORS = {
|
|
412
|
+
ARRIVAL: "#FACC15",
|
|
413
|
+
// yellow
|
|
414
|
+
DEPARTURE: "#22C55E",
|
|
415
|
+
// green
|
|
416
|
+
GROUND_VEHICLE: "#3B82F6",
|
|
417
|
+
// blue
|
|
418
|
+
LOST: "#FFFFFF",
|
|
419
|
+
// white
|
|
420
|
+
UNKNOWN: "#FFFFFF"
|
|
421
|
+
// white — no flight plan
|
|
422
|
+
};
|
|
423
|
+
function getLabelCategory(track) {
|
|
424
|
+
if (track.status === "lost") return "LOST";
|
|
425
|
+
if (track.targetType === "vehicle") return "GROUND_VEHICLE";
|
|
426
|
+
switch (track.flightPlan?.phase) {
|
|
427
|
+
case "ARRIVAL":
|
|
428
|
+
return "ARRIVAL";
|
|
429
|
+
case "DEPARTURE":
|
|
430
|
+
return "DEPARTURE";
|
|
431
|
+
default:
|
|
432
|
+
return "UNKNOWN";
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function withLabel(track) {
|
|
436
|
+
const labelCategory = getLabelCategory(track);
|
|
437
|
+
return { ...track, labelCategory, labelColor: LABEL_COLORS[labelCategory] };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// src/sdk/cache/index.ts
|
|
350
441
|
var TypedEmitter = class {
|
|
351
442
|
listeners = /* @__PURE__ */ new Map();
|
|
352
443
|
on(event, fn) {
|
|
@@ -360,15 +451,18 @@ var TypedEmitter = class {
|
|
|
360
451
|
};
|
|
361
452
|
var TrackCache = class extends TypedEmitter {
|
|
362
453
|
tracks = /* @__PURE__ */ new Map();
|
|
454
|
+
/** Merges into the cached track and returns the labeled result. A "lost"
|
|
455
|
+
* track is evicted from the cache but still returned (labelCategory LOST). */
|
|
363
456
|
applyUpdate(track) {
|
|
457
|
+
const merged = withLabel({ ...this.tracks.get(track.trackId), ...track });
|
|
364
458
|
if (track.status === "lost") {
|
|
365
459
|
this.tracks.delete(track.trackId);
|
|
366
460
|
this.emit("removed", { trackId: track.trackId });
|
|
367
|
-
return;
|
|
461
|
+
return merged;
|
|
368
462
|
}
|
|
369
|
-
const merged = { ...this.tracks.get(track.trackId), ...track };
|
|
370
463
|
this.tracks.set(track.trackId, merged);
|
|
371
464
|
this.emit("update", merged);
|
|
465
|
+
return merged;
|
|
372
466
|
}
|
|
373
467
|
applyRemoved(ev) {
|
|
374
468
|
this.tracks.delete(ev.trackId);
|
|
@@ -377,9 +471,11 @@ var TrackCache = class extends TypedEmitter {
|
|
|
377
471
|
applySnapshot(tracks) {
|
|
378
472
|
this.tracks.clear();
|
|
379
473
|
for (const t of tracks) {
|
|
380
|
-
if (isRenderableTrack(t)) this.tracks.set(t.trackId, t);
|
|
474
|
+
if (isRenderableTrack(t)) this.tracks.set(t.trackId, withLabel(t));
|
|
381
475
|
}
|
|
382
|
-
|
|
476
|
+
const all = Array.from(this.tracks.values());
|
|
477
|
+
this.emit("snapshot", all);
|
|
478
|
+
return all;
|
|
383
479
|
}
|
|
384
480
|
getAll() {
|
|
385
481
|
return Array.from(this.tracks.values());
|
|
@@ -472,15 +568,15 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
472
568
|
this._status = s;
|
|
473
569
|
callbacks.onStatusChange?.(s);
|
|
474
570
|
},
|
|
475
|
-
onSnapshot: (tracks) => {
|
|
476
|
-
this.cache.applySnapshot(tracks);
|
|
571
|
+
onSnapshot: (tracks, meta) => {
|
|
572
|
+
const labeled = this.cache.applySnapshot(tracks);
|
|
477
573
|
this._lastUpdateAt = Date.now();
|
|
478
|
-
callbacks.onSnapshot?.(
|
|
574
|
+
callbacks.onSnapshot?.(labeled, meta);
|
|
479
575
|
},
|
|
480
|
-
onTrackUpdate: (track) => {
|
|
481
|
-
this.cache.applyUpdate(track);
|
|
576
|
+
onTrackUpdate: (track, meta) => {
|
|
577
|
+
const labeled = this.cache.applyUpdate(track);
|
|
482
578
|
this._lastUpdateAt = Date.now();
|
|
483
|
-
callbacks.onTrackUpdate?.(
|
|
579
|
+
callbacks.onTrackUpdate?.(labeled, meta);
|
|
484
580
|
},
|
|
485
581
|
onTrackRemoved: (ev) => {
|
|
486
582
|
this.cache.applyRemoved(ev);
|
|
@@ -499,15 +595,23 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
499
595
|
return () => this.wsClient.disconnect();
|
|
500
596
|
},
|
|
501
597
|
updateFilter: (opts) => this.wsClient.updateFilter(opts),
|
|
598
|
+
/** Drop the live socket and reconnect immediately (no backoff wait),
|
|
599
|
+
* re-subscribing with the last options. Returns false if nothing has
|
|
600
|
+
* been subscribed yet. */
|
|
601
|
+
reconnect: () => this.wsClient.reconnect(),
|
|
502
602
|
stream: (opts) => {
|
|
503
603
|
const queue = [];
|
|
504
|
-
let
|
|
604
|
+
let resolve2 = null;
|
|
505
605
|
let done = false;
|
|
506
606
|
this.wsClient.connect({ encoding: "json", ...opts }, {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
607
|
+
onSnapshot: (tracks) => {
|
|
608
|
+
this.cache.applySnapshot(tracks);
|
|
609
|
+
},
|
|
610
|
+
onTrackUpdate: (raw) => {
|
|
611
|
+
const t = this.cache.applyUpdate(raw);
|
|
612
|
+
if (resolve2) {
|
|
613
|
+
resolve2({ value: t, done: false });
|
|
614
|
+
resolve2 = null;
|
|
511
615
|
} else queue.push(t);
|
|
512
616
|
},
|
|
513
617
|
onStatusChange: (s) => {
|
|
@@ -520,7 +624,7 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
520
624
|
if (queue.length) return Promise.resolve({ value: queue.shift(), done: false });
|
|
521
625
|
if (done) return Promise.resolve({ value: void 0, done: true });
|
|
522
626
|
return new Promise((r) => {
|
|
523
|
-
|
|
627
|
+
resolve2 = r;
|
|
524
628
|
});
|
|
525
629
|
},
|
|
526
630
|
return: async () => {
|
|
@@ -545,6 +649,17 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
545
649
|
return this.trpc.tracks.history.query(input);
|
|
546
650
|
}
|
|
547
651
|
};
|
|
652
|
+
/** Flight plans received from AFTN feeds. Live tracks that correlate with a
|
|
653
|
+
* plan (Mode-S address, else callsign) also carry `track.flightPlan`. */
|
|
654
|
+
flightPlans = {
|
|
655
|
+
list: (input) => this.rest.listFlightPlans(input).then((r) => r.data.flightPlans),
|
|
656
|
+
get: (id) => this.rest.getFlightPlan(id).then((r) => r.data.flightPlan)
|
|
657
|
+
};
|
|
658
|
+
/** Raw AFTN/ATS messages received by AFTN feeds — the input side of the
|
|
659
|
+
* flight-plan pipeline (newest first, capped by the service). */
|
|
660
|
+
aftn = {
|
|
661
|
+
messages: (input) => this.rest.listAftnMessages(input).then((r) => r.data.messages)
|
|
662
|
+
};
|
|
548
663
|
sources = {
|
|
549
664
|
/** Registers a source and returns its token, tied to `airportId` — a
|
|
550
665
|
* source is a sensor physically at one airport, so this is required.
|
|
@@ -665,19 +780,330 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
665
780
|
disconnect() {
|
|
666
781
|
this.wsClient.disconnect();
|
|
667
782
|
}
|
|
783
|
+
reconnect() {
|
|
784
|
+
return this.wsClient.reconnect();
|
|
785
|
+
}
|
|
668
786
|
};
|
|
669
787
|
|
|
670
788
|
// src/shared/utils/index.ts
|
|
671
789
|
import { isRenderableTrack as isRenderableTrack2, haversineKm, isInsideBbox, toCurrentTrack, clamp } from "@aeroswarm/core/utils";
|
|
790
|
+
|
|
791
|
+
// src/shared/motion/index.ts
|
|
792
|
+
var KT_TO_MS = 0.514444;
|
|
793
|
+
var M_PER_DEG = 111320;
|
|
794
|
+
var MAX_PLAUSIBLE_KT = 1500;
|
|
795
|
+
var OFFSET_WINDOW = 20;
|
|
796
|
+
var ALT_EPSILON_FT = 10;
|
|
797
|
+
var HEADING_EPSILON_DEG = 0.5;
|
|
798
|
+
var SURFACE_ALT_FT = 150;
|
|
799
|
+
var clamp01 = (x) => x < 0 ? 0 : x > 1 ? 1 : x;
|
|
800
|
+
var smoothstep = (x) => {
|
|
801
|
+
const c = clamp01(x);
|
|
802
|
+
return c * c * (3 - 2 * c);
|
|
803
|
+
};
|
|
804
|
+
var wrapDeg = (d) => ((d + 180) % 360 + 360) % 360 - 180;
|
|
805
|
+
var norm360 = (d) => (d % 360 + 360) % 360;
|
|
806
|
+
var cosLat = (lat) => Math.max(0.01, Math.cos(lat * Math.PI / 180));
|
|
807
|
+
function validHeading(h) {
|
|
808
|
+
return h != null && Number.isFinite(h) && h >= 0 && h <= 360 ? h % 360 : null;
|
|
809
|
+
}
|
|
810
|
+
function move(lat, lon, bearingDeg, distM) {
|
|
811
|
+
const rad = bearingDeg * Math.PI / 180;
|
|
812
|
+
const dLat = distM * Math.cos(rad) / M_PER_DEG;
|
|
813
|
+
const dLon = distM * Math.sin(rad) / (M_PER_DEG * cosLat(lat + dLat / 2));
|
|
814
|
+
return { lat: lat + dLat, lon: lon + dLon };
|
|
815
|
+
}
|
|
816
|
+
function distanceM(aLat, aLon, bLat, bLon) {
|
|
817
|
+
const dy = (bLat - aLat) * M_PER_DEG;
|
|
818
|
+
const dx = (bLon - aLon) * M_PER_DEG * cosLat((aLat + bLat) / 2);
|
|
819
|
+
return Math.hypot(dx, dy);
|
|
820
|
+
}
|
|
821
|
+
var headingChanged = (a, b) => a === null || b === null ? a !== b : Math.abs(wrapDeg(a - b)) > HEADING_EPSILON_DEG;
|
|
822
|
+
var altitudeChanged = (a, b) => a === null || b === null ? a !== b : Math.abs(a - b) >= ALT_EPSILON_FT;
|
|
823
|
+
var defaultIsSurface = (t) => t.targetType === "vehicle" || t.altitudeFt != null && t.altitudeFt <= SURFACE_ALT_FT;
|
|
824
|
+
function resolve(o) {
|
|
825
|
+
return {
|
|
826
|
+
blendMs: o.blendMs ?? 1500,
|
|
827
|
+
headingSmoothMs: o.headingSmoothMs ?? 800,
|
|
828
|
+
maxExtrapolateMs: o.maxExtrapolateMs ?? { air: 2e4, surface: 8e3 },
|
|
829
|
+
minMovingKt: o.minMovingKt ?? 5,
|
|
830
|
+
snapDistanceM: o.snapDistanceM ?? 5e3,
|
|
831
|
+
frameHz: o.frameHz ?? 30,
|
|
832
|
+
epsilonM: o.epsilonM ?? 0.5,
|
|
833
|
+
extrapolate: o.extrapolate ?? true,
|
|
834
|
+
isSurface: o.isSurface ?? defaultIsSurface,
|
|
835
|
+
clock: o.clock ?? Date.now,
|
|
836
|
+
scheduler: o.scheduler
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
function defaultScheduler() {
|
|
840
|
+
const g = globalThis;
|
|
841
|
+
if (typeof g.requestAnimationFrame === "function" && typeof g.cancelAnimationFrame === "function") {
|
|
842
|
+
return {
|
|
843
|
+
request: (cb) => g.requestAnimationFrame(cb),
|
|
844
|
+
cancel: (h) => g.cancelAnimationFrame(h)
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
return {
|
|
848
|
+
request: (cb) => setTimeout(() => cb(Date.now()), 16),
|
|
849
|
+
cancel: (h) => clearTimeout(h)
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
var Motion = class {
|
|
853
|
+
o;
|
|
854
|
+
entries = /* @__PURE__ */ new Map();
|
|
855
|
+
/** Tracks whose picture can still change. Everything else costs nothing per frame. */
|
|
856
|
+
active = /* @__PURE__ */ new Set();
|
|
857
|
+
listeners = /* @__PURE__ */ new Set();
|
|
858
|
+
offsets = [];
|
|
859
|
+
offset = 0;
|
|
860
|
+
handle = null;
|
|
861
|
+
lastFrameAt = Number.NEGATIVE_INFINITY;
|
|
862
|
+
constructor(options) {
|
|
863
|
+
this.o = resolve(options);
|
|
864
|
+
}
|
|
865
|
+
get size() {
|
|
866
|
+
return this.entries.size;
|
|
867
|
+
}
|
|
868
|
+
offsetMs() {
|
|
869
|
+
return this.offset;
|
|
870
|
+
}
|
|
871
|
+
// ── time ────────────────────────────────────────────────────────────────
|
|
872
|
+
/** Browser clock + learned offset = the server's clock. */
|
|
873
|
+
serverNow(at) {
|
|
874
|
+
return (at ?? this.o.clock()) + this.offset;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* `serverTime - browserNow` is the true offset MINUS the network delay, so the
|
|
878
|
+
* largest recent value is the one with the least delay, i.e. the best estimate.
|
|
879
|
+
*/
|
|
880
|
+
observe(serverTime) {
|
|
881
|
+
if (!Number.isFinite(serverTime)) return;
|
|
882
|
+
this.offsets.push(serverTime - this.o.clock());
|
|
883
|
+
if (this.offsets.length > OFFSET_WINDOW) this.offsets.shift();
|
|
884
|
+
this.offset = Math.max(...this.offsets);
|
|
885
|
+
}
|
|
886
|
+
cap(e) {
|
|
887
|
+
const m = this.o.maxExtrapolateMs;
|
|
888
|
+
return e.surface ? m.surface : m.air;
|
|
889
|
+
}
|
|
890
|
+
// ── model ───────────────────────────────────────────────────────────────
|
|
891
|
+
posAt(e, s) {
|
|
892
|
+
const cap = this.cap(e);
|
|
893
|
+
let elapsed = Math.min(Math.max(0, s - e.fixAt), cap);
|
|
894
|
+
if (e.frozen !== null) elapsed = Math.min(elapsed, e.frozen);
|
|
895
|
+
if (!this.o.extrapolate || e.course === null || e.speedMs <= 0) {
|
|
896
|
+
return { lat: e.fixLat, lon: e.fixLon, extrapolating: false };
|
|
897
|
+
}
|
|
898
|
+
const p = move(e.fixLat, e.fixLon, e.course, e.speedMs * elapsed / 1e3);
|
|
899
|
+
return { ...p, extrapolating: e.frozen === null && elapsed < cap };
|
|
900
|
+
}
|
|
901
|
+
sampleAt(e, s) {
|
|
902
|
+
const p = this.posAt(e, s);
|
|
903
|
+
const rest = 1 - smoothstep((s - e.blendStart) / this.o.blendMs);
|
|
904
|
+
let headingDeg = null;
|
|
905
|
+
if (e.hdgTo !== null) {
|
|
906
|
+
const from = e.hdgFrom ?? e.hdgTo;
|
|
907
|
+
const left = 1 - smoothstep((s - e.hdgStart) / this.o.headingSmoothMs);
|
|
908
|
+
headingDeg = norm360(e.hdgTo + wrapDeg(from - e.hdgTo) * left);
|
|
909
|
+
}
|
|
910
|
+
let altitudeFt = null;
|
|
911
|
+
if (e.altTo !== null) {
|
|
912
|
+
const k = smoothstep((s - e.altStart) / this.o.blendMs);
|
|
913
|
+
altitudeFt = e.altFrom === null ? e.altTo : e.altFrom + (e.altTo - e.altFrom) * k;
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
lat: p.lat + e.errLat * rest,
|
|
917
|
+
lon: p.lon + e.errLon * rest,
|
|
918
|
+
headingDeg,
|
|
919
|
+
altitudeFt,
|
|
920
|
+
moving: e.speedMs > 0,
|
|
921
|
+
extrapolating: p.extrapolating
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
// ── input ───────────────────────────────────────────────────────────────
|
|
925
|
+
update(t, meta) {
|
|
926
|
+
if (meta?.serverTime !== void 0) this.observe(meta.serverTime);
|
|
927
|
+
if (t.lat == null || t.lon == null || !Number.isFinite(t.lat) || !Number.isFinite(t.lon)) {
|
|
928
|
+
this.remove(t.trackId);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
const s = this.serverNow();
|
|
932
|
+
const e = this.entries.get(t.trackId);
|
|
933
|
+
if (!e) {
|
|
934
|
+
this.create(t, s);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
this.refresh(e, t, s);
|
|
938
|
+
}
|
|
939
|
+
moving(t, course) {
|
|
940
|
+
const kt = t.groundSpeedKt;
|
|
941
|
+
return course !== null && kt != null && Number.isFinite(kt) && kt >= this.o.minMovingKt && kt <= MAX_PLAUSIBLE_KT;
|
|
942
|
+
}
|
|
943
|
+
create(t, s) {
|
|
944
|
+
const course = validHeading(t.headingDeg);
|
|
945
|
+
const moving = this.moving(t, course);
|
|
946
|
+
const e = {
|
|
947
|
+
id: t.trackId,
|
|
948
|
+
fixLat: t.lat,
|
|
949
|
+
fixLon: t.lon,
|
|
950
|
+
fixAt: t.lastSeenAt,
|
|
951
|
+
speedMs: moving ? t.groundSpeedKt * KT_TO_MS : 0,
|
|
952
|
+
course,
|
|
953
|
+
surface: this.o.isSurface(t),
|
|
954
|
+
frozen: null,
|
|
955
|
+
errLat: 0,
|
|
956
|
+
errLon: 0,
|
|
957
|
+
blendStart: s,
|
|
958
|
+
hdgFrom: course,
|
|
959
|
+
hdgTo: course,
|
|
960
|
+
hdgStart: s,
|
|
961
|
+
altFrom: null,
|
|
962
|
+
altTo: t.altitudeFt,
|
|
963
|
+
altStart: s,
|
|
964
|
+
settleAt: 0,
|
|
965
|
+
emitted: null
|
|
966
|
+
};
|
|
967
|
+
if (t.status === "lost") e.frozen = this.frozenAt(e, s);
|
|
968
|
+
this.entries.set(e.id, e);
|
|
969
|
+
this.settle(e);
|
|
970
|
+
this.active.add(e.id);
|
|
971
|
+
this.ensureLoop();
|
|
972
|
+
}
|
|
973
|
+
frozenAt(e, s) {
|
|
974
|
+
return Math.min(Math.max(0, s - e.fixAt), this.cap(e));
|
|
975
|
+
}
|
|
976
|
+
refresh(e, t, s) {
|
|
977
|
+
e.surface = this.o.isSurface(t);
|
|
978
|
+
if (t.status === "lost") e.frozen ??= this.frozenAt(e, s);
|
|
979
|
+
else e.frozen = null;
|
|
980
|
+
const lat = t.lat;
|
|
981
|
+
const lon = t.lon;
|
|
982
|
+
const older = t.lastSeenAt < e.fixAt;
|
|
983
|
+
const same = t.lastSeenAt === e.fixAt && lat === e.fixLat && lon === e.fixLon;
|
|
984
|
+
if (older || same) {
|
|
985
|
+
this.settle(e);
|
|
986
|
+
this.touch(e);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
const shown = this.sampleAt(e, s);
|
|
990
|
+
const course = validHeading(t.headingDeg) ?? e.course;
|
|
991
|
+
const moving = this.moving(t, course);
|
|
992
|
+
e.fixLat = lat;
|
|
993
|
+
e.fixLon = lon;
|
|
994
|
+
e.fixAt = t.lastSeenAt;
|
|
995
|
+
e.speedMs = moving ? t.groundSpeedKt * KT_TO_MS : 0;
|
|
996
|
+
e.course = course;
|
|
997
|
+
const predicted = this.posAt(e, s);
|
|
998
|
+
const jump = distanceM(shown.lat, shown.lon, predicted.lat, predicted.lon);
|
|
999
|
+
const snap = jump > this.o.snapDistanceM;
|
|
1000
|
+
e.errLat = snap ? 0 : shown.lat - predicted.lat;
|
|
1001
|
+
e.errLon = snap ? 0 : shown.lon - predicted.lon;
|
|
1002
|
+
e.blendStart = s;
|
|
1003
|
+
e.hdgFrom = snap ? course : shown.headingDeg ?? course;
|
|
1004
|
+
e.hdgTo = course;
|
|
1005
|
+
e.hdgStart = s;
|
|
1006
|
+
e.altFrom = snap ? t.altitudeFt : shown.altitudeFt;
|
|
1007
|
+
e.altTo = t.altitudeFt;
|
|
1008
|
+
e.altStart = s;
|
|
1009
|
+
this.settle(e);
|
|
1010
|
+
this.touch(e);
|
|
1011
|
+
}
|
|
1012
|
+
/** The server time after which nothing about this track changes on its own. */
|
|
1013
|
+
settle(e) {
|
|
1014
|
+
const ends = [e.blendStart + this.o.blendMs, e.hdgStart + this.o.headingSmoothMs, e.altStart + this.o.blendMs];
|
|
1015
|
+
if (this.o.extrapolate && e.speedMs > 0 && e.frozen === null) ends.push(e.fixAt + this.cap(e));
|
|
1016
|
+
e.settleAt = Math.max(...ends);
|
|
1017
|
+
}
|
|
1018
|
+
touch(e) {
|
|
1019
|
+
this.active.add(e.id);
|
|
1020
|
+
this.ensureLoop();
|
|
1021
|
+
}
|
|
1022
|
+
remove(trackId) {
|
|
1023
|
+
this.entries.delete(trackId);
|
|
1024
|
+
this.active.delete(trackId);
|
|
1025
|
+
}
|
|
1026
|
+
clear() {
|
|
1027
|
+
this.entries.clear();
|
|
1028
|
+
this.active.clear();
|
|
1029
|
+
}
|
|
1030
|
+
// ── output ──────────────────────────────────────────────────────────────
|
|
1031
|
+
sample(trackId, at) {
|
|
1032
|
+
const e = this.entries.get(trackId);
|
|
1033
|
+
return e ? this.sampleAt(e, this.serverNow(at)) : void 0;
|
|
1034
|
+
}
|
|
1035
|
+
differs(a, b) {
|
|
1036
|
+
if (a === null || a.moving !== b.moving || a.extrapolating !== b.extrapolating) return true;
|
|
1037
|
+
if (distanceM(a.lat, a.lon, b.lat, b.lon) > this.o.epsilonM) return true;
|
|
1038
|
+
return headingChanged(a.headingDeg, b.headingDeg) || altitudeChanged(a.altitudeFt, b.altitudeFt);
|
|
1039
|
+
}
|
|
1040
|
+
tick(at) {
|
|
1041
|
+
const s = this.serverNow(at);
|
|
1042
|
+
const changed = /* @__PURE__ */ new Map();
|
|
1043
|
+
for (const id of [...this.active]) {
|
|
1044
|
+
const e = this.entries.get(id);
|
|
1045
|
+
if (!e) {
|
|
1046
|
+
this.active.delete(id);
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
const sample = this.sampleAt(e, s);
|
|
1050
|
+
if (this.differs(e.emitted, sample)) {
|
|
1051
|
+
e.emitted = sample;
|
|
1052
|
+
changed.set(id, sample);
|
|
1053
|
+
}
|
|
1054
|
+
if (s >= e.settleAt) this.active.delete(id);
|
|
1055
|
+
}
|
|
1056
|
+
return changed;
|
|
1057
|
+
}
|
|
1058
|
+
// ── frame loop ──────────────────────────────────────────────────────────
|
|
1059
|
+
start(onFrame) {
|
|
1060
|
+
this.listeners.add(onFrame);
|
|
1061
|
+
this.ensureLoop();
|
|
1062
|
+
return () => {
|
|
1063
|
+
this.listeners.delete(onFrame);
|
|
1064
|
+
if (this.listeners.size === 0) this.cancelLoop();
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
ensureLoop() {
|
|
1068
|
+
if (this.handle !== null || this.listeners.size === 0 || this.active.size === 0) return;
|
|
1069
|
+
const scheduler = this.o.scheduler ?? (this.o.scheduler = defaultScheduler());
|
|
1070
|
+
this.handle = scheduler.request((ts) => this.frame(ts));
|
|
1071
|
+
}
|
|
1072
|
+
cancelLoop() {
|
|
1073
|
+
if (this.handle === null) return;
|
|
1074
|
+
(this.o.scheduler ?? defaultScheduler()).cancel(this.handle);
|
|
1075
|
+
this.handle = null;
|
|
1076
|
+
}
|
|
1077
|
+
frame(ts) {
|
|
1078
|
+
this.handle = null;
|
|
1079
|
+
if (this.listeners.size === 0) return;
|
|
1080
|
+
if (ts - this.lastFrameAt >= 1e3 / this.o.frameHz - 1) {
|
|
1081
|
+
this.lastFrameAt = ts;
|
|
1082
|
+
const changed = this.tick();
|
|
1083
|
+
if (changed.size > 0) {
|
|
1084
|
+
const frame = { at: this.serverNow(), changed };
|
|
1085
|
+
for (const fn of [...this.listeners]) fn(frame);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
this.ensureLoop();
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
function createTrackMotion(options = {}) {
|
|
1092
|
+
return new Motion(options);
|
|
1093
|
+
}
|
|
672
1094
|
export {
|
|
673
1095
|
AeroReplayClient,
|
|
674
1096
|
AeroSwarm,
|
|
675
1097
|
AeroWsClient,
|
|
1098
|
+
LABEL_COLORS,
|
|
676
1099
|
TrackCache,
|
|
677
1100
|
clamp,
|
|
1101
|
+
createTrackMotion,
|
|
1102
|
+
getLabelCategory,
|
|
678
1103
|
haversineKm,
|
|
679
1104
|
isInsideBbox,
|
|
680
1105
|
isRenderableTrack2 as isRenderableTrack,
|
|
681
|
-
toCurrentTrack
|
|
1106
|
+
toCurrentTrack,
|
|
1107
|
+
withLabel
|
|
682
1108
|
};
|
|
683
1109
|
//# sourceMappingURL=index.js.map
|