@aeroswarm/client 0.1.12 → 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.
@@ -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);
@@ -571,15 +572,15 @@ var AeroSwarm = class _AeroSwarm {
571
572
  this._status = s;
572
573
  callbacks.onStatusChange?.(s);
573
574
  },
574
- onSnapshot: (tracks) => {
575
+ onSnapshot: (tracks, meta) => {
575
576
  const labeled = this.cache.applySnapshot(tracks);
576
577
  this._lastUpdateAt = Date.now();
577
- callbacks.onSnapshot?.(labeled);
578
+ callbacks.onSnapshot?.(labeled, meta);
578
579
  },
579
- onTrackUpdate: (track) => {
580
+ onTrackUpdate: (track, meta) => {
580
581
  const labeled = this.cache.applyUpdate(track);
581
582
  this._lastUpdateAt = Date.now();
582
- callbacks.onTrackUpdate?.(labeled);
583
+ callbacks.onTrackUpdate?.(labeled, meta);
583
584
  },
584
585
  onTrackRemoved: (ev) => {
585
586
  this.cache.applyRemoved(ev);
@@ -604,7 +605,7 @@ var AeroSwarm = class _AeroSwarm {
604
605
  reconnect: () => this.wsClient.reconnect(),
605
606
  stream: (opts) => {
606
607
  const queue = [];
607
- let resolve = null;
608
+ let resolve2 = null;
608
609
  let done = false;
609
610
  this.wsClient.connect({ encoding: "json", ...opts }, {
610
611
  onSnapshot: (tracks) => {
@@ -612,9 +613,9 @@ var AeroSwarm = class _AeroSwarm {
612
613
  },
613
614
  onTrackUpdate: (raw) => {
614
615
  const t = this.cache.applyUpdate(raw);
615
- if (resolve) {
616
- resolve({ value: t, done: false });
617
- resolve = null;
616
+ if (resolve2) {
617
+ resolve2({ value: t, done: false });
618
+ resolve2 = null;
618
619
  } else queue.push(t);
619
620
  },
620
621
  onStatusChange: (s) => {
@@ -627,7 +628,7 @@ var AeroSwarm = class _AeroSwarm {
627
628
  if (queue.length) return Promise.resolve({ value: queue.shift(), done: false });
628
629
  if (done) return Promise.resolve({ value: void 0, done: true });
629
630
  return new Promise((r) => {
630
- resolve = r;
631
+ resolve2 = r;
631
632
  });
632
633
  },
633
634
  return: async () => {
@@ -820,8 +821,331 @@ function AeroSwarmProvider({
820
821
  return /* @__PURE__ */ jsx(QueryClientProvider, { client: qc, children: /* @__PURE__ */ jsx(AeroSwarmContext.Provider, { value: { client }, children }) });
821
822
  }
822
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
+
823
1147
  // src/react/hooks/use-track-history.ts
824
- import { useCallback, useEffect, useRef } from "react";
1148
+ import { useCallback, useEffect as useEffect2, useRef as useRef2 } from "react";
825
1149
  var DR_MIN_SPEED_KT = 10;
826
1150
  var MAX_SPEED_KT = 1200;
827
1151
  var GAP_RESET_MS = 3e4;
@@ -841,22 +1165,22 @@ function useTrackHistory(options) {
841
1165
  maxJumpDeg = 5,
842
1166
  onFrame
843
1167
  } = options;
844
- const currentPositions = useRef(/* @__PURE__ */ new Map());
845
- const lastActualPos = useRef(/* @__PURE__ */ new Map());
846
- const history = useRef(/* @__PURE__ */ new Map());
847
- const lastSeen = useRef(/* @__PURE__ */ new Map());
848
- const lastUpdPerf = useRef(/* @__PURE__ */ new Map());
849
- const interp = useRef(/* @__PURE__ */ new Map());
850
- const velocity = useRef(/* @__PURE__ */ new Map());
851
- const drState = useRef(/* @__PURE__ */ new Map());
852
- const rafRef = useRef(null);
853
- const onFrameRef = useRef(onFrame);
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);
854
1178
  onFrameRef.current = onFrame;
855
- const maxHistoryPointsRef = useRef(maxHistoryPoints);
1179
+ const maxHistoryPointsRef = useRef2(maxHistoryPoints);
856
1180
  maxHistoryPointsRef.current = maxHistoryPoints;
857
- const minDeltaDegRef = useRef(minDeltaDeg);
1181
+ const minDeltaDegRef = useRef2(minDeltaDeg);
858
1182
  minDeltaDegRef.current = minDeltaDeg;
859
- const maxJumpDegRef = useRef(maxJumpDeg);
1183
+ const maxJumpDegRef = useRef2(maxJumpDeg);
860
1184
  maxJumpDegRef.current = maxJumpDeg;
861
1185
  function emitFrame() {
862
1186
  onFrameRef.current({
@@ -1019,7 +1343,7 @@ function useTrackHistory(options) {
1019
1343
  drState.current.delete(trackId);
1020
1344
  emitFrame();
1021
1345
  }, []);
1022
- useEffect(() => {
1346
+ useEffect2(() => {
1023
1347
  return () => {
1024
1348
  if (rafRef.current !== null) {
1025
1349
  cancelAnimationFrame(rafRef.current);
@@ -1034,7 +1358,7 @@ function useTrackHistory(options) {
1034
1358
  }
1035
1359
 
1036
1360
  // src/react/hooks/index.ts
1037
- import { useCallback as useCallback2, useEffect as useEffect2, useMemo as useMemo2, useRef as useRef2 } from "react";
1361
+ import { useCallback as useCallback2, useEffect as useEffect3, useMemo as useMemo3, useRef as useRef3 } from "react";
1038
1362
  import { useQuery } from "@tanstack/react-query";
1039
1363
  import { useShallow } from "zustand/react/shallow";
1040
1364
 
@@ -1134,7 +1458,7 @@ function useAeroTracks(opts = {}) {
1134
1458
  const isDegraded = useTrackStore(selectIsDegraded);
1135
1459
  const lastUpdateAt = useTrackStore(selectLastUpdateAt);
1136
1460
  const error = useTrackStore(selectError);
1137
- const unsubRef = useRef2(null);
1461
+ const unsubRef = useRef3(null);
1138
1462
  const { isLoading } = useQuery({
1139
1463
  queryKey: ["aero-snapshot", opts.bbox?.join(","), opts.source, opts.minConfidence],
1140
1464
  enabled: opts.enabled !== false,
@@ -1148,7 +1472,7 @@ function useAeroTracks(opts = {}) {
1148
1472
  return result;
1149
1473
  }
1150
1474
  });
1151
- useEffect2(() => {
1475
+ useEffect3(() => {
1152
1476
  if (opts.enabled === false) return;
1153
1477
  const actions = useTrackStore.getState();
1154
1478
  unsubRef.current = client.tracks.subscribe(
@@ -1190,7 +1514,7 @@ function useAeroTracks(opts = {}) {
1190
1514
  }
1191
1515
  var useTracks = useAeroTracks;
1192
1516
  function useAeroTrack(trackId) {
1193
- const track = useTrackStore(useMemo2(() => selectTrack(trackId), [trackId]));
1517
+ const track = useTrackStore(useMemo3(() => selectTrack(trackId), [trackId]));
1194
1518
  return { track };
1195
1519
  }
1196
1520
  function useAeroSnapshot(opts = {}) {
@@ -1283,6 +1607,7 @@ export {
1283
1607
  useSourceHealth,
1284
1608
  useTrackCount,
1285
1609
  useTrackHistory,
1610
+ useTrackMotion,
1286
1611
  useTrackStore,
1287
1612
  useTracks,
1288
1613
  useWebhooks