@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/react/index.js
CHANGED
|
@@ -23,11 +23,13 @@ var AeroWsClient = class {
|
|
|
23
23
|
lastSeq = -1;
|
|
24
24
|
lastHeartbeat = Date.now();
|
|
25
25
|
heartbeatTimer = null;
|
|
26
|
+
reconnectTimer = null;
|
|
26
27
|
status = "idle";
|
|
27
28
|
connect(opts, callbacks) {
|
|
28
29
|
this.lastSubscribe = opts;
|
|
29
30
|
this.callbacks = callbacks;
|
|
30
31
|
this.stopped = false;
|
|
32
|
+
this._clearReconnectTimer();
|
|
31
33
|
this._setStatus("connecting");
|
|
32
34
|
this._connect();
|
|
33
35
|
}
|
|
@@ -38,10 +40,27 @@ var AeroWsClient = class {
|
|
|
38
40
|
disconnect() {
|
|
39
41
|
this.stopped = true;
|
|
40
42
|
this._clearHeartbeatTimer();
|
|
41
|
-
this.
|
|
42
|
-
this.
|
|
43
|
+
this._clearReconnectTimer();
|
|
44
|
+
this._dropSocket();
|
|
43
45
|
this._setStatus("offline");
|
|
44
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Force a fresh connection now, skipping any backoff wait. Re-subscribes with
|
|
49
|
+
* the last `connect()` options (the server then sends a new snapshot).
|
|
50
|
+
* Also resumes after `disconnect()`. No-op if `connect()` was never called.
|
|
51
|
+
* Returns false in that no-op case.
|
|
52
|
+
*/
|
|
53
|
+
reconnect() {
|
|
54
|
+
if (!this.lastSubscribe) return false;
|
|
55
|
+
this.stopped = false;
|
|
56
|
+
this.attempt = 0;
|
|
57
|
+
this._clearHeartbeatTimer();
|
|
58
|
+
this._clearReconnectTimer();
|
|
59
|
+
this._dropSocket();
|
|
60
|
+
this._setStatus("reconnecting");
|
|
61
|
+
this._connect();
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
45
64
|
getStatus() {
|
|
46
65
|
return this.status;
|
|
47
66
|
}
|
|
@@ -50,8 +69,17 @@ var AeroWsClient = class {
|
|
|
50
69
|
this.status = s;
|
|
51
70
|
this.callbacks.onStatusChange?.(s);
|
|
52
71
|
}
|
|
72
|
+
/** Close the current socket without letting its onclose schedule a reconnect. */
|
|
73
|
+
_dropSocket() {
|
|
74
|
+
const ws = this.ws;
|
|
75
|
+
this.ws = null;
|
|
76
|
+
if (!ws) return;
|
|
77
|
+
ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null;
|
|
78
|
+
ws.close();
|
|
79
|
+
}
|
|
53
80
|
_connect() {
|
|
54
81
|
const wsUrl = `${this.url}?token=${encodeURIComponent(this.token)}`;
|
|
82
|
+
this._dropSocket();
|
|
55
83
|
try {
|
|
56
84
|
this.ws = new WebSocket(wsUrl);
|
|
57
85
|
this.ws.binaryType = "arraybuffer";
|
|
@@ -116,12 +144,13 @@ var AeroWsClient = class {
|
|
|
116
144
|
this._requestSnapshot();
|
|
117
145
|
}
|
|
118
146
|
this.lastSeq = msg.seq;
|
|
147
|
+
const meta = { serverTime: msg.serverTime, seq: msg.seq };
|
|
119
148
|
switch (msg.type) {
|
|
120
149
|
case "snapshot":
|
|
121
|
-
this.callbacks.onSnapshot?.(msg.payload.tracks);
|
|
150
|
+
this.callbacks.onSnapshot?.(msg.payload.tracks, meta);
|
|
122
151
|
break;
|
|
123
152
|
case "track.updated":
|
|
124
|
-
this.callbacks.onTrackUpdate?.(msg.payload.track);
|
|
153
|
+
this.callbacks.onTrackUpdate?.(msg.payload.track, meta);
|
|
125
154
|
break;
|
|
126
155
|
case "track.removed":
|
|
127
156
|
this.callbacks.onTrackRemoved?.(msg.payload);
|
|
@@ -167,10 +196,18 @@ var AeroWsClient = class {
|
|
|
167
196
|
_scheduleReconnect() {
|
|
168
197
|
const delay = Math.min(1e3 * 2 ** this.attempt, this.maxReconnectDelay);
|
|
169
198
|
this.attempt++;
|
|
170
|
-
|
|
199
|
+
this._clearReconnectTimer();
|
|
200
|
+
this.reconnectTimer = setTimeout(() => {
|
|
201
|
+
this.reconnectTimer = null;
|
|
171
202
|
if (!this.stopped) this._connect();
|
|
172
203
|
}, delay);
|
|
173
204
|
}
|
|
205
|
+
_clearReconnectTimer() {
|
|
206
|
+
if (this.reconnectTimer) {
|
|
207
|
+
clearTimeout(this.reconnectTimer);
|
|
208
|
+
this.reconnectTimer = null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
174
211
|
};
|
|
175
212
|
|
|
176
213
|
// src/sdk/websocket/replay.ts
|
|
@@ -320,6 +357,28 @@ var AeroRestClient = class {
|
|
|
320
357
|
const qs = search.size ? `?${search.toString()}` : "";
|
|
321
358
|
return this.req("GET", `/v1/tracks/current${qs}`);
|
|
322
359
|
}
|
|
360
|
+
/** Flight plans received from AFTN feeds (FPL/CHG/DLA/DEP/ARR/CNL folded per flight). */
|
|
361
|
+
listFlightPlans(params) {
|
|
362
|
+
const search = new URLSearchParams();
|
|
363
|
+
if (params?.airportId) search.set("airportId", params.airportId);
|
|
364
|
+
if (params?.status) search.set("status", params.status);
|
|
365
|
+
if (params?.callsign) search.set("callsign", params.callsign);
|
|
366
|
+
if (params?.limit != null) search.set("limit", String(params.limit));
|
|
367
|
+
const qs = search.size ? `?${search.toString()}` : "";
|
|
368
|
+
return this.req("GET", `/v1/flight-plans${qs}`);
|
|
369
|
+
}
|
|
370
|
+
getFlightPlan(id) {
|
|
371
|
+
return this.req("GET", `/v1/flight-plans/${encodeURIComponent(id)}`);
|
|
372
|
+
}
|
|
373
|
+
/** Raw AFTN messages (every parsed ATS message, newest first — the input
|
|
374
|
+
* side of the flight-plan pipeline). */
|
|
375
|
+
listAftnMessages(params) {
|
|
376
|
+
const search = new URLSearchParams();
|
|
377
|
+
if (params?.airportId) search.set("airportId", params.airportId);
|
|
378
|
+
if (params?.limit != null) search.set("limit", String(params.limit));
|
|
379
|
+
const qs = search.size ? `?${search.toString()}` : "";
|
|
380
|
+
return this.req("GET", `/v1/aftn/messages${qs}`);
|
|
381
|
+
}
|
|
323
382
|
health() {
|
|
324
383
|
return this.req("GET", "/health");
|
|
325
384
|
}
|
|
@@ -351,6 +410,38 @@ var AeroGraphQLClient = class {
|
|
|
351
410
|
|
|
352
411
|
// src/sdk/cache/index.ts
|
|
353
412
|
import { isRenderableTrack } from "@aeroswarm/core/utils";
|
|
413
|
+
|
|
414
|
+
// src/shared/label/index.ts
|
|
415
|
+
var LABEL_COLORS = {
|
|
416
|
+
ARRIVAL: "#FACC15",
|
|
417
|
+
// yellow
|
|
418
|
+
DEPARTURE: "#22C55E",
|
|
419
|
+
// green
|
|
420
|
+
GROUND_VEHICLE: "#3B82F6",
|
|
421
|
+
// blue
|
|
422
|
+
LOST: "#FFFFFF",
|
|
423
|
+
// white
|
|
424
|
+
UNKNOWN: "#FFFFFF"
|
|
425
|
+
// white — no flight plan
|
|
426
|
+
};
|
|
427
|
+
function getLabelCategory(track) {
|
|
428
|
+
if (track.status === "lost") return "LOST";
|
|
429
|
+
if (track.targetType === "vehicle") return "GROUND_VEHICLE";
|
|
430
|
+
switch (track.flightPlan?.phase) {
|
|
431
|
+
case "ARRIVAL":
|
|
432
|
+
return "ARRIVAL";
|
|
433
|
+
case "DEPARTURE":
|
|
434
|
+
return "DEPARTURE";
|
|
435
|
+
default:
|
|
436
|
+
return "UNKNOWN";
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
function withLabel(track) {
|
|
440
|
+
const labelCategory = getLabelCategory(track);
|
|
441
|
+
return { ...track, labelCategory, labelColor: LABEL_COLORS[labelCategory] };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// src/sdk/cache/index.ts
|
|
354
445
|
var TypedEmitter = class {
|
|
355
446
|
listeners = /* @__PURE__ */ new Map();
|
|
356
447
|
on(event, fn) {
|
|
@@ -364,15 +455,18 @@ var TypedEmitter = class {
|
|
|
364
455
|
};
|
|
365
456
|
var TrackCache = class extends TypedEmitter {
|
|
366
457
|
tracks = /* @__PURE__ */ new Map();
|
|
458
|
+
/** Merges into the cached track and returns the labeled result. A "lost"
|
|
459
|
+
* track is evicted from the cache but still returned (labelCategory LOST). */
|
|
367
460
|
applyUpdate(track) {
|
|
461
|
+
const merged = withLabel({ ...this.tracks.get(track.trackId), ...track });
|
|
368
462
|
if (track.status === "lost") {
|
|
369
463
|
this.tracks.delete(track.trackId);
|
|
370
464
|
this.emit("removed", { trackId: track.trackId });
|
|
371
|
-
return;
|
|
465
|
+
return merged;
|
|
372
466
|
}
|
|
373
|
-
const merged = { ...this.tracks.get(track.trackId), ...track };
|
|
374
467
|
this.tracks.set(track.trackId, merged);
|
|
375
468
|
this.emit("update", merged);
|
|
469
|
+
return merged;
|
|
376
470
|
}
|
|
377
471
|
applyRemoved(ev) {
|
|
378
472
|
this.tracks.delete(ev.trackId);
|
|
@@ -381,9 +475,11 @@ var TrackCache = class extends TypedEmitter {
|
|
|
381
475
|
applySnapshot(tracks) {
|
|
382
476
|
this.tracks.clear();
|
|
383
477
|
for (const t of tracks) {
|
|
384
|
-
if (isRenderableTrack(t)) this.tracks.set(t.trackId, t);
|
|
478
|
+
if (isRenderableTrack(t)) this.tracks.set(t.trackId, withLabel(t));
|
|
385
479
|
}
|
|
386
|
-
|
|
480
|
+
const all = Array.from(this.tracks.values());
|
|
481
|
+
this.emit("snapshot", all);
|
|
482
|
+
return all;
|
|
387
483
|
}
|
|
388
484
|
getAll() {
|
|
389
485
|
return Array.from(this.tracks.values());
|
|
@@ -476,15 +572,15 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
476
572
|
this._status = s;
|
|
477
573
|
callbacks.onStatusChange?.(s);
|
|
478
574
|
},
|
|
479
|
-
onSnapshot: (tracks) => {
|
|
480
|
-
this.cache.applySnapshot(tracks);
|
|
575
|
+
onSnapshot: (tracks, meta) => {
|
|
576
|
+
const labeled = this.cache.applySnapshot(tracks);
|
|
481
577
|
this._lastUpdateAt = Date.now();
|
|
482
|
-
callbacks.onSnapshot?.(
|
|
578
|
+
callbacks.onSnapshot?.(labeled, meta);
|
|
483
579
|
},
|
|
484
|
-
onTrackUpdate: (track) => {
|
|
485
|
-
this.cache.applyUpdate(track);
|
|
580
|
+
onTrackUpdate: (track, meta) => {
|
|
581
|
+
const labeled = this.cache.applyUpdate(track);
|
|
486
582
|
this._lastUpdateAt = Date.now();
|
|
487
|
-
callbacks.onTrackUpdate?.(
|
|
583
|
+
callbacks.onTrackUpdate?.(labeled, meta);
|
|
488
584
|
},
|
|
489
585
|
onTrackRemoved: (ev) => {
|
|
490
586
|
this.cache.applyRemoved(ev);
|
|
@@ -503,15 +599,23 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
503
599
|
return () => this.wsClient.disconnect();
|
|
504
600
|
},
|
|
505
601
|
updateFilter: (opts) => this.wsClient.updateFilter(opts),
|
|
602
|
+
/** Drop the live socket and reconnect immediately (no backoff wait),
|
|
603
|
+
* re-subscribing with the last options. Returns false if nothing has
|
|
604
|
+
* been subscribed yet. */
|
|
605
|
+
reconnect: () => this.wsClient.reconnect(),
|
|
506
606
|
stream: (opts) => {
|
|
507
607
|
const queue = [];
|
|
508
|
-
let
|
|
608
|
+
let resolve2 = null;
|
|
509
609
|
let done = false;
|
|
510
610
|
this.wsClient.connect({ encoding: "json", ...opts }, {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
611
|
+
onSnapshot: (tracks) => {
|
|
612
|
+
this.cache.applySnapshot(tracks);
|
|
613
|
+
},
|
|
614
|
+
onTrackUpdate: (raw) => {
|
|
615
|
+
const t = this.cache.applyUpdate(raw);
|
|
616
|
+
if (resolve2) {
|
|
617
|
+
resolve2({ value: t, done: false });
|
|
618
|
+
resolve2 = null;
|
|
515
619
|
} else queue.push(t);
|
|
516
620
|
},
|
|
517
621
|
onStatusChange: (s) => {
|
|
@@ -524,7 +628,7 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
524
628
|
if (queue.length) return Promise.resolve({ value: queue.shift(), done: false });
|
|
525
629
|
if (done) return Promise.resolve({ value: void 0, done: true });
|
|
526
630
|
return new Promise((r) => {
|
|
527
|
-
|
|
631
|
+
resolve2 = r;
|
|
528
632
|
});
|
|
529
633
|
},
|
|
530
634
|
return: async () => {
|
|
@@ -549,6 +653,17 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
549
653
|
return this.trpc.tracks.history.query(input);
|
|
550
654
|
}
|
|
551
655
|
};
|
|
656
|
+
/** Flight plans received from AFTN feeds. Live tracks that correlate with a
|
|
657
|
+
* plan (Mode-S address, else callsign) also carry `track.flightPlan`. */
|
|
658
|
+
flightPlans = {
|
|
659
|
+
list: (input) => this.rest.listFlightPlans(input).then((r) => r.data.flightPlans),
|
|
660
|
+
get: (id) => this.rest.getFlightPlan(id).then((r) => r.data.flightPlan)
|
|
661
|
+
};
|
|
662
|
+
/** Raw AFTN/ATS messages received by AFTN feeds — the input side of the
|
|
663
|
+
* flight-plan pipeline (newest first, capped by the service). */
|
|
664
|
+
aftn = {
|
|
665
|
+
messages: (input) => this.rest.listAftnMessages(input).then((r) => r.data.messages)
|
|
666
|
+
};
|
|
552
667
|
sources = {
|
|
553
668
|
/** Registers a source and returns its token, tied to `airportId` — a
|
|
554
669
|
* source is a sensor physically at one airport, so this is required.
|
|
@@ -669,6 +784,9 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
669
784
|
disconnect() {
|
|
670
785
|
this.wsClient.disconnect();
|
|
671
786
|
}
|
|
787
|
+
reconnect() {
|
|
788
|
+
return this.wsClient.reconnect();
|
|
789
|
+
}
|
|
672
790
|
};
|
|
673
791
|
|
|
674
792
|
// src/react/context/index.tsx
|
|
@@ -703,8 +821,331 @@ function AeroSwarmProvider({
|
|
|
703
821
|
return /* @__PURE__ */ jsx(QueryClientProvider, { client: qc, children: /* @__PURE__ */ jsx(AeroSwarmContext.Provider, { value: { client }, children }) });
|
|
704
822
|
}
|
|
705
823
|
|
|
824
|
+
// src/react/hooks/use-track-motion.ts
|
|
825
|
+
import { useEffect, useMemo as useMemo2, useRef } from "react";
|
|
826
|
+
|
|
827
|
+
// src/shared/motion/index.ts
|
|
828
|
+
var KT_TO_MS = 0.514444;
|
|
829
|
+
var M_PER_DEG = 111320;
|
|
830
|
+
var MAX_PLAUSIBLE_KT = 1500;
|
|
831
|
+
var OFFSET_WINDOW = 20;
|
|
832
|
+
var ALT_EPSILON_FT = 10;
|
|
833
|
+
var HEADING_EPSILON_DEG = 0.5;
|
|
834
|
+
var SURFACE_ALT_FT = 150;
|
|
835
|
+
var clamp01 = (x) => x < 0 ? 0 : x > 1 ? 1 : x;
|
|
836
|
+
var smoothstep = (x) => {
|
|
837
|
+
const c = clamp01(x);
|
|
838
|
+
return c * c * (3 - 2 * c);
|
|
839
|
+
};
|
|
840
|
+
var wrapDeg = (d) => ((d + 180) % 360 + 360) % 360 - 180;
|
|
841
|
+
var norm360 = (d) => (d % 360 + 360) % 360;
|
|
842
|
+
var cosLat = (lat) => Math.max(0.01, Math.cos(lat * Math.PI / 180));
|
|
843
|
+
function validHeading(h) {
|
|
844
|
+
return h != null && Number.isFinite(h) && h >= 0 && h <= 360 ? h % 360 : null;
|
|
845
|
+
}
|
|
846
|
+
function move(lat, lon, bearingDeg, distM) {
|
|
847
|
+
const rad = bearingDeg * Math.PI / 180;
|
|
848
|
+
const dLat = distM * Math.cos(rad) / M_PER_DEG;
|
|
849
|
+
const dLon = distM * Math.sin(rad) / (M_PER_DEG * cosLat(lat + dLat / 2));
|
|
850
|
+
return { lat: lat + dLat, lon: lon + dLon };
|
|
851
|
+
}
|
|
852
|
+
function distanceM(aLat, aLon, bLat, bLon) {
|
|
853
|
+
const dy = (bLat - aLat) * M_PER_DEG;
|
|
854
|
+
const dx = (bLon - aLon) * M_PER_DEG * cosLat((aLat + bLat) / 2);
|
|
855
|
+
return Math.hypot(dx, dy);
|
|
856
|
+
}
|
|
857
|
+
var headingChanged = (a, b) => a === null || b === null ? a !== b : Math.abs(wrapDeg(a - b)) > HEADING_EPSILON_DEG;
|
|
858
|
+
var altitudeChanged = (a, b) => a === null || b === null ? a !== b : Math.abs(a - b) >= ALT_EPSILON_FT;
|
|
859
|
+
var defaultIsSurface = (t) => t.targetType === "vehicle" || t.altitudeFt != null && t.altitudeFt <= SURFACE_ALT_FT;
|
|
860
|
+
function resolve(o) {
|
|
861
|
+
return {
|
|
862
|
+
blendMs: o.blendMs ?? 1500,
|
|
863
|
+
headingSmoothMs: o.headingSmoothMs ?? 800,
|
|
864
|
+
maxExtrapolateMs: o.maxExtrapolateMs ?? { air: 2e4, surface: 8e3 },
|
|
865
|
+
minMovingKt: o.minMovingKt ?? 5,
|
|
866
|
+
snapDistanceM: o.snapDistanceM ?? 5e3,
|
|
867
|
+
frameHz: o.frameHz ?? 30,
|
|
868
|
+
epsilonM: o.epsilonM ?? 0.5,
|
|
869
|
+
extrapolate: o.extrapolate ?? true,
|
|
870
|
+
isSurface: o.isSurface ?? defaultIsSurface,
|
|
871
|
+
clock: o.clock ?? Date.now,
|
|
872
|
+
scheduler: o.scheduler
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
function defaultScheduler() {
|
|
876
|
+
const g = globalThis;
|
|
877
|
+
if (typeof g.requestAnimationFrame === "function" && typeof g.cancelAnimationFrame === "function") {
|
|
878
|
+
return {
|
|
879
|
+
request: (cb) => g.requestAnimationFrame(cb),
|
|
880
|
+
cancel: (h) => g.cancelAnimationFrame(h)
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
return {
|
|
884
|
+
request: (cb) => setTimeout(() => cb(Date.now()), 16),
|
|
885
|
+
cancel: (h) => clearTimeout(h)
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
var Motion = class {
|
|
889
|
+
o;
|
|
890
|
+
entries = /* @__PURE__ */ new Map();
|
|
891
|
+
/** Tracks whose picture can still change. Everything else costs nothing per frame. */
|
|
892
|
+
active = /* @__PURE__ */ new Set();
|
|
893
|
+
listeners = /* @__PURE__ */ new Set();
|
|
894
|
+
offsets = [];
|
|
895
|
+
offset = 0;
|
|
896
|
+
handle = null;
|
|
897
|
+
lastFrameAt = Number.NEGATIVE_INFINITY;
|
|
898
|
+
constructor(options) {
|
|
899
|
+
this.o = resolve(options);
|
|
900
|
+
}
|
|
901
|
+
get size() {
|
|
902
|
+
return this.entries.size;
|
|
903
|
+
}
|
|
904
|
+
offsetMs() {
|
|
905
|
+
return this.offset;
|
|
906
|
+
}
|
|
907
|
+
// ── time ────────────────────────────────────────────────────────────────
|
|
908
|
+
/** Browser clock + learned offset = the server's clock. */
|
|
909
|
+
serverNow(at) {
|
|
910
|
+
return (at ?? this.o.clock()) + this.offset;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* `serverTime - browserNow` is the true offset MINUS the network delay, so the
|
|
914
|
+
* largest recent value is the one with the least delay, i.e. the best estimate.
|
|
915
|
+
*/
|
|
916
|
+
observe(serverTime) {
|
|
917
|
+
if (!Number.isFinite(serverTime)) return;
|
|
918
|
+
this.offsets.push(serverTime - this.o.clock());
|
|
919
|
+
if (this.offsets.length > OFFSET_WINDOW) this.offsets.shift();
|
|
920
|
+
this.offset = Math.max(...this.offsets);
|
|
921
|
+
}
|
|
922
|
+
cap(e) {
|
|
923
|
+
const m = this.o.maxExtrapolateMs;
|
|
924
|
+
return e.surface ? m.surface : m.air;
|
|
925
|
+
}
|
|
926
|
+
// ── model ───────────────────────────────────────────────────────────────
|
|
927
|
+
posAt(e, s) {
|
|
928
|
+
const cap = this.cap(e);
|
|
929
|
+
let elapsed = Math.min(Math.max(0, s - e.fixAt), cap);
|
|
930
|
+
if (e.frozen !== null) elapsed = Math.min(elapsed, e.frozen);
|
|
931
|
+
if (!this.o.extrapolate || e.course === null || e.speedMs <= 0) {
|
|
932
|
+
return { lat: e.fixLat, lon: e.fixLon, extrapolating: false };
|
|
933
|
+
}
|
|
934
|
+
const p = move(e.fixLat, e.fixLon, e.course, e.speedMs * elapsed / 1e3);
|
|
935
|
+
return { ...p, extrapolating: e.frozen === null && elapsed < cap };
|
|
936
|
+
}
|
|
937
|
+
sampleAt(e, s) {
|
|
938
|
+
const p = this.posAt(e, s);
|
|
939
|
+
const rest = 1 - smoothstep((s - e.blendStart) / this.o.blendMs);
|
|
940
|
+
let headingDeg = null;
|
|
941
|
+
if (e.hdgTo !== null) {
|
|
942
|
+
const from = e.hdgFrom ?? e.hdgTo;
|
|
943
|
+
const left = 1 - smoothstep((s - e.hdgStart) / this.o.headingSmoothMs);
|
|
944
|
+
headingDeg = norm360(e.hdgTo + wrapDeg(from - e.hdgTo) * left);
|
|
945
|
+
}
|
|
946
|
+
let altitudeFt = null;
|
|
947
|
+
if (e.altTo !== null) {
|
|
948
|
+
const k = smoothstep((s - e.altStart) / this.o.blendMs);
|
|
949
|
+
altitudeFt = e.altFrom === null ? e.altTo : e.altFrom + (e.altTo - e.altFrom) * k;
|
|
950
|
+
}
|
|
951
|
+
return {
|
|
952
|
+
lat: p.lat + e.errLat * rest,
|
|
953
|
+
lon: p.lon + e.errLon * rest,
|
|
954
|
+
headingDeg,
|
|
955
|
+
altitudeFt,
|
|
956
|
+
moving: e.speedMs > 0,
|
|
957
|
+
extrapolating: p.extrapolating
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
// ── input ───────────────────────────────────────────────────────────────
|
|
961
|
+
update(t, meta) {
|
|
962
|
+
if (meta?.serverTime !== void 0) this.observe(meta.serverTime);
|
|
963
|
+
if (t.lat == null || t.lon == null || !Number.isFinite(t.lat) || !Number.isFinite(t.lon)) {
|
|
964
|
+
this.remove(t.trackId);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const s = this.serverNow();
|
|
968
|
+
const e = this.entries.get(t.trackId);
|
|
969
|
+
if (!e) {
|
|
970
|
+
this.create(t, s);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
this.refresh(e, t, s);
|
|
974
|
+
}
|
|
975
|
+
moving(t, course) {
|
|
976
|
+
const kt = t.groundSpeedKt;
|
|
977
|
+
return course !== null && kt != null && Number.isFinite(kt) && kt >= this.o.minMovingKt && kt <= MAX_PLAUSIBLE_KT;
|
|
978
|
+
}
|
|
979
|
+
create(t, s) {
|
|
980
|
+
const course = validHeading(t.headingDeg);
|
|
981
|
+
const moving = this.moving(t, course);
|
|
982
|
+
const e = {
|
|
983
|
+
id: t.trackId,
|
|
984
|
+
fixLat: t.lat,
|
|
985
|
+
fixLon: t.lon,
|
|
986
|
+
fixAt: t.lastSeenAt,
|
|
987
|
+
speedMs: moving ? t.groundSpeedKt * KT_TO_MS : 0,
|
|
988
|
+
course,
|
|
989
|
+
surface: this.o.isSurface(t),
|
|
990
|
+
frozen: null,
|
|
991
|
+
errLat: 0,
|
|
992
|
+
errLon: 0,
|
|
993
|
+
blendStart: s,
|
|
994
|
+
hdgFrom: course,
|
|
995
|
+
hdgTo: course,
|
|
996
|
+
hdgStart: s,
|
|
997
|
+
altFrom: null,
|
|
998
|
+
altTo: t.altitudeFt,
|
|
999
|
+
altStart: s,
|
|
1000
|
+
settleAt: 0,
|
|
1001
|
+
emitted: null
|
|
1002
|
+
};
|
|
1003
|
+
if (t.status === "lost") e.frozen = this.frozenAt(e, s);
|
|
1004
|
+
this.entries.set(e.id, e);
|
|
1005
|
+
this.settle(e);
|
|
1006
|
+
this.active.add(e.id);
|
|
1007
|
+
this.ensureLoop();
|
|
1008
|
+
}
|
|
1009
|
+
frozenAt(e, s) {
|
|
1010
|
+
return Math.min(Math.max(0, s - e.fixAt), this.cap(e));
|
|
1011
|
+
}
|
|
1012
|
+
refresh(e, t, s) {
|
|
1013
|
+
e.surface = this.o.isSurface(t);
|
|
1014
|
+
if (t.status === "lost") e.frozen ??= this.frozenAt(e, s);
|
|
1015
|
+
else e.frozen = null;
|
|
1016
|
+
const lat = t.lat;
|
|
1017
|
+
const lon = t.lon;
|
|
1018
|
+
const older = t.lastSeenAt < e.fixAt;
|
|
1019
|
+
const same = t.lastSeenAt === e.fixAt && lat === e.fixLat && lon === e.fixLon;
|
|
1020
|
+
if (older || same) {
|
|
1021
|
+
this.settle(e);
|
|
1022
|
+
this.touch(e);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const shown = this.sampleAt(e, s);
|
|
1026
|
+
const course = validHeading(t.headingDeg) ?? e.course;
|
|
1027
|
+
const moving = this.moving(t, course);
|
|
1028
|
+
e.fixLat = lat;
|
|
1029
|
+
e.fixLon = lon;
|
|
1030
|
+
e.fixAt = t.lastSeenAt;
|
|
1031
|
+
e.speedMs = moving ? t.groundSpeedKt * KT_TO_MS : 0;
|
|
1032
|
+
e.course = course;
|
|
1033
|
+
const predicted = this.posAt(e, s);
|
|
1034
|
+
const jump = distanceM(shown.lat, shown.lon, predicted.lat, predicted.lon);
|
|
1035
|
+
const snap = jump > this.o.snapDistanceM;
|
|
1036
|
+
e.errLat = snap ? 0 : shown.lat - predicted.lat;
|
|
1037
|
+
e.errLon = snap ? 0 : shown.lon - predicted.lon;
|
|
1038
|
+
e.blendStart = s;
|
|
1039
|
+
e.hdgFrom = snap ? course : shown.headingDeg ?? course;
|
|
1040
|
+
e.hdgTo = course;
|
|
1041
|
+
e.hdgStart = s;
|
|
1042
|
+
e.altFrom = snap ? t.altitudeFt : shown.altitudeFt;
|
|
1043
|
+
e.altTo = t.altitudeFt;
|
|
1044
|
+
e.altStart = s;
|
|
1045
|
+
this.settle(e);
|
|
1046
|
+
this.touch(e);
|
|
1047
|
+
}
|
|
1048
|
+
/** The server time after which nothing about this track changes on its own. */
|
|
1049
|
+
settle(e) {
|
|
1050
|
+
const ends = [e.blendStart + this.o.blendMs, e.hdgStart + this.o.headingSmoothMs, e.altStart + this.o.blendMs];
|
|
1051
|
+
if (this.o.extrapolate && e.speedMs > 0 && e.frozen === null) ends.push(e.fixAt + this.cap(e));
|
|
1052
|
+
e.settleAt = Math.max(...ends);
|
|
1053
|
+
}
|
|
1054
|
+
touch(e) {
|
|
1055
|
+
this.active.add(e.id);
|
|
1056
|
+
this.ensureLoop();
|
|
1057
|
+
}
|
|
1058
|
+
remove(trackId) {
|
|
1059
|
+
this.entries.delete(trackId);
|
|
1060
|
+
this.active.delete(trackId);
|
|
1061
|
+
}
|
|
1062
|
+
clear() {
|
|
1063
|
+
this.entries.clear();
|
|
1064
|
+
this.active.clear();
|
|
1065
|
+
}
|
|
1066
|
+
// ── output ──────────────────────────────────────────────────────────────
|
|
1067
|
+
sample(trackId, at) {
|
|
1068
|
+
const e = this.entries.get(trackId);
|
|
1069
|
+
return e ? this.sampleAt(e, this.serverNow(at)) : void 0;
|
|
1070
|
+
}
|
|
1071
|
+
differs(a, b) {
|
|
1072
|
+
if (a === null || a.moving !== b.moving || a.extrapolating !== b.extrapolating) return true;
|
|
1073
|
+
if (distanceM(a.lat, a.lon, b.lat, b.lon) > this.o.epsilonM) return true;
|
|
1074
|
+
return headingChanged(a.headingDeg, b.headingDeg) || altitudeChanged(a.altitudeFt, b.altitudeFt);
|
|
1075
|
+
}
|
|
1076
|
+
tick(at) {
|
|
1077
|
+
const s = this.serverNow(at);
|
|
1078
|
+
const changed = /* @__PURE__ */ new Map();
|
|
1079
|
+
for (const id of [...this.active]) {
|
|
1080
|
+
const e = this.entries.get(id);
|
|
1081
|
+
if (!e) {
|
|
1082
|
+
this.active.delete(id);
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
const sample = this.sampleAt(e, s);
|
|
1086
|
+
if (this.differs(e.emitted, sample)) {
|
|
1087
|
+
e.emitted = sample;
|
|
1088
|
+
changed.set(id, sample);
|
|
1089
|
+
}
|
|
1090
|
+
if (s >= e.settleAt) this.active.delete(id);
|
|
1091
|
+
}
|
|
1092
|
+
return changed;
|
|
1093
|
+
}
|
|
1094
|
+
// ── frame loop ──────────────────────────────────────────────────────────
|
|
1095
|
+
start(onFrame) {
|
|
1096
|
+
this.listeners.add(onFrame);
|
|
1097
|
+
this.ensureLoop();
|
|
1098
|
+
return () => {
|
|
1099
|
+
this.listeners.delete(onFrame);
|
|
1100
|
+
if (this.listeners.size === 0) this.cancelLoop();
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
ensureLoop() {
|
|
1104
|
+
if (this.handle !== null || this.listeners.size === 0 || this.active.size === 0) return;
|
|
1105
|
+
const scheduler = this.o.scheduler ?? (this.o.scheduler = defaultScheduler());
|
|
1106
|
+
this.handle = scheduler.request((ts) => this.frame(ts));
|
|
1107
|
+
}
|
|
1108
|
+
cancelLoop() {
|
|
1109
|
+
if (this.handle === null) return;
|
|
1110
|
+
(this.o.scheduler ?? defaultScheduler()).cancel(this.handle);
|
|
1111
|
+
this.handle = null;
|
|
1112
|
+
}
|
|
1113
|
+
frame(ts) {
|
|
1114
|
+
this.handle = null;
|
|
1115
|
+
if (this.listeners.size === 0) return;
|
|
1116
|
+
if (ts - this.lastFrameAt >= 1e3 / this.o.frameHz - 1) {
|
|
1117
|
+
this.lastFrameAt = ts;
|
|
1118
|
+
const changed = this.tick();
|
|
1119
|
+
if (changed.size > 0) {
|
|
1120
|
+
const frame = { at: this.serverNow(), changed };
|
|
1121
|
+
for (const fn of [...this.listeners]) fn(frame);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
this.ensureLoop();
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
function createTrackMotion(options = {}) {
|
|
1128
|
+
return new Motion(options);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// src/react/hooks/use-track-motion.ts
|
|
1132
|
+
function useTrackMotion(options) {
|
|
1133
|
+
const { onFrame, ...motionOptions } = options;
|
|
1134
|
+
const onFrameRef = useRef(onFrame);
|
|
1135
|
+
onFrameRef.current = onFrame;
|
|
1136
|
+
const motion = useMemo2(() => createTrackMotion(motionOptions), []);
|
|
1137
|
+
useEffect(() => {
|
|
1138
|
+
const stop = motion.start((frame) => onFrameRef.current(frame));
|
|
1139
|
+
return () => {
|
|
1140
|
+
stop();
|
|
1141
|
+
motion.clear();
|
|
1142
|
+
};
|
|
1143
|
+
}, [motion]);
|
|
1144
|
+
return motion;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
706
1147
|
// src/react/hooks/use-track-history.ts
|
|
707
|
-
import { useCallback, useEffect, useRef } from "react";
|
|
1148
|
+
import { useCallback, useEffect as useEffect2, useRef as useRef2 } from "react";
|
|
708
1149
|
var DR_MIN_SPEED_KT = 10;
|
|
709
1150
|
var MAX_SPEED_KT = 1200;
|
|
710
1151
|
var GAP_RESET_MS = 3e4;
|
|
@@ -724,22 +1165,22 @@ function useTrackHistory(options) {
|
|
|
724
1165
|
maxJumpDeg = 5,
|
|
725
1166
|
onFrame
|
|
726
1167
|
} = options;
|
|
727
|
-
const currentPositions =
|
|
728
|
-
const lastActualPos =
|
|
729
|
-
const history =
|
|
730
|
-
const lastSeen =
|
|
731
|
-
const lastUpdPerf =
|
|
732
|
-
const interp =
|
|
733
|
-
const velocity =
|
|
734
|
-
const drState =
|
|
735
|
-
const rafRef =
|
|
736
|
-
const onFrameRef =
|
|
1168
|
+
const currentPositions = useRef2(/* @__PURE__ */ new Map());
|
|
1169
|
+
const lastActualPos = useRef2(/* @__PURE__ */ new Map());
|
|
1170
|
+
const history = useRef2(/* @__PURE__ */ new Map());
|
|
1171
|
+
const lastSeen = useRef2(/* @__PURE__ */ new Map());
|
|
1172
|
+
const lastUpdPerf = useRef2(/* @__PURE__ */ new Map());
|
|
1173
|
+
const interp = useRef2(/* @__PURE__ */ new Map());
|
|
1174
|
+
const velocity = useRef2(/* @__PURE__ */ new Map());
|
|
1175
|
+
const drState = useRef2(/* @__PURE__ */ new Map());
|
|
1176
|
+
const rafRef = useRef2(null);
|
|
1177
|
+
const onFrameRef = useRef2(onFrame);
|
|
737
1178
|
onFrameRef.current = onFrame;
|
|
738
|
-
const maxHistoryPointsRef =
|
|
1179
|
+
const maxHistoryPointsRef = useRef2(maxHistoryPoints);
|
|
739
1180
|
maxHistoryPointsRef.current = maxHistoryPoints;
|
|
740
|
-
const minDeltaDegRef =
|
|
1181
|
+
const minDeltaDegRef = useRef2(minDeltaDeg);
|
|
741
1182
|
minDeltaDegRef.current = minDeltaDeg;
|
|
742
|
-
const maxJumpDegRef =
|
|
1183
|
+
const maxJumpDegRef = useRef2(maxJumpDeg);
|
|
743
1184
|
maxJumpDegRef.current = maxJumpDeg;
|
|
744
1185
|
function emitFrame() {
|
|
745
1186
|
onFrameRef.current({
|
|
@@ -902,7 +1343,7 @@ function useTrackHistory(options) {
|
|
|
902
1343
|
drState.current.delete(trackId);
|
|
903
1344
|
emitFrame();
|
|
904
1345
|
}, []);
|
|
905
|
-
|
|
1346
|
+
useEffect2(() => {
|
|
906
1347
|
return () => {
|
|
907
1348
|
if (rafRef.current !== null) {
|
|
908
1349
|
cancelAnimationFrame(rafRef.current);
|
|
@@ -917,7 +1358,7 @@ function useTrackHistory(options) {
|
|
|
917
1358
|
}
|
|
918
1359
|
|
|
919
1360
|
// src/react/hooks/index.ts
|
|
920
|
-
import { useEffect as
|
|
1361
|
+
import { useCallback as useCallback2, useEffect as useEffect3, useMemo as useMemo3, useRef as useRef3 } from "react";
|
|
921
1362
|
import { useQuery } from "@tanstack/react-query";
|
|
922
1363
|
import { useShallow } from "zustand/react/shallow";
|
|
923
1364
|
|
|
@@ -938,14 +1379,14 @@ var useTrackStore = create()((set, get) => ({
|
|
|
938
1379
|
degradedReason: null,
|
|
939
1380
|
applyUpdate(track) {
|
|
940
1381
|
const next = new Map(get().tracks);
|
|
941
|
-
next.set(track.trackId, { ...next.get(track.trackId), ...track });
|
|
1382
|
+
next.set(track.trackId, withLabel({ ...next.get(track.trackId), ...track }));
|
|
942
1383
|
set({ tracks: next, lastUpdateAt: Date.now() });
|
|
943
1384
|
},
|
|
944
1385
|
applyBatch(updates, removedIds) {
|
|
945
1386
|
if (updates.length === 0 && removedIds.length === 0) return;
|
|
946
1387
|
const next = new Map(get().tracks);
|
|
947
1388
|
for (const track of updates) {
|
|
948
|
-
next.set(track.trackId, { ...next.get(track.trackId), ...track });
|
|
1389
|
+
next.set(track.trackId, withLabel({ ...next.get(track.trackId), ...track }));
|
|
949
1390
|
}
|
|
950
1391
|
for (const id of removedIds) next.delete(id);
|
|
951
1392
|
set({ tracks: next, lastUpdateAt: Date.now() });
|
|
@@ -958,7 +1399,7 @@ var useTrackStore = create()((set, get) => ({
|
|
|
958
1399
|
applySnapshot(tracks) {
|
|
959
1400
|
const next = /* @__PURE__ */ new Map();
|
|
960
1401
|
for (const t of tracks) {
|
|
961
|
-
if (isRenderableTrack2(t)) next.set(t.trackId, t);
|
|
1402
|
+
if (isRenderableTrack2(t)) next.set(t.trackId, withLabel(t));
|
|
962
1403
|
}
|
|
963
1404
|
set({ tracks: next, lastUpdateAt: Date.now() });
|
|
964
1405
|
},
|
|
@@ -1017,7 +1458,7 @@ function useAeroTracks(opts = {}) {
|
|
|
1017
1458
|
const isDegraded = useTrackStore(selectIsDegraded);
|
|
1018
1459
|
const lastUpdateAt = useTrackStore(selectLastUpdateAt);
|
|
1019
1460
|
const error = useTrackStore(selectError);
|
|
1020
|
-
const unsubRef =
|
|
1461
|
+
const unsubRef = useRef3(null);
|
|
1021
1462
|
const { isLoading } = useQuery({
|
|
1022
1463
|
queryKey: ["aero-snapshot", opts.bbox?.join(","), opts.source, opts.minConfidence],
|
|
1023
1464
|
enabled: opts.enabled !== false,
|
|
@@ -1031,7 +1472,7 @@ function useAeroTracks(opts = {}) {
|
|
|
1031
1472
|
return result;
|
|
1032
1473
|
}
|
|
1033
1474
|
});
|
|
1034
|
-
|
|
1475
|
+
useEffect3(() => {
|
|
1035
1476
|
if (opts.enabled === false) return;
|
|
1036
1477
|
const actions = useTrackStore.getState();
|
|
1037
1478
|
unsubRef.current = client.tracks.subscribe(
|
|
@@ -1057,6 +1498,7 @@ function useAeroTracks(opts = {}) {
|
|
|
1057
1498
|
useTrackStore.getState().setStatus("idle");
|
|
1058
1499
|
};
|
|
1059
1500
|
}, [opts.bbox?.join(","), opts.source, opts.minConfidence, opts.maxUpdateHz, opts.enabled]);
|
|
1501
|
+
const reconnect = useCallback2(() => client.tracks.reconnect(), [client]);
|
|
1060
1502
|
return {
|
|
1061
1503
|
tracks,
|
|
1062
1504
|
allTracks,
|
|
@@ -1066,12 +1508,13 @@ function useAeroTracks(opts = {}) {
|
|
|
1066
1508
|
isDegraded,
|
|
1067
1509
|
lastUpdateAt,
|
|
1068
1510
|
error,
|
|
1069
|
-
trackCount: tracks.length
|
|
1511
|
+
trackCount: tracks.length,
|
|
1512
|
+
reconnect
|
|
1070
1513
|
};
|
|
1071
1514
|
}
|
|
1072
1515
|
var useTracks = useAeroTracks;
|
|
1073
1516
|
function useAeroTrack(trackId) {
|
|
1074
|
-
const track = useTrackStore(
|
|
1517
|
+
const track = useTrackStore(useMemo3(() => selectTrack(trackId), [trackId]));
|
|
1075
1518
|
return { track };
|
|
1076
1519
|
}
|
|
1077
1520
|
function useAeroSnapshot(opts = {}) {
|
|
@@ -1164,6 +1607,7 @@ export {
|
|
|
1164
1607
|
useSourceHealth,
|
|
1165
1608
|
useTrackCount,
|
|
1166
1609
|
useTrackHistory,
|
|
1610
|
+
useTrackMotion,
|
|
1167
1611
|
useTrackStore,
|
|
1168
1612
|
useTracks,
|
|
1169
1613
|
useWebhooks
|