@aeroswarm/client 0.1.12 → 0.1.14
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 +73 -18
- package/dist/index-ByNBpjNk.d.ts +75 -0
- package/dist/index-CyxVoJXL.d.ts +733 -0
- package/dist/index.d.ts +18 -688
- package/dist/index.js +436 -16
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +24 -3
- package/dist/react/index.js +439 -35
- package/dist/react/index.js.map +1 -1
- package/dist/sdk/index.d.ts +1 -2
- package/dist/sdk/index.js +96 -12
- package/dist/sdk/index.js.map +1 -1
- package/package.json +2 -2
package/dist/react/index.js
CHANGED
|
@@ -144,12 +144,13 @@ var AeroWsClient = class {
|
|
|
144
144
|
this._requestSnapshot();
|
|
145
145
|
}
|
|
146
146
|
this.lastSeq = msg.seq;
|
|
147
|
+
const meta = { serverTime: msg.serverTime, seq: msg.seq };
|
|
147
148
|
switch (msg.type) {
|
|
148
149
|
case "snapshot":
|
|
149
|
-
this.callbacks.onSnapshot?.(msg.payload.tracks);
|
|
150
|
+
this.callbacks.onSnapshot?.(msg.payload.tracks, meta);
|
|
150
151
|
break;
|
|
151
152
|
case "track.updated":
|
|
152
|
-
this.callbacks.onTrackUpdate?.(msg.payload.track);
|
|
153
|
+
this.callbacks.onTrackUpdate?.(msg.payload.track, meta);
|
|
153
154
|
break;
|
|
154
155
|
case "track.removed":
|
|
155
156
|
this.callbacks.onTrackRemoved?.(msg.payload);
|
|
@@ -317,6 +318,85 @@ function createAeroTrpcClient(endpoint, token) {
|
|
|
317
318
|
});
|
|
318
319
|
}
|
|
319
320
|
|
|
321
|
+
// src/sdk/api/response.ts
|
|
322
|
+
var ENVELOPE_HEADER = "x-aero-envelope";
|
|
323
|
+
var STATUS_CODES = [
|
|
324
|
+
[401, "UNAUTHORIZED"],
|
|
325
|
+
[403, "FORBIDDEN"],
|
|
326
|
+
[404, "NOT_FOUND"],
|
|
327
|
+
[400, "BAD_REQUEST"],
|
|
328
|
+
[409, "CONFLICT"],
|
|
329
|
+
[429, "RATE_LIMITED"],
|
|
330
|
+
[503, "UNAVAILABLE"],
|
|
331
|
+
[500, "INTERNAL"]
|
|
332
|
+
];
|
|
333
|
+
function codeForStatus(status) {
|
|
334
|
+
const hit = STATUS_CODES.find(([s]) => s === status);
|
|
335
|
+
if (hit) return hit[1];
|
|
336
|
+
return status >= 500 ? "INTERNAL" : "BAD_REQUEST";
|
|
337
|
+
}
|
|
338
|
+
var AeroApiError = class extends Error {
|
|
339
|
+
status;
|
|
340
|
+
code;
|
|
341
|
+
/** Retrying later may succeed (rate limit, server busy or failing). */
|
|
342
|
+
recoverable;
|
|
343
|
+
details;
|
|
344
|
+
constructor(init) {
|
|
345
|
+
super(init.message);
|
|
346
|
+
this.name = "AeroApiError";
|
|
347
|
+
this.status = init.status;
|
|
348
|
+
this.code = init.code;
|
|
349
|
+
this.recoverable = init.recoverable;
|
|
350
|
+
this.details = init.details;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
354
|
+
function isEnvelope(body) {
|
|
355
|
+
return isObject(body) && body.ok === true && "data" in body;
|
|
356
|
+
}
|
|
357
|
+
function isFailure(body) {
|
|
358
|
+
return isObject(body) && body.ok === false;
|
|
359
|
+
}
|
|
360
|
+
function errorFrom(res, body, text, label) {
|
|
361
|
+
const prefix = label ? `${label} \u2192 ${res.status}` : `HTTP ${res.status}`;
|
|
362
|
+
const failure = isFailure(body) ? body : void 0;
|
|
363
|
+
const e = failure?.error;
|
|
364
|
+
if (isObject(e)) {
|
|
365
|
+
const code = typeof e.code === "string" ? e.code : codeForStatus(res.status);
|
|
366
|
+
const message2 = typeof e.message === "string" ? e.message : res.statusText || prefix;
|
|
367
|
+
return new AeroApiError({
|
|
368
|
+
status: res.status,
|
|
369
|
+
code,
|
|
370
|
+
message: `${prefix}: ${message2}`,
|
|
371
|
+
recoverable: typeof e.recoverable === "boolean" ? e.recoverable : res.status >= 500,
|
|
372
|
+
details: e.details
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
const message = typeof e === "string" ? e : text.slice(0, 200) || res.statusText;
|
|
376
|
+
return new AeroApiError({
|
|
377
|
+
status: res.status,
|
|
378
|
+
code: codeForStatus(res.status),
|
|
379
|
+
message: message ? `${prefix}: ${message}` : prefix,
|
|
380
|
+
recoverable: res.status >= 500 || res.status === 429,
|
|
381
|
+
details: failure?.detail
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
async function readApiBody(res, label) {
|
|
385
|
+
const text = await res.text();
|
|
386
|
+
let body;
|
|
387
|
+
try {
|
|
388
|
+
body = text ? JSON.parse(text) : void 0;
|
|
389
|
+
} catch {
|
|
390
|
+
body = void 0;
|
|
391
|
+
}
|
|
392
|
+
if (!res.ok || isFailure(body)) throw errorFrom(res, body, text, label);
|
|
393
|
+
return body;
|
|
394
|
+
}
|
|
395
|
+
async function parseApiResponse(res, label) {
|
|
396
|
+
const body = await readApiBody(res, label);
|
|
397
|
+
return isEnvelope(body) ? body.data : body;
|
|
398
|
+
}
|
|
399
|
+
|
|
320
400
|
// src/sdk/api/rest.ts
|
|
321
401
|
var AeroRestClient = class {
|
|
322
402
|
constructor(endpoint, token) {
|
|
@@ -334,8 +414,7 @@ var AeroRestClient = class {
|
|
|
334
414
|
},
|
|
335
415
|
body: body ? JSON.stringify(body) : void 0
|
|
336
416
|
});
|
|
337
|
-
|
|
338
|
-
return res.json();
|
|
417
|
+
return await readApiBody(res, `${method} ${path}`);
|
|
339
418
|
}
|
|
340
419
|
createSource(body) {
|
|
341
420
|
return this.req("POST", "/v1/sources", body);
|
|
@@ -557,9 +636,10 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
557
636
|
async federatedFetch(base, path, query) {
|
|
558
637
|
const url = new URL(`${base}${path}`);
|
|
559
638
|
for (const [k, v] of Object.entries(query ?? {})) if (v !== void 0) url.searchParams.set(k, v);
|
|
560
|
-
const res = await fetch(url, {
|
|
561
|
-
|
|
562
|
-
|
|
639
|
+
const res = await fetch(url, {
|
|
640
|
+
headers: { Authorization: `Bearer ${this.opts.token}`, [ENVELOPE_HEADER]: "1" }
|
|
641
|
+
});
|
|
642
|
+
return parseApiResponse(res, `Federated request ${url.pathname}`);
|
|
563
643
|
}
|
|
564
644
|
tracks = {
|
|
565
645
|
subscribe: (opts, callbacks) => {
|
|
@@ -571,15 +651,15 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
571
651
|
this._status = s;
|
|
572
652
|
callbacks.onStatusChange?.(s);
|
|
573
653
|
},
|
|
574
|
-
onSnapshot: (tracks) => {
|
|
654
|
+
onSnapshot: (tracks, meta) => {
|
|
575
655
|
const labeled = this.cache.applySnapshot(tracks);
|
|
576
656
|
this._lastUpdateAt = Date.now();
|
|
577
|
-
callbacks.onSnapshot?.(labeled);
|
|
657
|
+
callbacks.onSnapshot?.(labeled, meta);
|
|
578
658
|
},
|
|
579
|
-
onTrackUpdate: (track) => {
|
|
659
|
+
onTrackUpdate: (track, meta) => {
|
|
580
660
|
const labeled = this.cache.applyUpdate(track);
|
|
581
661
|
this._lastUpdateAt = Date.now();
|
|
582
|
-
callbacks.onTrackUpdate?.(labeled);
|
|
662
|
+
callbacks.onTrackUpdate?.(labeled, meta);
|
|
583
663
|
},
|
|
584
664
|
onTrackRemoved: (ev) => {
|
|
585
665
|
this.cache.applyRemoved(ev);
|
|
@@ -604,7 +684,7 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
604
684
|
reconnect: () => this.wsClient.reconnect(),
|
|
605
685
|
stream: (opts) => {
|
|
606
686
|
const queue = [];
|
|
607
|
-
let
|
|
687
|
+
let resolve2 = null;
|
|
608
688
|
let done = false;
|
|
609
689
|
this.wsClient.connect({ encoding: "json", ...opts }, {
|
|
610
690
|
onSnapshot: (tracks) => {
|
|
@@ -612,9 +692,9 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
612
692
|
},
|
|
613
693
|
onTrackUpdate: (raw) => {
|
|
614
694
|
const t = this.cache.applyUpdate(raw);
|
|
615
|
-
if (
|
|
616
|
-
|
|
617
|
-
|
|
695
|
+
if (resolve2) {
|
|
696
|
+
resolve2({ value: t, done: false });
|
|
697
|
+
resolve2 = null;
|
|
618
698
|
} else queue.push(t);
|
|
619
699
|
},
|
|
620
700
|
onStatusChange: (s) => {
|
|
@@ -627,7 +707,7 @@ var AeroSwarm = class _AeroSwarm {
|
|
|
627
707
|
if (queue.length) return Promise.resolve({ value: queue.shift(), done: false });
|
|
628
708
|
if (done) return Promise.resolve({ value: void 0, done: true });
|
|
629
709
|
return new Promise((r) => {
|
|
630
|
-
|
|
710
|
+
resolve2 = r;
|
|
631
711
|
});
|
|
632
712
|
},
|
|
633
713
|
return: async () => {
|
|
@@ -820,8 +900,331 @@ function AeroSwarmProvider({
|
|
|
820
900
|
return /* @__PURE__ */ jsx(QueryClientProvider, { client: qc, children: /* @__PURE__ */ jsx(AeroSwarmContext.Provider, { value: { client }, children }) });
|
|
821
901
|
}
|
|
822
902
|
|
|
903
|
+
// src/react/hooks/use-track-motion.ts
|
|
904
|
+
import { useEffect, useMemo as useMemo2, useRef } from "react";
|
|
905
|
+
|
|
906
|
+
// src/shared/motion/index.ts
|
|
907
|
+
var KT_TO_MS = 0.514444;
|
|
908
|
+
var M_PER_DEG = 111320;
|
|
909
|
+
var MAX_PLAUSIBLE_KT = 1500;
|
|
910
|
+
var OFFSET_WINDOW = 20;
|
|
911
|
+
var ALT_EPSILON_FT = 10;
|
|
912
|
+
var HEADING_EPSILON_DEG = 0.5;
|
|
913
|
+
var SURFACE_ALT_FT = 150;
|
|
914
|
+
var clamp01 = (x) => x < 0 ? 0 : x > 1 ? 1 : x;
|
|
915
|
+
var smoothstep = (x) => {
|
|
916
|
+
const c = clamp01(x);
|
|
917
|
+
return c * c * (3 - 2 * c);
|
|
918
|
+
};
|
|
919
|
+
var wrapDeg = (d) => ((d + 180) % 360 + 360) % 360 - 180;
|
|
920
|
+
var norm360 = (d) => (d % 360 + 360) % 360;
|
|
921
|
+
var cosLat = (lat) => Math.max(0.01, Math.cos(lat * Math.PI / 180));
|
|
922
|
+
function validHeading(h) {
|
|
923
|
+
return h != null && Number.isFinite(h) && h >= 0 && h <= 360 ? h % 360 : null;
|
|
924
|
+
}
|
|
925
|
+
function move(lat, lon, bearingDeg, distM) {
|
|
926
|
+
const rad = bearingDeg * Math.PI / 180;
|
|
927
|
+
const dLat = distM * Math.cos(rad) / M_PER_DEG;
|
|
928
|
+
const dLon = distM * Math.sin(rad) / (M_PER_DEG * cosLat(lat + dLat / 2));
|
|
929
|
+
return { lat: lat + dLat, lon: lon + dLon };
|
|
930
|
+
}
|
|
931
|
+
function distanceM(aLat, aLon, bLat, bLon) {
|
|
932
|
+
const dy = (bLat - aLat) * M_PER_DEG;
|
|
933
|
+
const dx = (bLon - aLon) * M_PER_DEG * cosLat((aLat + bLat) / 2);
|
|
934
|
+
return Math.hypot(dx, dy);
|
|
935
|
+
}
|
|
936
|
+
var headingChanged = (a, b) => a === null || b === null ? a !== b : Math.abs(wrapDeg(a - b)) > HEADING_EPSILON_DEG;
|
|
937
|
+
var altitudeChanged = (a, b) => a === null || b === null ? a !== b : Math.abs(a - b) >= ALT_EPSILON_FT;
|
|
938
|
+
var defaultIsSurface = (t) => t.targetType === "vehicle" || t.altitudeFt != null && t.altitudeFt <= SURFACE_ALT_FT;
|
|
939
|
+
function resolve(o) {
|
|
940
|
+
return {
|
|
941
|
+
blendMs: o.blendMs ?? 1500,
|
|
942
|
+
headingSmoothMs: o.headingSmoothMs ?? 800,
|
|
943
|
+
maxExtrapolateMs: o.maxExtrapolateMs ?? { air: 2e4, surface: 8e3 },
|
|
944
|
+
minMovingKt: o.minMovingKt ?? 5,
|
|
945
|
+
snapDistanceM: o.snapDistanceM ?? 5e3,
|
|
946
|
+
frameHz: o.frameHz ?? 30,
|
|
947
|
+
epsilonM: o.epsilonM ?? 0.5,
|
|
948
|
+
extrapolate: o.extrapolate ?? true,
|
|
949
|
+
isSurface: o.isSurface ?? defaultIsSurface,
|
|
950
|
+
clock: o.clock ?? Date.now,
|
|
951
|
+
scheduler: o.scheduler
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
function defaultScheduler() {
|
|
955
|
+
const g = globalThis;
|
|
956
|
+
if (typeof g.requestAnimationFrame === "function" && typeof g.cancelAnimationFrame === "function") {
|
|
957
|
+
return {
|
|
958
|
+
request: (cb) => g.requestAnimationFrame(cb),
|
|
959
|
+
cancel: (h) => g.cancelAnimationFrame(h)
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
return {
|
|
963
|
+
request: (cb) => setTimeout(() => cb(Date.now()), 16),
|
|
964
|
+
cancel: (h) => clearTimeout(h)
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
var Motion = class {
|
|
968
|
+
o;
|
|
969
|
+
entries = /* @__PURE__ */ new Map();
|
|
970
|
+
/** Tracks whose picture can still change. Everything else costs nothing per frame. */
|
|
971
|
+
active = /* @__PURE__ */ new Set();
|
|
972
|
+
listeners = /* @__PURE__ */ new Set();
|
|
973
|
+
offsets = [];
|
|
974
|
+
offset = 0;
|
|
975
|
+
handle = null;
|
|
976
|
+
lastFrameAt = Number.NEGATIVE_INFINITY;
|
|
977
|
+
constructor(options) {
|
|
978
|
+
this.o = resolve(options);
|
|
979
|
+
}
|
|
980
|
+
get size() {
|
|
981
|
+
return this.entries.size;
|
|
982
|
+
}
|
|
983
|
+
offsetMs() {
|
|
984
|
+
return this.offset;
|
|
985
|
+
}
|
|
986
|
+
// ── time ────────────────────────────────────────────────────────────────
|
|
987
|
+
/** Browser clock + learned offset = the server's clock. */
|
|
988
|
+
serverNow(at) {
|
|
989
|
+
return (at ?? this.o.clock()) + this.offset;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* `serverTime - browserNow` is the true offset MINUS the network delay, so the
|
|
993
|
+
* largest recent value is the one with the least delay, i.e. the best estimate.
|
|
994
|
+
*/
|
|
995
|
+
observe(serverTime) {
|
|
996
|
+
if (!Number.isFinite(serverTime)) return;
|
|
997
|
+
this.offsets.push(serverTime - this.o.clock());
|
|
998
|
+
if (this.offsets.length > OFFSET_WINDOW) this.offsets.shift();
|
|
999
|
+
this.offset = Math.max(...this.offsets);
|
|
1000
|
+
}
|
|
1001
|
+
cap(e) {
|
|
1002
|
+
const m = this.o.maxExtrapolateMs;
|
|
1003
|
+
return e.surface ? m.surface : m.air;
|
|
1004
|
+
}
|
|
1005
|
+
// ── model ───────────────────────────────────────────────────────────────
|
|
1006
|
+
posAt(e, s) {
|
|
1007
|
+
const cap = this.cap(e);
|
|
1008
|
+
let elapsed = Math.min(Math.max(0, s - e.fixAt), cap);
|
|
1009
|
+
if (e.frozen !== null) elapsed = Math.min(elapsed, e.frozen);
|
|
1010
|
+
if (!this.o.extrapolate || e.course === null || e.speedMs <= 0) {
|
|
1011
|
+
return { lat: e.fixLat, lon: e.fixLon, extrapolating: false };
|
|
1012
|
+
}
|
|
1013
|
+
const p = move(e.fixLat, e.fixLon, e.course, e.speedMs * elapsed / 1e3);
|
|
1014
|
+
return { ...p, extrapolating: e.frozen === null && elapsed < cap };
|
|
1015
|
+
}
|
|
1016
|
+
sampleAt(e, s) {
|
|
1017
|
+
const p = this.posAt(e, s);
|
|
1018
|
+
const rest = 1 - smoothstep((s - e.blendStart) / this.o.blendMs);
|
|
1019
|
+
let headingDeg = null;
|
|
1020
|
+
if (e.hdgTo !== null) {
|
|
1021
|
+
const from = e.hdgFrom ?? e.hdgTo;
|
|
1022
|
+
const left = 1 - smoothstep((s - e.hdgStart) / this.o.headingSmoothMs);
|
|
1023
|
+
headingDeg = norm360(e.hdgTo + wrapDeg(from - e.hdgTo) * left);
|
|
1024
|
+
}
|
|
1025
|
+
let altitudeFt = null;
|
|
1026
|
+
if (e.altTo !== null) {
|
|
1027
|
+
const k = smoothstep((s - e.altStart) / this.o.blendMs);
|
|
1028
|
+
altitudeFt = e.altFrom === null ? e.altTo : e.altFrom + (e.altTo - e.altFrom) * k;
|
|
1029
|
+
}
|
|
1030
|
+
return {
|
|
1031
|
+
lat: p.lat + e.errLat * rest,
|
|
1032
|
+
lon: p.lon + e.errLon * rest,
|
|
1033
|
+
headingDeg,
|
|
1034
|
+
altitudeFt,
|
|
1035
|
+
moving: e.speedMs > 0,
|
|
1036
|
+
extrapolating: p.extrapolating
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
// ── input ───────────────────────────────────────────────────────────────
|
|
1040
|
+
update(t, meta) {
|
|
1041
|
+
if (meta?.serverTime !== void 0) this.observe(meta.serverTime);
|
|
1042
|
+
if (t.lat == null || t.lon == null || !Number.isFinite(t.lat) || !Number.isFinite(t.lon)) {
|
|
1043
|
+
this.remove(t.trackId);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
const s = this.serverNow();
|
|
1047
|
+
const e = this.entries.get(t.trackId);
|
|
1048
|
+
if (!e) {
|
|
1049
|
+
this.create(t, s);
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
this.refresh(e, t, s);
|
|
1053
|
+
}
|
|
1054
|
+
moving(t, course) {
|
|
1055
|
+
const kt = t.groundSpeedKt;
|
|
1056
|
+
return course !== null && kt != null && Number.isFinite(kt) && kt >= this.o.minMovingKt && kt <= MAX_PLAUSIBLE_KT;
|
|
1057
|
+
}
|
|
1058
|
+
create(t, s) {
|
|
1059
|
+
const course = validHeading(t.headingDeg);
|
|
1060
|
+
const moving = this.moving(t, course);
|
|
1061
|
+
const e = {
|
|
1062
|
+
id: t.trackId,
|
|
1063
|
+
fixLat: t.lat,
|
|
1064
|
+
fixLon: t.lon,
|
|
1065
|
+
fixAt: t.lastSeenAt,
|
|
1066
|
+
speedMs: moving ? t.groundSpeedKt * KT_TO_MS : 0,
|
|
1067
|
+
course,
|
|
1068
|
+
surface: this.o.isSurface(t),
|
|
1069
|
+
frozen: null,
|
|
1070
|
+
errLat: 0,
|
|
1071
|
+
errLon: 0,
|
|
1072
|
+
blendStart: s,
|
|
1073
|
+
hdgFrom: course,
|
|
1074
|
+
hdgTo: course,
|
|
1075
|
+
hdgStart: s,
|
|
1076
|
+
altFrom: null,
|
|
1077
|
+
altTo: t.altitudeFt,
|
|
1078
|
+
altStart: s,
|
|
1079
|
+
settleAt: 0,
|
|
1080
|
+
emitted: null
|
|
1081
|
+
};
|
|
1082
|
+
if (t.status === "lost") e.frozen = this.frozenAt(e, s);
|
|
1083
|
+
this.entries.set(e.id, e);
|
|
1084
|
+
this.settle(e);
|
|
1085
|
+
this.active.add(e.id);
|
|
1086
|
+
this.ensureLoop();
|
|
1087
|
+
}
|
|
1088
|
+
frozenAt(e, s) {
|
|
1089
|
+
return Math.min(Math.max(0, s - e.fixAt), this.cap(e));
|
|
1090
|
+
}
|
|
1091
|
+
refresh(e, t, s) {
|
|
1092
|
+
e.surface = this.o.isSurface(t);
|
|
1093
|
+
if (t.status === "lost") e.frozen ??= this.frozenAt(e, s);
|
|
1094
|
+
else e.frozen = null;
|
|
1095
|
+
const lat = t.lat;
|
|
1096
|
+
const lon = t.lon;
|
|
1097
|
+
const older = t.lastSeenAt < e.fixAt;
|
|
1098
|
+
const same = t.lastSeenAt === e.fixAt && lat === e.fixLat && lon === e.fixLon;
|
|
1099
|
+
if (older || same) {
|
|
1100
|
+
this.settle(e);
|
|
1101
|
+
this.touch(e);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
const shown = this.sampleAt(e, s);
|
|
1105
|
+
const course = validHeading(t.headingDeg) ?? e.course;
|
|
1106
|
+
const moving = this.moving(t, course);
|
|
1107
|
+
e.fixLat = lat;
|
|
1108
|
+
e.fixLon = lon;
|
|
1109
|
+
e.fixAt = t.lastSeenAt;
|
|
1110
|
+
e.speedMs = moving ? t.groundSpeedKt * KT_TO_MS : 0;
|
|
1111
|
+
e.course = course;
|
|
1112
|
+
const predicted = this.posAt(e, s);
|
|
1113
|
+
const jump = distanceM(shown.lat, shown.lon, predicted.lat, predicted.lon);
|
|
1114
|
+
const snap = jump > this.o.snapDistanceM;
|
|
1115
|
+
e.errLat = snap ? 0 : shown.lat - predicted.lat;
|
|
1116
|
+
e.errLon = snap ? 0 : shown.lon - predicted.lon;
|
|
1117
|
+
e.blendStart = s;
|
|
1118
|
+
e.hdgFrom = snap ? course : shown.headingDeg ?? course;
|
|
1119
|
+
e.hdgTo = course;
|
|
1120
|
+
e.hdgStart = s;
|
|
1121
|
+
e.altFrom = snap ? t.altitudeFt : shown.altitudeFt;
|
|
1122
|
+
e.altTo = t.altitudeFt;
|
|
1123
|
+
e.altStart = s;
|
|
1124
|
+
this.settle(e);
|
|
1125
|
+
this.touch(e);
|
|
1126
|
+
}
|
|
1127
|
+
/** The server time after which nothing about this track changes on its own. */
|
|
1128
|
+
settle(e) {
|
|
1129
|
+
const ends = [e.blendStart + this.o.blendMs, e.hdgStart + this.o.headingSmoothMs, e.altStart + this.o.blendMs];
|
|
1130
|
+
if (this.o.extrapolate && e.speedMs > 0 && e.frozen === null) ends.push(e.fixAt + this.cap(e));
|
|
1131
|
+
e.settleAt = Math.max(...ends);
|
|
1132
|
+
}
|
|
1133
|
+
touch(e) {
|
|
1134
|
+
this.active.add(e.id);
|
|
1135
|
+
this.ensureLoop();
|
|
1136
|
+
}
|
|
1137
|
+
remove(trackId) {
|
|
1138
|
+
this.entries.delete(trackId);
|
|
1139
|
+
this.active.delete(trackId);
|
|
1140
|
+
}
|
|
1141
|
+
clear() {
|
|
1142
|
+
this.entries.clear();
|
|
1143
|
+
this.active.clear();
|
|
1144
|
+
}
|
|
1145
|
+
// ── output ──────────────────────────────────────────────────────────────
|
|
1146
|
+
sample(trackId, at) {
|
|
1147
|
+
const e = this.entries.get(trackId);
|
|
1148
|
+
return e ? this.sampleAt(e, this.serverNow(at)) : void 0;
|
|
1149
|
+
}
|
|
1150
|
+
differs(a, b) {
|
|
1151
|
+
if (a === null || a.moving !== b.moving || a.extrapolating !== b.extrapolating) return true;
|
|
1152
|
+
if (distanceM(a.lat, a.lon, b.lat, b.lon) > this.o.epsilonM) return true;
|
|
1153
|
+
return headingChanged(a.headingDeg, b.headingDeg) || altitudeChanged(a.altitudeFt, b.altitudeFt);
|
|
1154
|
+
}
|
|
1155
|
+
tick(at) {
|
|
1156
|
+
const s = this.serverNow(at);
|
|
1157
|
+
const changed = /* @__PURE__ */ new Map();
|
|
1158
|
+
for (const id of [...this.active]) {
|
|
1159
|
+
const e = this.entries.get(id);
|
|
1160
|
+
if (!e) {
|
|
1161
|
+
this.active.delete(id);
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
const sample = this.sampleAt(e, s);
|
|
1165
|
+
if (this.differs(e.emitted, sample)) {
|
|
1166
|
+
e.emitted = sample;
|
|
1167
|
+
changed.set(id, sample);
|
|
1168
|
+
}
|
|
1169
|
+
if (s >= e.settleAt) this.active.delete(id);
|
|
1170
|
+
}
|
|
1171
|
+
return changed;
|
|
1172
|
+
}
|
|
1173
|
+
// ── frame loop ──────────────────────────────────────────────────────────
|
|
1174
|
+
start(onFrame) {
|
|
1175
|
+
this.listeners.add(onFrame);
|
|
1176
|
+
this.ensureLoop();
|
|
1177
|
+
return () => {
|
|
1178
|
+
this.listeners.delete(onFrame);
|
|
1179
|
+
if (this.listeners.size === 0) this.cancelLoop();
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
ensureLoop() {
|
|
1183
|
+
if (this.handle !== null || this.listeners.size === 0 || this.active.size === 0) return;
|
|
1184
|
+
const scheduler = this.o.scheduler ?? (this.o.scheduler = defaultScheduler());
|
|
1185
|
+
this.handle = scheduler.request((ts) => this.frame(ts));
|
|
1186
|
+
}
|
|
1187
|
+
cancelLoop() {
|
|
1188
|
+
if (this.handle === null) return;
|
|
1189
|
+
(this.o.scheduler ?? defaultScheduler()).cancel(this.handle);
|
|
1190
|
+
this.handle = null;
|
|
1191
|
+
}
|
|
1192
|
+
frame(ts) {
|
|
1193
|
+
this.handle = null;
|
|
1194
|
+
if (this.listeners.size === 0) return;
|
|
1195
|
+
if (ts - this.lastFrameAt >= 1e3 / this.o.frameHz - 1) {
|
|
1196
|
+
this.lastFrameAt = ts;
|
|
1197
|
+
const changed = this.tick();
|
|
1198
|
+
if (changed.size > 0) {
|
|
1199
|
+
const frame = { at: this.serverNow(), changed };
|
|
1200
|
+
for (const fn of [...this.listeners]) fn(frame);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
this.ensureLoop();
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
function createTrackMotion(options = {}) {
|
|
1207
|
+
return new Motion(options);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// src/react/hooks/use-track-motion.ts
|
|
1211
|
+
function useTrackMotion(options) {
|
|
1212
|
+
const { onFrame, ...motionOptions } = options;
|
|
1213
|
+
const onFrameRef = useRef(onFrame);
|
|
1214
|
+
onFrameRef.current = onFrame;
|
|
1215
|
+
const motion = useMemo2(() => createTrackMotion(motionOptions), []);
|
|
1216
|
+
useEffect(() => {
|
|
1217
|
+
const stop = motion.start((frame) => onFrameRef.current(frame));
|
|
1218
|
+
return () => {
|
|
1219
|
+
stop();
|
|
1220
|
+
motion.clear();
|
|
1221
|
+
};
|
|
1222
|
+
}, [motion]);
|
|
1223
|
+
return motion;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
823
1226
|
// src/react/hooks/use-track-history.ts
|
|
824
|
-
import { useCallback, useEffect, useRef } from "react";
|
|
1227
|
+
import { useCallback, useEffect as useEffect2, useRef as useRef2 } from "react";
|
|
825
1228
|
var DR_MIN_SPEED_KT = 10;
|
|
826
1229
|
var MAX_SPEED_KT = 1200;
|
|
827
1230
|
var GAP_RESET_MS = 3e4;
|
|
@@ -841,22 +1244,22 @@ function useTrackHistory(options) {
|
|
|
841
1244
|
maxJumpDeg = 5,
|
|
842
1245
|
onFrame
|
|
843
1246
|
} = options;
|
|
844
|
-
const currentPositions =
|
|
845
|
-
const lastActualPos =
|
|
846
|
-
const history =
|
|
847
|
-
const lastSeen =
|
|
848
|
-
const lastUpdPerf =
|
|
849
|
-
const interp =
|
|
850
|
-
const velocity =
|
|
851
|
-
const drState =
|
|
852
|
-
const rafRef =
|
|
853
|
-
const onFrameRef =
|
|
1247
|
+
const currentPositions = useRef2(/* @__PURE__ */ new Map());
|
|
1248
|
+
const lastActualPos = useRef2(/* @__PURE__ */ new Map());
|
|
1249
|
+
const history = useRef2(/* @__PURE__ */ new Map());
|
|
1250
|
+
const lastSeen = useRef2(/* @__PURE__ */ new Map());
|
|
1251
|
+
const lastUpdPerf = useRef2(/* @__PURE__ */ new Map());
|
|
1252
|
+
const interp = useRef2(/* @__PURE__ */ new Map());
|
|
1253
|
+
const velocity = useRef2(/* @__PURE__ */ new Map());
|
|
1254
|
+
const drState = useRef2(/* @__PURE__ */ new Map());
|
|
1255
|
+
const rafRef = useRef2(null);
|
|
1256
|
+
const onFrameRef = useRef2(onFrame);
|
|
854
1257
|
onFrameRef.current = onFrame;
|
|
855
|
-
const maxHistoryPointsRef =
|
|
1258
|
+
const maxHistoryPointsRef = useRef2(maxHistoryPoints);
|
|
856
1259
|
maxHistoryPointsRef.current = maxHistoryPoints;
|
|
857
|
-
const minDeltaDegRef =
|
|
1260
|
+
const minDeltaDegRef = useRef2(minDeltaDeg);
|
|
858
1261
|
minDeltaDegRef.current = minDeltaDeg;
|
|
859
|
-
const maxJumpDegRef =
|
|
1262
|
+
const maxJumpDegRef = useRef2(maxJumpDeg);
|
|
860
1263
|
maxJumpDegRef.current = maxJumpDeg;
|
|
861
1264
|
function emitFrame() {
|
|
862
1265
|
onFrameRef.current({
|
|
@@ -1019,7 +1422,7 @@ function useTrackHistory(options) {
|
|
|
1019
1422
|
drState.current.delete(trackId);
|
|
1020
1423
|
emitFrame();
|
|
1021
1424
|
}, []);
|
|
1022
|
-
|
|
1425
|
+
useEffect2(() => {
|
|
1023
1426
|
return () => {
|
|
1024
1427
|
if (rafRef.current !== null) {
|
|
1025
1428
|
cancelAnimationFrame(rafRef.current);
|
|
@@ -1034,7 +1437,7 @@ function useTrackHistory(options) {
|
|
|
1034
1437
|
}
|
|
1035
1438
|
|
|
1036
1439
|
// src/react/hooks/index.ts
|
|
1037
|
-
import { useCallback as useCallback2, useEffect as
|
|
1440
|
+
import { useCallback as useCallback2, useEffect as useEffect3, useMemo as useMemo3, useRef as useRef3 } from "react";
|
|
1038
1441
|
import { useQuery } from "@tanstack/react-query";
|
|
1039
1442
|
import { useShallow } from "zustand/react/shallow";
|
|
1040
1443
|
|
|
@@ -1134,7 +1537,7 @@ function useAeroTracks(opts = {}) {
|
|
|
1134
1537
|
const isDegraded = useTrackStore(selectIsDegraded);
|
|
1135
1538
|
const lastUpdateAt = useTrackStore(selectLastUpdateAt);
|
|
1136
1539
|
const error = useTrackStore(selectError);
|
|
1137
|
-
const unsubRef =
|
|
1540
|
+
const unsubRef = useRef3(null);
|
|
1138
1541
|
const { isLoading } = useQuery({
|
|
1139
1542
|
queryKey: ["aero-snapshot", opts.bbox?.join(","), opts.source, opts.minConfidence],
|
|
1140
1543
|
enabled: opts.enabled !== false,
|
|
@@ -1148,7 +1551,7 @@ function useAeroTracks(opts = {}) {
|
|
|
1148
1551
|
return result;
|
|
1149
1552
|
}
|
|
1150
1553
|
});
|
|
1151
|
-
|
|
1554
|
+
useEffect3(() => {
|
|
1152
1555
|
if (opts.enabled === false) return;
|
|
1153
1556
|
const actions = useTrackStore.getState();
|
|
1154
1557
|
unsubRef.current = client.tracks.subscribe(
|
|
@@ -1190,7 +1593,7 @@ function useAeroTracks(opts = {}) {
|
|
|
1190
1593
|
}
|
|
1191
1594
|
var useTracks = useAeroTracks;
|
|
1192
1595
|
function useAeroTrack(trackId) {
|
|
1193
|
-
const track = useTrackStore(
|
|
1596
|
+
const track = useTrackStore(useMemo3(() => selectTrack(trackId), [trackId]));
|
|
1194
1597
|
return { track };
|
|
1195
1598
|
}
|
|
1196
1599
|
function useAeroSnapshot(opts = {}) {
|
|
@@ -1283,6 +1686,7 @@ export {
|
|
|
1283
1686
|
useSourceHealth,
|
|
1284
1687
|
useTrackCount,
|
|
1285
1688
|
useTrackHistory,
|
|
1689
|
+
useTrackMotion,
|
|
1286
1690
|
useTrackStore,
|
|
1287
1691
|
useTracks,
|
|
1288
1692
|
useWebhooks
|