@aeroswarm/live-server 0.0.0-stage → 0.1.2

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 ADDED
@@ -0,0 +1,1173 @@
1
+ // ../core/dist/chunk-J2UTSYKD.js
2
+ function toCurrentTrack(fused, staleAfterMs = 15e3, lostAfterMs = 9e4) {
3
+ const age = Date.now() - fused.updatedAt;
4
+ const status = age > lostAfterMs ? "lost" : age > staleAfterMs ? "stale" : "active";
5
+ const src = fused.sources.length > 1 ? "FUSED" : protocolToSource(fused.protocol);
6
+ const rawHeading = fused.velocity?.heading;
7
+ const headingDeg = rawHeading != null ? (rawHeading % 360 + 360) % 360 : null;
8
+ return {
9
+ trackId: fused.trackId,
10
+ source: src,
11
+ icao24: fused.identity.icao24 ?? null,
12
+ callsign: fused.identity.callsign ?? null,
13
+ squawk: fused.identity.squawk ?? null,
14
+ ...fused.identity.targetType !== void 0 && { targetType: fused.identity.targetType },
15
+ ...fused.identity.vehicleFleet !== void 0 && { vehicleFleet: fused.identity.vehicleFleet },
16
+ ...fused.airportId !== void 0 && { airportId: fused.airportId },
17
+ lat: fused.position?.lat ?? null,
18
+ lon: fused.position?.lon ?? null,
19
+ altitudeFt: fused.position?.alt ?? null,
20
+ groundSpeedKt: fused.velocity?.groundSpeed ?? null,
21
+ headingDeg,
22
+ verticalRateFpm: fused.velocity?.verticalRate ?? null,
23
+ confidence: fused.confidence.overall,
24
+ lastSeenAt: fused.updatedAt,
25
+ status
26
+ };
27
+ }
28
+ function protocolToSource(protocol) {
29
+ if (protocol === "asterix-cat010") return "ASTERIX_CAT010";
30
+ if (protocol === "asterix-cat011") return "ASTERIX_CAT011";
31
+ if (protocol === "asterix-cat020") return "ASTERIX_CAT020";
32
+ if (protocol === "asterix-cat021") return "ASTERIX_CAT021";
33
+ if (protocol === "asterix-cat048") return "ASTERIX_CAT048";
34
+ if (protocol === "asterix-cat062") return "ASTERIX_CAT062";
35
+ return "ADSB";
36
+ }
37
+ var STALE_AFTER_MS = 15e3;
38
+ var LOST_AFTER_MS = 9e4;
39
+
40
+ // src/types.ts
41
+ var noopLogger = { info() {
42
+ }, warn() {
43
+ }, error() {
44
+ } };
45
+ function trackScore(t) {
46
+ return t.confidence * 1e13 + t.lastSeenAt;
47
+ }
48
+ function inBbox(t, bbox) {
49
+ if (!bbox) return true;
50
+ if (t.lat == null || t.lon == null) return true;
51
+ return t.lon >= bbox[0] && t.lat >= bbox[1] && t.lon <= bbox[2] && t.lat <= bbox[3];
52
+ }
53
+ function matchesFilter(t, f) {
54
+ if (f.airportId && t.airportId !== f.airportId) return false;
55
+ if (!inBbox(t, f.bbox)) return false;
56
+ if (f.minConfidence !== void 0 && t.confidence < f.minConfidence) return false;
57
+ return true;
58
+ }
59
+
60
+ // src/track-state.ts
61
+ var DEFAULT_REMOVE_AFTER_MS = 18e5;
62
+ function inside(lon, lat, b) {
63
+ return lon >= b[0] && lat >= b[1] && lon <= b[2] && lat <= b[3];
64
+ }
65
+ var TrackState = class {
66
+ constructor(opts = {}) {
67
+ this.opts = opts;
68
+ this.staleAfterMs = opts.staleAfterMs ?? STALE_AFTER_MS;
69
+ this.lostAfterMs = opts.lostAfterMs ?? LOST_AFTER_MS;
70
+ this.removeAfterMs = opts.removeAfterMs ?? DEFAULT_REMOVE_AFTER_MS;
71
+ this.now = opts.now ?? Date.now;
72
+ }
73
+ opts;
74
+ tracks = /* @__PURE__ */ new Map();
75
+ byIcao24 = /* @__PURE__ */ new Map();
76
+ onUpdate = /* @__PURE__ */ new Set();
77
+ onRemoved = /* @__PURE__ */ new Set();
78
+ staleAfterMs;
79
+ lostAfterMs;
80
+ removeAfterMs;
81
+ sweepTimer = null;
82
+ now;
83
+ statusFor(age) {
84
+ return age > this.lostAfterMs ? "lost" : age > this.staleAfterMs ? "stale" : "active";
85
+ }
86
+ on(event, fn) {
87
+ const set = event === "update" ? this.onUpdate : this.onRemoved;
88
+ set.add(fn);
89
+ return () => {
90
+ set.delete(fn);
91
+ };
92
+ }
93
+ get size() {
94
+ return this.tracks.size;
95
+ }
96
+ get(trackId) {
97
+ return this.tracks.get(trackId);
98
+ }
99
+ values() {
100
+ return this.tracks.values();
101
+ }
102
+ /** Apply one fused event. Returns the resulting track, or null when the event
103
+ * removed a track or was ignored. */
104
+ ingest(fused) {
105
+ if (fused.lifecycle === "dropped") {
106
+ this.remove(fused.trackId, "dropped");
107
+ return null;
108
+ }
109
+ if (this.opts.interestBbox && !this.inInterestZone(fused, this.opts.interestBbox)) return null;
110
+ const icao24 = fused.identity?.icao24;
111
+ if (icao24) {
112
+ const existing = this.byIcao24.get(icao24);
113
+ if (existing && existing !== fused.trackId && this.tracks.has(existing)) {
114
+ this.remove(existing, "superseded");
115
+ }
116
+ this.byIcao24.set(icao24, fused.trackId);
117
+ }
118
+ let current = toCurrentTrack(fused, this.staleAfterMs, this.lostAfterMs);
119
+ current = { ...current, status: this.statusFor(this.now() - current.lastSeenAt) };
120
+ if (this.opts.attachFlightPlan) current = this.opts.attachFlightPlan(current);
121
+ this.tracks.set(current.trackId, current);
122
+ this.emitUpdate(current, false);
123
+ return current;
124
+ }
125
+ /** Remove a track (no-op if unknown). */
126
+ drop(trackId, reason = "dropped") {
127
+ this.remove(trackId, reason);
128
+ }
129
+ /** Recompute `status` from `lastSeenAt` and remove long-gone tracks.
130
+ * Emits only on a real status change. Call every few seconds. */
131
+ sweep(now = this.now()) {
132
+ for (const [trackId, track] of [...this.tracks]) {
133
+ const age = now - track.lastSeenAt;
134
+ if (this.removeAfterMs > 0 && age > this.removeAfterMs) {
135
+ this.remove(trackId, "lost");
136
+ continue;
137
+ }
138
+ const status = this.statusFor(age);
139
+ if (track.status === status) continue;
140
+ const updated = { ...track, status };
141
+ this.tracks.set(trackId, updated);
142
+ this.emitUpdate(updated, true);
143
+ }
144
+ }
145
+ /** Re-attach flight plans, emitting only for tracks whose plan data changed.
146
+ * Call when a plan changes so clients see it before the next radar update. */
147
+ refreshFlightPlans(match) {
148
+ const attach = this.opts.attachFlightPlan;
149
+ if (!attach) return;
150
+ for (const [trackId, track] of [...this.tracks]) {
151
+ if (match && !match(track)) continue;
152
+ const next = attach(track);
153
+ if (JSON.stringify(next.flightPlan) === JSON.stringify(track.flightPlan)) continue;
154
+ this.tracks.set(trackId, next);
155
+ this.emitUpdate(next, true);
156
+ }
157
+ }
158
+ snapshot(filter = {}) {
159
+ let out = [...this.tracks.values()].filter((t) => matchesFilter(t, filter));
160
+ out.sort((a, b) => trackScore(b) - trackScore(a));
161
+ if (filter.maxTracks !== void 0) out = out.slice(0, filter.maxTracks);
162
+ return out;
163
+ }
164
+ /** Run `sweep` on an interval. Idempotent. */
165
+ startSweep(intervalMs = 5e3) {
166
+ if (this.sweepTimer) return;
167
+ this.sweepTimer = setInterval(() => this.sweep(), intervalMs);
168
+ this.sweepTimer.unref?.();
169
+ }
170
+ stopSweep() {
171
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
172
+ this.sweepTimer = null;
173
+ }
174
+ remove(trackId, reason) {
175
+ const track = this.tracks.get(trackId);
176
+ if (!track) return;
177
+ this.tracks.delete(trackId);
178
+ if (track.icao24 && this.byIcao24.get(track.icao24) === trackId) this.byIcao24.delete(track.icao24);
179
+ for (const fn of this.onRemoved) fn({ trackId, reason });
180
+ }
181
+ emitUpdate(track, force) {
182
+ for (const fn of this.onUpdate) fn(track, { force });
183
+ }
184
+ inInterestZone(fused, bbox) {
185
+ const lat = fused.position?.lat;
186
+ const lon = fused.position?.lon;
187
+ if (lat == null || lon == null) return true;
188
+ if (inside(lon, lat, bbox)) return true;
189
+ const heading = fused.velocity?.heading;
190
+ const speedKt = fused.velocity?.groundSpeed;
191
+ if (heading == null || speedKt == null || speedKt < 10) return false;
192
+ const hours = (this.opts.interestLookaheadMin ?? 90) / 60;
193
+ const distDeg = speedKt * 1.852 * hours / 111.32;
194
+ const rad = heading * Math.PI / 180;
195
+ const projLat = lat + distDeg * Math.cos(rad);
196
+ const projLon = lon + distDeg * Math.sin(rad) / Math.cos(lat * Math.PI / 180);
197
+ return inside(projLon, projLat, bbox);
198
+ }
199
+ };
200
+ function createTrackState(opts) {
201
+ return new TrackState(opts);
202
+ }
203
+
204
+ // ../core/dist/chunk-3T3CHHEM.js
205
+ var FLIGHT_PLAN_MATCH_WINDOW_MS = 36 * 36e5;
206
+ function normalizeCallsign(cs) {
207
+ const v = cs?.trim().toUpperCase().replace(/\s+/g, "");
208
+ return v || void 0;
209
+ }
210
+ function dayKey(msg, receivedAt) {
211
+ if (msg.dof && /^\d{6}$/.test(msg.dof)) return `20${msg.dof}`;
212
+ const t = msg.eobtAt ?? msg.atdAt ?? msg.ataAt ?? receivedAt;
213
+ return new Date(t).toISOString().slice(0, 10).replace(/-/g, "");
214
+ }
215
+ function flightPlanId(msg, receivedAt) {
216
+ const callsign = normalizeCallsign(msg.callsign);
217
+ if (!callsign) return null;
218
+ return `${callsign}|${msg.adep ?? "-"}|${msg.ades ?? "-"}|${dayKey(msg, receivedAt)}`;
219
+ }
220
+ function pickMatchingPlan(candidates, msg, receivedAt) {
221
+ const ref = msg.eobtAt ?? msg.atdAt ?? msg.ataAt ?? receivedAt;
222
+ let best;
223
+ let bestDiff = Infinity;
224
+ for (const c of candidates) {
225
+ if (msg.adep && c.adep && msg.adep !== c.adep) continue;
226
+ if (msg.ades && c.ades && msg.ades !== c.ades) continue;
227
+ const planTime = c.atdAt ?? c.eobtAt ?? c.firstSeenAt;
228
+ const diff = Math.abs(planTime - ref);
229
+ if (diff <= FLIGHT_PLAN_MATCH_WINDOW_MS && diff < bestDiff) {
230
+ best = c;
231
+ bestDiff = diff;
232
+ }
233
+ }
234
+ return best;
235
+ }
236
+ function assignDefined(target, patch) {
237
+ for (const [k, v] of Object.entries(patch)) {
238
+ if (v !== void 0 && v !== null && v !== "") target[k] = v;
239
+ }
240
+ }
241
+ function eetMs(eet) {
242
+ if (!eet || !/^\d{4}$/.test(eet)) return void 0;
243
+ return (Number(eet.slice(0, 2)) * 60 + Number(eet.slice(2, 4))) * 6e4;
244
+ }
245
+ function applyFlightPlanEvent(prev, ev) {
246
+ const m = ev.message;
247
+ const callsign = normalizeCallsign(m.callsign);
248
+ if (!callsign) return null;
249
+ const id = prev?.id ?? flightPlanId(m, ev.receivedAt);
250
+ if (!id) return null;
251
+ const next = {
252
+ status: "filed",
253
+ airportId: ev.airportId,
254
+ firstSeenAt: ev.receivedAt,
255
+ ...prev,
256
+ id,
257
+ callsign,
258
+ other: { ...prev?.other ?? {} },
259
+ lastMessageType: m.type,
260
+ messageCount: (prev?.messageCount ?? 0) + 1,
261
+ updatedAt: ev.receivedAt
262
+ };
263
+ assignDefined(next, { icao24: m.icao24, registration: m.registration, dof: m.dof });
264
+ Object.assign(next.other, m.other);
265
+ if (!next.adep && m.adep) next.adep = m.adep;
266
+ if (!next.ades && m.ades) next.ades = m.ades;
267
+ switch (m.type) {
268
+ case "FPL":
269
+ assignDefined(next, {
270
+ aircraftType: m.aircraftType,
271
+ wake: m.wake,
272
+ flightRules: m.flightRules,
273
+ flightType: m.flightType,
274
+ adep: m.adep,
275
+ ades: m.ades,
276
+ alternates: m.alternates,
277
+ eobtAt: m.eobtAt,
278
+ eet: m.eet,
279
+ cruiseSpeed: m.cruiseSpeed,
280
+ cruiseLevel: m.cruiseLevel,
281
+ route: m.route,
282
+ equipment: m.equipment
283
+ });
284
+ if (prev?.status !== "departed" && prev?.status !== "arrived") next.status = "filed";
285
+ break;
286
+ case "CHG":
287
+ assignDefined(next, {
288
+ aircraftType: m.aircraftType,
289
+ wake: m.wake,
290
+ cruiseSpeed: m.cruiseSpeed,
291
+ cruiseLevel: m.cruiseLevel,
292
+ route: m.route
293
+ });
294
+ if (m.changes?.["13"]) assignDefined(next, { adep: m.adep, eobtAt: m.eobtAt });
295
+ if (m.changes?.["16"]) assignDefined(next, { ades: m.ades, eet: m.eet });
296
+ break;
297
+ case "DLA":
298
+ assignDefined(next, { eobtAt: m.eobtAt });
299
+ if (next.status === "filed" || next.status === "delayed") next.status = "delayed";
300
+ break;
301
+ case "DEP":
302
+ next.atdAt = m.atdAt ?? ev.receivedAt;
303
+ next.status = "departed";
304
+ break;
305
+ case "ARR":
306
+ next.ataAt = m.ataAt ?? ev.receivedAt;
307
+ next.status = "arrived";
308
+ break;
309
+ case "CNL":
310
+ next.status = "cancelled";
311
+ break;
312
+ }
313
+ const departure = next.atdAt ?? next.eobtAt;
314
+ const flightTime = eetMs(next.eet);
315
+ next.etaAt = next.ataAt ?? (departure !== void 0 && flightTime !== void 0 ? departure + flightTime : void 0);
316
+ return next;
317
+ }
318
+ function flightPhase(plan, airportId) {
319
+ const icao = airportId?.trim().toUpperCase();
320
+ if (icao && plan.ades === icao) return "ARRIVAL";
321
+ if (icao && plan.adep === icao) return "DEPARTURE";
322
+ return plan.adep || plan.ades ? "OVERFLIGHT" : "UNKNOWN";
323
+ }
324
+ function summarizeFlightPlan(plan, airportId) {
325
+ const summary = { id: plan.id, status: plan.status, phase: flightPhase(plan, airportId) };
326
+ assignDefined(summary, {
327
+ adep: plan.adep,
328
+ ades: plan.ades,
329
+ aircraftType: plan.aircraftType,
330
+ registration: plan.registration,
331
+ eobtAt: plan.eobtAt,
332
+ atdAt: plan.atdAt,
333
+ ataAt: plan.ataAt,
334
+ etaAt: plan.etaAt,
335
+ cruiseLevel: plan.cruiseLevel,
336
+ route: plan.route
337
+ });
338
+ return summary;
339
+ }
340
+
341
+ // src/flightplan-store.ts
342
+ var FlightPlanStore = class {
343
+ plans = /* @__PURE__ */ new Map();
344
+ byCallsign = /* @__PURE__ */ new Map();
345
+ byIcao24 = /* @__PURE__ */ new Map();
346
+ onChanged = /* @__PURE__ */ new Set();
347
+ onExpired = /* @__PURE__ */ new Set();
348
+ ttlMs;
349
+ sweepTimer = null;
350
+ constructor(opts = {}) {
351
+ this.ttlMs = opts.ttlMs ?? 48 * 36e5;
352
+ }
353
+ on(event, fn) {
354
+ const set = event === "changed" ? this.onChanged : this.onExpired;
355
+ set.add(fn);
356
+ return () => {
357
+ set.delete(fn);
358
+ };
359
+ }
360
+ get size() {
361
+ return this.plans.size;
362
+ }
363
+ get(id) {
364
+ return this.plans.get(id);
365
+ }
366
+ /** Fold one AFTN message into its flight. Returns the updated plan, or null if ignored. */
367
+ apply(ev) {
368
+ const m = ev.message;
369
+ const callsign = normalizeCallsign(m.callsign);
370
+ if (!callsign) return null;
371
+ let prev;
372
+ if (m.type === "FPL") {
373
+ const id = flightPlanId(m, ev.receivedAt);
374
+ prev = id ? this.plans.get(id) : void 0;
375
+ } else {
376
+ const candidates = [...this.byCallsign.get(callsign) ?? []].map((id) => this.plans.get(id)).filter((p) => p !== void 0);
377
+ prev = pickMatchingPlan(candidates, m, ev.receivedAt);
378
+ }
379
+ const next = applyFlightPlanEvent(prev, ev);
380
+ if (!next) return null;
381
+ if (prev) this.unindex(prev);
382
+ this.plans.set(next.id, next);
383
+ this.index(next);
384
+ for (const fn of this.onChanged) fn(next);
385
+ return next;
386
+ }
387
+ /** Restore plans (e.g. from a database after a restart). Does not emit `changed`. */
388
+ load(plans, now = Date.now()) {
389
+ const cutoff = now - this.ttlMs;
390
+ let loaded = 0;
391
+ for (const plan of plans) {
392
+ if (!plan?.id || plan.updatedAt < cutoff) continue;
393
+ const prev = this.plans.get(plan.id);
394
+ if (prev) this.unindex(prev);
395
+ this.plans.set(plan.id, plan);
396
+ this.index(plan);
397
+ loaded++;
398
+ }
399
+ return loaded;
400
+ }
401
+ /** Best plan for a live track. Every plan sharing the Mode-S address or
402
+ * callsign is a candidate (an airframe flies several legs a day); the leg
403
+ * in progress, or nearest in time, wins. Cancelled plans never match. */
404
+ find(track, now = Date.now()) {
405
+ const candidates = /* @__PURE__ */ new Set();
406
+ if (track.icao24) {
407
+ for (const id of this.byIcao24.get(track.icao24.toLowerCase()) ?? []) {
408
+ const p = this.plans.get(id);
409
+ if (p) candidates.add(p);
410
+ }
411
+ }
412
+ const callsign = normalizeCallsign(track.callsign);
413
+ if (callsign) {
414
+ for (const id of this.byCallsign.get(callsign) ?? []) {
415
+ const p = this.plans.get(id);
416
+ if (p) candidates.add(p);
417
+ }
418
+ }
419
+ let best;
420
+ let bestScore = Infinity;
421
+ for (const p of candidates) {
422
+ if (p.status === "cancelled") continue;
423
+ const s = Math.abs((p.ataAt ?? p.atdAt ?? p.eobtAt ?? p.firstSeenAt) - now);
424
+ if (s < bestScore || s === bestScore && best && p.updatedAt > best.updatedAt) {
425
+ best = p;
426
+ bestScore = s;
427
+ }
428
+ }
429
+ return best;
430
+ }
431
+ /** Track with its flight-plan summary attached; unchanged when none matches.
432
+ * An arrow property so it can be passed unbound, e.g. `{ attachFlightPlan: store.attach }`. */
433
+ attach = (track) => {
434
+ const plan = this.find(track);
435
+ if (!plan) {
436
+ if (!track.flightPlan) return track;
437
+ const { flightPlan: _drop, ...rest } = track;
438
+ return rest;
439
+ }
440
+ return { ...track, flightPlan: summarizeFlightPlan(plan, track.airportId) };
441
+ };
442
+ /** True if `plan` could correlate with `track`. Pair with `TrackState.refreshFlightPlans`. */
443
+ matches(plan, track) {
444
+ if (plan.icao24 && track.icao24?.toLowerCase() === plan.icao24) return true;
445
+ return normalizeCallsign(track.callsign) === plan.callsign;
446
+ }
447
+ list(filter = {}) {
448
+ const callsign = normalizeCallsign(filter.callsign);
449
+ const out = [...this.plans.values()].filter((p) => !filter.airportId || !p.airportId || p.airportId === filter.airportId).filter((p) => !filter.status || p.status === filter.status).filter((p) => !callsign || p.callsign.includes(callsign)).sort((a, b) => b.updatedAt - a.updatedAt);
450
+ return filter.limit && filter.limit > 0 ? out.slice(0, filter.limit) : out;
451
+ }
452
+ /** Drop plans past the TTL; returns their ids. */
453
+ sweep(now = Date.now()) {
454
+ const cutoff = now - this.ttlMs;
455
+ const removed = [];
456
+ for (const [id, plan] of [...this.plans]) {
457
+ if (plan.updatedAt >= cutoff) continue;
458
+ this.plans.delete(id);
459
+ this.unindex(plan);
460
+ removed.push(id);
461
+ for (const fn of this.onExpired) fn(id);
462
+ }
463
+ return removed;
464
+ }
465
+ startSweep(intervalMs = 10 * 6e4) {
466
+ if (this.sweepTimer) return;
467
+ this.sweepTimer = setInterval(() => this.sweep(), intervalMs);
468
+ this.sweepTimer.unref?.();
469
+ }
470
+ stopSweep() {
471
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
472
+ this.sweepTimer = null;
473
+ }
474
+ index(plan) {
475
+ let ids = this.byCallsign.get(plan.callsign);
476
+ if (!ids) this.byCallsign.set(plan.callsign, ids = /* @__PURE__ */ new Set());
477
+ ids.add(plan.id);
478
+ if (plan.icao24) {
479
+ let iids = this.byIcao24.get(plan.icao24);
480
+ if (!iids) this.byIcao24.set(plan.icao24, iids = /* @__PURE__ */ new Set());
481
+ iids.add(plan.id);
482
+ }
483
+ }
484
+ unindex(plan) {
485
+ const ids = this.byCallsign.get(plan.callsign);
486
+ ids?.delete(plan.id);
487
+ if (ids?.size === 0) this.byCallsign.delete(plan.callsign);
488
+ if (plan.icao24) {
489
+ const iids = this.byIcao24.get(plan.icao24);
490
+ iids?.delete(plan.id);
491
+ if (iids?.size === 0) this.byIcao24.delete(plan.icao24);
492
+ }
493
+ }
494
+ };
495
+ function createFlightPlanStore(opts) {
496
+ return new FlightPlanStore(opts);
497
+ }
498
+
499
+ // src/movement.ts
500
+ var AIRBORNE_ALT_FT = 300;
501
+ var GROUND_ALT_FT = 100;
502
+ var TAKEOFF_MIN_GS_KT = 70;
503
+ var LANDING_MAX_GS_KT = 190;
504
+ var DEBOUNCE_MS = 12e4;
505
+ var MovementDetector = class {
506
+ phases = /* @__PURE__ */ new Map();
507
+ /** Feed one track state; returns a movement event on a transition. */
508
+ update(s) {
509
+ const alt = s.altitudeFt ?? null;
510
+ const gs = s.groundSpeedKt ?? null;
511
+ const looksAirborne = s.onGround === false || alt !== null && alt > AIRBORNE_ALT_FT;
512
+ const looksGround = s.onGround === true || alt !== null && alt < GROUND_ALT_FT;
513
+ const phase = this.phases.get(s.trackId);
514
+ if (!phase) {
515
+ this.phases.set(s.trackId, {
516
+ airborne: looksAirborne && !looksGround,
517
+ lastAt: s.at,
518
+ lastMovementAt: 0
519
+ });
520
+ return null;
521
+ }
522
+ phase.lastAt = s.at;
523
+ let event = null;
524
+ const debounced = phase.lastMovementAt !== 0 && s.at - phase.lastMovementAt < DEBOUNCE_MS;
525
+ if (!phase.airborne && looksAirborne && !looksGround) {
526
+ if ((gs ?? 0) >= TAKEOFF_MIN_GS_KT && !debounced) {
527
+ event = this.makeEvent(s, "takeoff");
528
+ phase.lastMovementAt = s.at;
529
+ }
530
+ phase.airborne = true;
531
+ } else if (phase.airborne && looksGround) {
532
+ if ((gs ?? 0) <= LANDING_MAX_GS_KT && !debounced) {
533
+ event = this.makeEvent(s, "landing");
534
+ phase.lastMovementAt = s.at;
535
+ }
536
+ phase.airborne = false;
537
+ }
538
+ return event;
539
+ }
540
+ /** Forget one track (call when its track is removed). */
541
+ forget(trackId) {
542
+ this.phases.delete(trackId);
543
+ }
544
+ /** Forget tracks not updated within maxAgeMs (call periodically). */
545
+ sweep(maxAgeMs, now = Date.now()) {
546
+ for (const [id, phase] of this.phases) {
547
+ if (now - phase.lastAt > maxAgeMs) this.phases.delete(id);
548
+ }
549
+ }
550
+ get trackedCount() {
551
+ return this.phases.size;
552
+ }
553
+ makeEvent(s, type) {
554
+ return {
555
+ trackId: s.trackId,
556
+ icao24: s.icao24 ?? null,
557
+ callsign: s.callsign ?? null,
558
+ ...s.airportId ? { airportId: s.airportId } : {},
559
+ type,
560
+ at: s.at,
561
+ lat: s.lat,
562
+ lon: s.lon
563
+ };
564
+ }
565
+ };
566
+ function createMovementDetector() {
567
+ return new MovementDetector();
568
+ }
569
+
570
+ // src/fusion.ts
571
+ var DEFAULT_SURFACE_PRIORITY = [
572
+ "asterix-cat010",
573
+ "asterix-cat011",
574
+ "asterix-cat020",
575
+ "asterix-cat021",
576
+ "asterix-cat062",
577
+ "asterix-cat048"
578
+ ];
579
+ var DEFAULT_AIRBORNE_PRIORITY = [
580
+ "asterix-cat020",
581
+ "asterix-cat010",
582
+ "asterix-cat011",
583
+ "asterix-cat021",
584
+ "asterix-cat062",
585
+ "asterix-cat048"
586
+ ];
587
+ function defaultFusionOptions() {
588
+ return {
589
+ surfacePriority: [...DEFAULT_SURFACE_PRIORITY],
590
+ airbornePriority: [...DEFAULT_AIRBORNE_PRIORITY],
591
+ maxSourceAgeMs: 5e3,
592
+ forgetAfterMs: 6e5,
593
+ spatial: false,
594
+ matchRadiusM: 150,
595
+ matchMaxSkewMs: 3e3,
596
+ disagreeM: 300
597
+ };
598
+ }
599
+ var SYNTHETIC_ID = /^[rtv]\d+-\d+-\d+$/;
600
+ var EARTH_M = 6371e3;
601
+ var isSyntheticId = (icao24) => SYNTHETIC_ID.test(icao24);
602
+ function distanceM(a, b) {
603
+ const rad = Math.PI / 180;
604
+ const dLat = (b.lat - a.lat) * rad;
605
+ const dLon = (b.lon - a.lon) * rad;
606
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2;
607
+ return 2 * EARTH_M * Math.asin(Math.min(1, Math.sqrt(h)));
608
+ }
609
+ var sourceOf = (event) => event.sources[0];
610
+ var keyFor = (event) => {
611
+ const icao24 = event.identity.icao24;
612
+ return isSyntheticId(icao24) ? `s:${sourceOf(event)}:${icao24}` : `m:${icao24}`;
613
+ };
614
+ function rank(list, event) {
615
+ const idx = list.findIndex(
616
+ (s) => s === event.protocol || s === sourceOf(event)
617
+ );
618
+ return idx < 0 ? list.length : idx;
619
+ }
620
+ function pickPrimary(fresh, list) {
621
+ const sorted = [...fresh].sort(
622
+ (a, b) => rank(list, a.event) - rank(list, b.event) || b.at - a.at
623
+ );
624
+ return sorted[0] ?? null;
625
+ }
626
+ function mergeIdentity(primary, others) {
627
+ const out = { ...primary.event.identity };
628
+ for (const other of others) {
629
+ const id = other.event.identity;
630
+ out.callsign ??= id.callsign;
631
+ out.squawk ??= id.squawk;
632
+ out.targetType ??= id.targetType;
633
+ out.vehicleFleet ??= id.vehicleFleet;
634
+ if (isSyntheticId(out.icao24) && !isSyntheticId(id.icao24)) {
635
+ out.icao24 = id.icao24;
636
+ }
637
+ }
638
+ return out;
639
+ }
640
+ function maxSpreadM(primary, others) {
641
+ const base = primary.event.position;
642
+ if (!base) return 0;
643
+ let max = 0;
644
+ for (const other of others) {
645
+ const pos = other.event.position;
646
+ if (pos) max = Math.max(max, distanceM(base, pos));
647
+ }
648
+ return max;
649
+ }
650
+ var Fuser = class {
651
+ opts;
652
+ now;
653
+ byKey = /* @__PURE__ */ new Map();
654
+ byId = /* @__PURE__ */ new Map();
655
+ spatialMatches = 0;
656
+ ambiguous = 0;
657
+ disagreements = 0;
658
+ constructor(opts) {
659
+ this.opts = opts;
660
+ this.now = opts.now ?? (() => Date.now());
661
+ }
662
+ ingest(event) {
663
+ const now = this.now();
664
+ const src = sourceOf(event);
665
+ const key = keyFor(event);
666
+ const known = this.byKey.get(key);
667
+ if (event.lifecycle === "dropped") return this.drop(known, event, src, now);
668
+ const entry = known ?? this.matchSpatially(event, src, now) ?? this.create(event);
669
+ this.link(entry, key, event);
670
+ entry.contributions.set(src, { event, sourceId: src, at: now });
671
+ return this.output(entry, src, now, false);
672
+ }
673
+ forget(trackId) {
674
+ const entry = this.byId.get(trackId);
675
+ if (entry) this.remove(entry);
676
+ }
677
+ sweep() {
678
+ const now = this.now();
679
+ for (const entry of [...this.byId.values()]) {
680
+ for (const [src, c] of entry.contributions) {
681
+ if (now - c.at > this.opts.forgetAfterMs)
682
+ entry.contributions.delete(src);
683
+ }
684
+ if (entry.contributions.size === 0) this.remove(entry);
685
+ }
686
+ }
687
+ stats() {
688
+ const now = this.now();
689
+ let merged = 0;
690
+ for (const entry of this.byId.values()) {
691
+ if (this.fresh(entry, now).length > 1) merged++;
692
+ }
693
+ return {
694
+ aircraft: this.byId.size,
695
+ merged,
696
+ spatialMatches: this.spatialMatches,
697
+ ambiguous: this.ambiguous,
698
+ disagreements: this.disagreements
699
+ };
700
+ }
701
+ fresh(entry, now) {
702
+ return [...entry.contributions.values()].filter(
703
+ (c) => now - c.at <= this.opts.maxSourceAgeMs
704
+ );
705
+ }
706
+ create(event) {
707
+ const entry = {
708
+ id: event.trackId,
709
+ keys: /* @__PURE__ */ new Set(),
710
+ contributions: /* @__PURE__ */ new Map(),
711
+ realIcao24: null,
712
+ lastPrimary: "",
713
+ lastSources: ""
714
+ };
715
+ this.byId.set(entry.id, entry);
716
+ return entry;
717
+ }
718
+ link(entry, key, event) {
719
+ entry.keys.add(key);
720
+ this.byKey.set(key, entry);
721
+ if (!isSyntheticId(event.identity.icao24)) {
722
+ entry.realIcao24 = event.identity.icao24;
723
+ }
724
+ }
725
+ remove(entry) {
726
+ for (const key of entry.keys) this.byKey.delete(key);
727
+ this.byId.delete(entry.id);
728
+ }
729
+ drop(entry, event, src, now) {
730
+ if (!entry) return event;
731
+ entry.contributions.delete(src);
732
+ if (entry.contributions.size > 0) return this.output(entry, src, now, true);
733
+ this.remove(entry);
734
+ return { ...event, trackId: entry.id };
735
+ }
736
+ /** The partner for a track nobody else shares an id with, or null. */
737
+ matchSpatially(event, src, now) {
738
+ const pos = event.position;
739
+ if (!this.opts.spatial || !pos) return null;
740
+ const hasMode = !isSyntheticId(event.identity.icao24);
741
+ const found = [];
742
+ for (const entry of this.byId.values()) {
743
+ if (entry.contributions.has(src) || hasMode && entry.realIcao24)
744
+ continue;
745
+ if (this.isNear(entry, pos, now)) found.push(entry);
746
+ }
747
+ if (found.length > 1) this.ambiguous++;
748
+ if (found.length !== 1) return null;
749
+ this.spatialMatches++;
750
+ return found[0] ?? null;
751
+ }
752
+ isNear(entry, pos, now) {
753
+ for (const c of entry.contributions.values()) {
754
+ const other = c.event.position;
755
+ if (!other || now - c.at > this.opts.matchMaxSkewMs) continue;
756
+ if (distanceM(pos, other) <= this.opts.matchRadiusM) return true;
757
+ }
758
+ return false;
759
+ }
760
+ /**
761
+ * The event for the aircraft as it stands now. Null when `src` is not the
762
+ * sensor being shown and nothing else changed, so a lower-priority sensor
763
+ * does not make the picture jump.
764
+ */
765
+ output(entry, src, now, force) {
766
+ const fresh = this.fresh(entry, now);
767
+ const onGround = fresh.some((c) => c.event.position?.onGround === true);
768
+ const list = onGround ? this.opts.surfacePriority : this.opts.airbornePriority;
769
+ const primary = pickPrimary(fresh, list);
770
+ if (!primary) return null;
771
+ const sources = fresh.map((c) => c.sourceId).sort().join(",");
772
+ const changed = force || primary.sourceId === src || primary.sourceId !== entry.lastPrimary || sources !== entry.lastSources;
773
+ entry.lastPrimary = primary.sourceId;
774
+ entry.lastSources = sources;
775
+ return changed ? this.build(entry, primary, fresh) : null;
776
+ }
777
+ build(entry, primary, fresh) {
778
+ const others = fresh.filter((c) => c !== primary).sort((a, b) => b.at - a.at);
779
+ const { event } = primary;
780
+ const out = {
781
+ ...event,
782
+ trackId: entry.id,
783
+ sources: [primary.sourceId, ...others.map((c) => c.sourceId)],
784
+ identity: mergeIdentity(primary, others),
785
+ lifecycle: others.length > 0 ? "merged" : event.lifecycle
786
+ };
787
+ const velocity = event.velocity ?? others.find((c) => c.event.velocity)?.event.velocity;
788
+ if (velocity) out.velocity = velocity;
789
+ if (others.length > 0) this.scoreAgreement(out, primary, others);
790
+ return out;
791
+ }
792
+ scoreAgreement(out, primary, others) {
793
+ const spread = maxSpreadM(primary, others);
794
+ if (spread > this.opts.disagreeM) this.disagreements++;
795
+ out.confidence = {
796
+ ...out.confidence,
797
+ sourceAgreement: Math.max(0, 1 - spread / this.opts.disagreeM)
798
+ };
799
+ }
800
+ };
801
+ function createTrackFuser(options = {}) {
802
+ const base = defaultFusionOptions();
803
+ const given = Object.fromEntries(
804
+ Object.entries(options).filter(([, v]) => v !== void 0)
805
+ );
806
+ return new Fuser({ ...base, ...given });
807
+ }
808
+
809
+ // ../core/dist/chunk-ZYX6U3ZK.js
810
+ import { z } from "zod";
811
+ var positionSchema = z.object({
812
+ lat: z.number().min(-90).max(90),
813
+ lon: z.number().min(-180).max(180),
814
+ alt: z.number().optional(),
815
+ altSource: z.enum(["baro", "gnss", "mlat"]).optional(),
816
+ onGround: z.boolean().optional()
817
+ });
818
+ var velocitySchema = z.object({
819
+ groundSpeed: z.number().nonnegative().optional(),
820
+ heading: z.number().min(-1).max(361).optional(),
821
+ verticalRate: z.number().optional()
822
+ });
823
+ var flightPlanSummarySchema = z.object({
824
+ id: z.string(),
825
+ adep: z.string().optional(),
826
+ ades: z.string().optional(),
827
+ aircraftType: z.string().optional(),
828
+ registration: z.string().optional(),
829
+ eobtAt: z.number().optional(),
830
+ atdAt: z.number().optional(),
831
+ ataAt: z.number().optional(),
832
+ etaAt: z.number().optional(),
833
+ cruiseLevel: z.string().optional(),
834
+ route: z.string().optional(),
835
+ status: z.enum(["filed", "delayed", "departed", "arrived", "cancelled"]),
836
+ phase: z.enum(["ARRIVAL", "DEPARTURE", "OVERFLIGHT", "UNKNOWN"])
837
+ });
838
+ var currentTrackSchema = z.object({
839
+ trackId: z.string(),
840
+ source: z.enum(["ADSB", "ASTERIX_CAT010", "ASTERIX_CAT011", "ASTERIX_CAT020", "ASTERIX_CAT021", "ASTERIX_CAT048", "ASTERIX_CAT062", "FUSED"]),
841
+ icao24: z.string().nullable(),
842
+ callsign: z.string().nullable(),
843
+ squawk: z.string().nullable(),
844
+ targetType: z.enum(["aircraft", "vehicle", "unknown"]).optional(),
845
+ vehicleFleet: z.number().optional(),
846
+ airportId: z.string().optional(),
847
+ lat: z.number().min(-90).max(90).nullable(),
848
+ lon: z.number().min(-180).max(180).nullable(),
849
+ altitudeFt: z.number().nullable(),
850
+ groundSpeedKt: z.number().nullable(),
851
+ headingDeg: z.number().min(-1).max(361).nullable(),
852
+ verticalRateFpm: z.number().nullable(),
853
+ confidence: z.number().min(0).max(1),
854
+ lastSeenAt: z.number(),
855
+ status: z.enum(["active", "stale", "lost"]),
856
+ flightPlan: flightPlanSummarySchema.optional()
857
+ });
858
+ var streamEnvelopeSchema = z.discriminatedUnion("type", [
859
+ z.object({
860
+ type: z.literal("snapshot"),
861
+ seq: z.number(),
862
+ serverTime: z.number(),
863
+ payload: z.object({ tracks: z.array(currentTrackSchema) })
864
+ }),
865
+ z.object({
866
+ type: z.literal("track.updated"),
867
+ seq: z.number(),
868
+ serverTime: z.number(),
869
+ payload: z.object({ track: currentTrackSchema })
870
+ }),
871
+ z.object({
872
+ type: z.literal("track.removed"),
873
+ seq: z.number(),
874
+ serverTime: z.number(),
875
+ payload: z.object({ trackId: z.string(), reason: z.string() })
876
+ }),
877
+ z.object({
878
+ type: z.literal("source.status"),
879
+ seq: z.number(),
880
+ serverTime: z.number(),
881
+ payload: z.object({ sourceId: z.string(), status: z.enum(["healthy", "degraded", "offline"]) })
882
+ }),
883
+ z.object({
884
+ type: z.literal("stream.heartbeat"),
885
+ seq: z.number(),
886
+ serverTime: z.number(),
887
+ payload: z.object({}).strict()
888
+ }),
889
+ z.object({
890
+ type: z.literal("stream.degraded"),
891
+ seq: z.number(),
892
+ serverTime: z.number(),
893
+ payload: z.object({ reason: z.string(), sourceId: z.string().optional() })
894
+ }),
895
+ z.object({
896
+ type: z.literal("stream.error"),
897
+ seq: z.number(),
898
+ serverTime: z.number(),
899
+ payload: z.object({ code: z.string(), message: z.string().optional(), recoverable: z.boolean() })
900
+ })
901
+ ]);
902
+ var sourceHeartbeatSchema = z.object({
903
+ sourceId: z.string(),
904
+ timestamp: z.number(),
905
+ packetsReceived: z.number().int(),
906
+ packetsSent: z.number().int(),
907
+ bufferFillRatio: z.number().min(0).max(1),
908
+ latencyMs: z.number().nonnegative(),
909
+ protocol: z.enum(["beast", "sbs", "asterix-cat021", "asterix-cat048"]),
910
+ receiverVersion: z.string().optional()
911
+ });
912
+ var wsSubscribeSchema = z.object({
913
+ type: z.literal("subscribe"),
914
+ bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
915
+ source: z.enum(["fused", "normalized"]).default("fused"),
916
+ minConfidence: z.number().min(0).max(1).optional(),
917
+ maxTracks: z.number().int().min(1).max(1e4).optional(),
918
+ mode: z.enum(["delta", "full"]).default("delta"),
919
+ maxUpdateHz: z.number().min(1).max(60).default(20),
920
+ encoding: z.enum(["json", "msgpack"]).default("json"),
921
+ airportId: z.string().min(2).max(8).optional()
922
+ });
923
+ var wsUpdateFilterSchema = z.object({
924
+ type: z.literal("update-filter"),
925
+ bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
926
+ minConfidence: z.number().min(0).max(1).optional(),
927
+ maxUpdateHz: z.number().min(1).max(60).optional(),
928
+ maxTracks: z.number().int().min(1).max(1e4).nullable().optional()
929
+ });
930
+ var apiErrorSchema = z.object({
931
+ ok: z.literal(false),
932
+ error: z.object({
933
+ code: z.string(),
934
+ message: z.string(),
935
+ recoverable: z.boolean()
936
+ })
937
+ });
938
+
939
+ // src/live-server.ts
940
+ function toText(raw) {
941
+ return typeof raw === "string" ? raw : new TextDecoder().decode(raw);
942
+ }
943
+ function createLiveServer(opts) {
944
+ const { state } = opts;
945
+ const log = opts.logger ?? noopLogger;
946
+ const defaultHz = opts.defaultUpdateHz ?? 10;
947
+ const maxBuffered = opts.maxBufferedBytes ?? 1048576;
948
+ const clients = /* @__PURE__ */ new Map();
949
+ let seq = 0;
950
+ const counters = { messagesSent: 0, deltasSkipped: 0 };
951
+ function send(c, type, payload, droppable = false) {
952
+ if (c.closed) return false;
953
+ if (droppable && c.socket.getBufferedAmount && c.socket.getBufferedAmount() > maxBuffered) {
954
+ counters.deltasSkipped++;
955
+ return false;
956
+ }
957
+ const envelope = { type, seq: ++seq, serverTime: Date.now(), payload };
958
+ try {
959
+ c.socket.send(JSON.stringify(envelope));
960
+ counters.messagesSent++;
961
+ return true;
962
+ } catch (err) {
963
+ log.warn({ err }, "live ws: send failed");
964
+ return false;
965
+ }
966
+ }
967
+ function sendError(c, code, message) {
968
+ send(c, "stream.error", { code, message, recoverable: true });
969
+ }
970
+ function shouldPush(c, trackId, now) {
971
+ const last = c.lastPushed.get(trackId) ?? 0;
972
+ if (now - last < 1e3 / c.maxUpdateHz) return false;
973
+ c.lastPushed.set(trackId, now);
974
+ return true;
975
+ }
976
+ function sendSnapshot(c) {
977
+ const tracks = state.snapshot(c.filter);
978
+ c.visible = new Set(tracks.map((t) => t.trackId));
979
+ c.lastPushed.clear();
980
+ send(c, "snapshot", { tracks });
981
+ }
982
+ function hide(c, trackId, reason) {
983
+ if (!c.visible.delete(trackId)) return;
984
+ c.lastPushed.delete(trackId);
985
+ send(c, "track.removed", { trackId, reason });
986
+ }
987
+ function admit(c, track) {
988
+ const cap = c.filter.maxTracks;
989
+ if (cap === void 0 || c.visible.size < cap) return true;
990
+ let lowestId = null;
991
+ let lowest = trackScore(track);
992
+ for (const id of c.visible) {
993
+ const t = state.get(id);
994
+ if (!t) {
995
+ c.visible.delete(id);
996
+ continue;
997
+ }
998
+ const s = trackScore(t);
999
+ if (s < lowest) {
1000
+ lowest = s;
1001
+ lowestId = id;
1002
+ }
1003
+ }
1004
+ if (c.visible.size < cap) return true;
1005
+ if (!lowestId) return false;
1006
+ hide(c, lowestId, "evicted");
1007
+ return true;
1008
+ }
1009
+ const offUpdate = state.on("update", (track, info) => {
1010
+ const now = Date.now();
1011
+ for (const c of clients.values()) {
1012
+ if (!c.subscribed) continue;
1013
+ if (!matchesFilter(track, c.filter)) {
1014
+ hide(c, track.trackId, "filtered");
1015
+ continue;
1016
+ }
1017
+ if (!c.visible.has(track.trackId)) {
1018
+ if (!admit(c, track)) continue;
1019
+ c.visible.add(track.trackId);
1020
+ c.lastPushed.set(track.trackId, now);
1021
+ } else if (!info.force && !shouldPush(c, track.trackId, now)) {
1022
+ continue;
1023
+ }
1024
+ if (!send(c, "track.updated", { track }, !info.force)) {
1025
+ c.lastPushed.delete(track.trackId);
1026
+ }
1027
+ }
1028
+ });
1029
+ const offRemoved = state.on("removed", ({ trackId, reason }) => {
1030
+ for (const c of clients.values()) {
1031
+ if (!c.visible.has(trackId)) continue;
1032
+ c.visible.delete(trackId);
1033
+ c.lastPushed.delete(trackId);
1034
+ send(c, "track.removed", { trackId, reason });
1035
+ }
1036
+ });
1037
+ const heartbeat = setInterval(() => {
1038
+ for (const c of clients.values()) if (c.ready) send(c, "stream.heartbeat", {});
1039
+ }, opts.heartbeatMs ?? 15e3);
1040
+ heartbeat.unref?.();
1041
+ function handle(c, raw) {
1042
+ let msg;
1043
+ try {
1044
+ msg = JSON.parse(toText(raw));
1045
+ } catch {
1046
+ sendError(c, "BAD_JSON", "Message is not valid JSON");
1047
+ return;
1048
+ }
1049
+ if (msg["type"] === "subscribe") {
1050
+ const parsed = wsSubscribeSchema.safeParse(msg);
1051
+ if (!parsed.success) {
1052
+ sendError(c, "BAD_SUBSCRIBE", "Invalid subscribe message");
1053
+ return;
1054
+ }
1055
+ const sub = parsed.data;
1056
+ c.filter = {
1057
+ airportId: c.pinnedAirportId ?? sub.airportId,
1058
+ bbox: sub.bbox,
1059
+ minConfidence: sub.minConfidence ?? 0,
1060
+ maxTracks: sub.maxTracks
1061
+ };
1062
+ c.maxUpdateHz = sub.maxUpdateHz;
1063
+ c.subscribed = true;
1064
+ sendSnapshot(c);
1065
+ } else if (msg["type"] === "update-filter") {
1066
+ const parsed = wsUpdateFilterSchema.safeParse(msg);
1067
+ if (!parsed.success) {
1068
+ sendError(c, "BAD_FILTER", "Invalid update-filter message");
1069
+ return;
1070
+ }
1071
+ const upd = parsed.data;
1072
+ if (upd.bbox !== void 0) c.filter.bbox = upd.bbox;
1073
+ if (upd.minConfidence !== void 0) c.filter.minConfidence = upd.minConfidence;
1074
+ if (upd.maxUpdateHz !== void 0) c.maxUpdateHz = upd.maxUpdateHz;
1075
+ if (upd.maxTracks !== void 0) c.filter.maxTracks = upd.maxTracks ?? void 0;
1076
+ if (c.subscribed) sendSnapshot(c);
1077
+ }
1078
+ }
1079
+ return {
1080
+ handlers: {
1081
+ async open(ws, ctx) {
1082
+ const c = {
1083
+ socket: ws,
1084
+ ready: false,
1085
+ closed: false,
1086
+ pending: [],
1087
+ subscribed: false,
1088
+ filter: {},
1089
+ maxUpdateHz: defaultHz,
1090
+ lastPushed: /* @__PURE__ */ new Map(),
1091
+ visible: /* @__PURE__ */ new Set()
1092
+ };
1093
+ clients.set(ws, c);
1094
+ if (!ctx.token) {
1095
+ ws.close(4001, "Unauthorized: token required");
1096
+ return;
1097
+ }
1098
+ let auth;
1099
+ try {
1100
+ auth = await opts.authenticate(ctx.token);
1101
+ } catch (err) {
1102
+ log.error({ err }, "live ws: authenticate threw");
1103
+ auth = null;
1104
+ }
1105
+ if (c.closed) return;
1106
+ if (!auth) {
1107
+ ws.close(4003, "Unauthorized: invalid token");
1108
+ return;
1109
+ }
1110
+ c.pinnedAirportId = auth.airportId;
1111
+ c.filter.airportId = auth.airportId;
1112
+ c.ready = true;
1113
+ send(c, "stream.heartbeat", {});
1114
+ const queued = c.pending.splice(0);
1115
+ for (const raw of queued) handle(c, raw);
1116
+ },
1117
+ message(ws, raw) {
1118
+ const c = clients.get(ws);
1119
+ if (!c || c.closed) return;
1120
+ if (!c.ready) {
1121
+ c.pending.push(raw);
1122
+ return;
1123
+ }
1124
+ handle(c, raw);
1125
+ },
1126
+ close(ws) {
1127
+ const c = clients.get(ws);
1128
+ if (c) c.closed = true;
1129
+ clients.delete(ws);
1130
+ }
1131
+ },
1132
+ sourceStatus(sourceId, status) {
1133
+ for (const c of clients.values()) if (c.ready) send(c, "source.status", { sourceId, status });
1134
+ },
1135
+ degraded(reason, sourceId) {
1136
+ for (const c of clients.values()) {
1137
+ if (c.ready) send(c, "stream.degraded", sourceId ? { reason, sourceId } : { reason });
1138
+ }
1139
+ },
1140
+ stats() {
1141
+ let subscribed = 0;
1142
+ for (const c of clients.values()) if (c.subscribed) subscribed++;
1143
+ return { clients: clients.size, subscribed, ...counters };
1144
+ },
1145
+ stop() {
1146
+ clearInterval(heartbeat);
1147
+ offUpdate();
1148
+ offRemoved();
1149
+ for (const c of clients.values()) {
1150
+ c.closed = true;
1151
+ try {
1152
+ c.socket.close(1001, "server shutting down");
1153
+ } catch {
1154
+ }
1155
+ }
1156
+ clients.clear();
1157
+ }
1158
+ };
1159
+ }
1160
+ export {
1161
+ DEFAULT_AIRBORNE_PRIORITY,
1162
+ DEFAULT_SURFACE_PRIORITY,
1163
+ FlightPlanStore,
1164
+ MovementDetector,
1165
+ TrackState,
1166
+ createFlightPlanStore,
1167
+ createLiveServer,
1168
+ createMovementDetector,
1169
+ createTrackFuser,
1170
+ createTrackState,
1171
+ defaultFusionOptions,
1172
+ isSyntheticId
1173
+ };