@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.
@@ -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.ws?.close();
42
- this.ws = null;
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
- setTimeout(() => {
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
- this.emit("snapshot", Array.from(this.tracks.values()));
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?.(tracks);
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?.(track);
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 resolve = null;
608
+ let resolve2 = null;
509
609
  let done = false;
510
610
  this.wsClient.connect({ encoding: "json", ...opts }, {
511
- onTrackUpdate: (t) => {
512
- if (resolve) {
513
- resolve({ value: t, done: false });
514
- resolve = null;
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
- resolve = r;
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 = useRef(/* @__PURE__ */ new Map());
728
- const lastActualPos = useRef(/* @__PURE__ */ new Map());
729
- const history = useRef(/* @__PURE__ */ new Map());
730
- const lastSeen = useRef(/* @__PURE__ */ new Map());
731
- const lastUpdPerf = useRef(/* @__PURE__ */ new Map());
732
- const interp = useRef(/* @__PURE__ */ new Map());
733
- const velocity = useRef(/* @__PURE__ */ new Map());
734
- const drState = useRef(/* @__PURE__ */ new Map());
735
- const rafRef = useRef(null);
736
- 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);
737
1178
  onFrameRef.current = onFrame;
738
- const maxHistoryPointsRef = useRef(maxHistoryPoints);
1179
+ const maxHistoryPointsRef = useRef2(maxHistoryPoints);
739
1180
  maxHistoryPointsRef.current = maxHistoryPoints;
740
- const minDeltaDegRef = useRef(minDeltaDeg);
1181
+ const minDeltaDegRef = useRef2(minDeltaDeg);
741
1182
  minDeltaDegRef.current = minDeltaDeg;
742
- const maxJumpDegRef = useRef(maxJumpDeg);
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
- useEffect(() => {
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 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";
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 = useRef2(null);
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
- useEffect2(() => {
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(useMemo2(() => selectTrack(trackId), [trackId]));
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