@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.
package/dist/index.js CHANGED
@@ -140,12 +140,13 @@ var AeroWsClient = class {
140
140
  this._requestSnapshot();
141
141
  }
142
142
  this.lastSeq = msg.seq;
143
+ const meta = { serverTime: msg.serverTime, seq: msg.seq };
143
144
  switch (msg.type) {
144
145
  case "snapshot":
145
- this.callbacks.onSnapshot?.(msg.payload.tracks);
146
+ this.callbacks.onSnapshot?.(msg.payload.tracks, meta);
146
147
  break;
147
148
  case "track.updated":
148
- this.callbacks.onTrackUpdate?.(msg.payload.track);
149
+ this.callbacks.onTrackUpdate?.(msg.payload.track, meta);
149
150
  break;
150
151
  case "track.removed":
151
152
  this.callbacks.onTrackRemoved?.(msg.payload);
@@ -567,15 +568,15 @@ var AeroSwarm = class _AeroSwarm {
567
568
  this._status = s;
568
569
  callbacks.onStatusChange?.(s);
569
570
  },
570
- onSnapshot: (tracks) => {
571
+ onSnapshot: (tracks, meta) => {
571
572
  const labeled = this.cache.applySnapshot(tracks);
572
573
  this._lastUpdateAt = Date.now();
573
- callbacks.onSnapshot?.(labeled);
574
+ callbacks.onSnapshot?.(labeled, meta);
574
575
  },
575
- onTrackUpdate: (track) => {
576
+ onTrackUpdate: (track, meta) => {
576
577
  const labeled = this.cache.applyUpdate(track);
577
578
  this._lastUpdateAt = Date.now();
578
- callbacks.onTrackUpdate?.(labeled);
579
+ callbacks.onTrackUpdate?.(labeled, meta);
579
580
  },
580
581
  onTrackRemoved: (ev) => {
581
582
  this.cache.applyRemoved(ev);
@@ -600,7 +601,7 @@ var AeroSwarm = class _AeroSwarm {
600
601
  reconnect: () => this.wsClient.reconnect(),
601
602
  stream: (opts) => {
602
603
  const queue = [];
603
- let resolve = null;
604
+ let resolve2 = null;
604
605
  let done = false;
605
606
  this.wsClient.connect({ encoding: "json", ...opts }, {
606
607
  onSnapshot: (tracks) => {
@@ -608,9 +609,9 @@ var AeroSwarm = class _AeroSwarm {
608
609
  },
609
610
  onTrackUpdate: (raw) => {
610
611
  const t = this.cache.applyUpdate(raw);
611
- if (resolve) {
612
- resolve({ value: t, done: false });
613
- resolve = null;
612
+ if (resolve2) {
613
+ resolve2({ value: t, done: false });
614
+ resolve2 = null;
614
615
  } else queue.push(t);
615
616
  },
616
617
  onStatusChange: (s) => {
@@ -623,7 +624,7 @@ var AeroSwarm = class _AeroSwarm {
623
624
  if (queue.length) return Promise.resolve({ value: queue.shift(), done: false });
624
625
  if (done) return Promise.resolve({ value: void 0, done: true });
625
626
  return new Promise((r) => {
626
- resolve = r;
627
+ resolve2 = r;
627
628
  });
628
629
  },
629
630
  return: async () => {
@@ -786,6 +787,310 @@ var AeroSwarm = class _AeroSwarm {
786
787
 
787
788
  // src/shared/utils/index.ts
788
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
+ }
789
1094
  export {
790
1095
  AeroReplayClient,
791
1096
  AeroSwarm,
@@ -793,6 +1098,7 @@ export {
793
1098
  LABEL_COLORS,
794
1099
  TrackCache,
795
1100
  clamp,
1101
+ createTrackMotion,
796
1102
  getLabelCategory,
797
1103
  haversineKm,
798
1104
  isInsideBbox,