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