@aeroswarm/client 0.1.11 → 0.1.12

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/README.md CHANGED
@@ -1,474 +1,537 @@
1
- # @aeroswarm/client
2
-
3
- TypeScript SDK and React bindings for consuming AeroSwarm data: live track streaming over WebSocket, REST/tRPC/GraphQL APIs, alerts, replay, and map-ready track state management.
4
-
5
- This is the **only** package frontends should use — surveillance decoding, Kafka, and Redis all stay server-side; the SDK consumes the normalized event stream.
6
-
7
- ## Installation
8
-
9
- ```bash
10
- pnpm add @aeroswarm/client
11
- ```
12
-
13
- ## Entry points
14
-
15
- | Import | Contents |
16
- |--------|----------|
17
- | `@aeroswarm/client` | Everything from `/sdk` + shared types/utils |
18
- | `@aeroswarm/client/sdk` | `AeroSwarm` class, `AeroWsClient`, `TrackCache` |
19
- | `@aeroswarm/client/react` | React hooks, `AeroSwarmProvider`, Zustand track store |
20
-
21
- ## SDK usage (framework-agnostic)
22
-
23
- ```ts
24
- import { AeroSwarm } from "@aeroswarm/client/sdk";
25
-
26
- const client = new AeroSwarm({
27
- endpoint: "http://192.168.2.231:8070", // REST/tRPC/GraphQL base
28
- token: "<viewer-or-source-token>",
29
- // wsEndpoint?: string — defaults to endpoint with ws:// scheme
30
- // encoding?: "json" | "msgpack"
31
- // reconnect?: boolean, maxReconnectDelay?: number (ms, default 30 000)
32
- // staleAfterMs?, removeAfterMs? — client-side track lifecycle overrides
33
- // onTokenExpired?: () => Promise<string> — refresh hook
34
- });
35
-
36
- const unsubscribe = client.tracks.subscribe(
37
- {
38
- bbox: [94, -11, 141, 6.5], // lonMin, latMin, lonMax, latMax
39
- source: "fused", // "fused" | "normalized"
40
- minConfidence: 0.5,
41
- maxTracks: 500, // server-side top-N
42
- maxUpdateHz: 5, // per-track update cap — see performance notes
43
- mode: "delta", // "delta" (updates) | "full" (periodic snapshots)
44
- includeAlerts: false,
45
- },
46
- {
47
- onSnapshot: (tracks) => console.log("initial state", tracks.length),
48
- onTrackUpdate: (track) => console.log(track.trackId, track.lat, track.lon),
49
- onTrackRemoved:(ev) => console.log("removed", ev.trackId),
50
- onStatusChange:(status) => console.log("ws:", status),
51
- onError: (err) => console.error(err.code, err.message),
52
- },
53
- );
54
-
55
- // later
56
- unsubscribe();
57
- client.disconnect();
58
- ```
59
-
60
- ### `AeroSwarm` surface
61
-
62
- | Member | Description |
63
- |--------|-------------|
64
- | `tracks.subscribe(opts, callbacks)` | Live WS stream (`/ws/tracks/live`); returns unsubscribe fn. Auto-reconnects with backoff; server sends a fresh authoritative snapshot on reconnect |
65
- | `tracks.stream(opts)` | Same live stream as an `AsyncIterable<CurrentTrack>` (`for await`) |
66
- | `tracks.updateFilter(opts)` | Change bbox / minConfidence / maxUpdateHz / maxTracks without reconnecting |
67
- | `tracks.snapshot(input?)` | One-shot current state via tRPC (`airportId`, `bbox`, `source`, `minConfidence`, `limit`, `offset`) |
68
- | `tracks.history(input)` | Persisted position history for one track (`trackId`, `airportId?`, `limit?`) — federated airports are served by their own backend |
69
- | `airports.*` | Airport registry: `list/create/update/delete` + `surface`/`setSurface` (runway/taxiway/apron GeoJSON for the map) |
70
- | `sources.*` | Register/list (optionally by `airportId`)/delete data sources (returns per-source auth tokens) |
71
- | `alerts.*` | Alert history + live alert subscription |
72
- | `rules.*` | Detection-rule CRUD — **global or per-airport** (see example below) |
73
- | `routes.*` | Route conformance: pre-defined ground routes + live track clearances (deviation alerts) |
74
- | `analytics.movements()` | Takeoffs/landings per hour + peak hour, per airport |
75
- | `webhooks.*` | Webhook CRUD |
76
- | `replay.days(input?)` | Data-availability calendar — which days have recorded tracks (see example below) |
77
- | `replay.range/seek/window` | Historical replay metadata: range summary, point-in-time seek, time-bucketed window for scrubbing |
78
- | `replay.stream(opts, cb)` | **Server-paced WS playback** — historical tracks streamed like live (see example below) |
79
- | `system.*` | `stats`, `infraHealth`, `getConfig`/`setConfig`, `clearTable`, `ingestHistory` |
80
- | `trpc` / `rest` / `graphql` | Direct typed API clients for anything not wrapped above — **untyped** (`createTRPCProxyClient<any>`), so prefer the wrappers above; fall back to `client.trpc.<router>.<procedure>.query/mutate(...)` only for endpoints not yet wrapped |
81
- | `cache` | A `TrackCache` instance (see below) |
82
- | `getStatus()` / `getLastUpdateAt()` / `getError()` / `getSourceStatus()` | Connection introspection |
83
- | `disconnect()` | Tear down the WS connection |
84
-
85
- ## Per-domain usage examples
86
-
87
- Everything below assumes `const client = new AeroSwarm({ endpoint, token })`.
88
-
89
- ### Airports (multi-airport tenancy)
90
-
91
- ```ts
92
- const { airports } = await client.airports.list();
93
- // [{ id: "WARR", name: "Juanda International Airport", city, lat, lon, bbox, timezone }, …]
94
-
95
- await client.airports.create({
96
- id: "WARR", name: "Juanda International Airport",
97
- lat: -7.3798, lon: 112.7869, bbox: [112.7, -7.42, 112.86, -7.33],
98
- });
99
- await client.airports.update({ id: "WARR", city: "Surabaya" });
100
- await client.airports.delete({ id: "WARR" });
101
-
102
- // Runway/taxiway/apron GeoJSON for the map's surface layer
103
- const { surface } = await client.airports.surface({ id: "WARR" });
104
- ```
105
-
106
- Most other calls accept an `airportId` (ICAO code) to scope to one airport; omitting it means "all airports".
107
-
108
- ### Rules — global vs per-airport
109
-
110
- Rules with no `airportId` are **global**: the safety engine evaluates them against tracks from every airport. With an `airportId` they only apply to tracks stamped with that airport.
111
-
112
- ```ts
113
- // GLOBAL geofence — applies everywhere
114
- const { ruleId } = await client.rules.create({
115
- name: "Restricted area — national",
116
- type: "geofence", // geofence | altitude | speed | squawk | custom
117
- severity: "high", // low | medium | high | critical
118
- conditions: { kind: "restricted", polygon: [[lon, lat], …] },
119
- actions: [],
120
- });
121
-
122
- // Airport-scoped geofence — WARR tracks only
123
- await client.rules.create({
124
- name: "South Apron",
125
- type: "geofence",
126
- severity: "medium",
127
- conditions: { kind: "apron", polygon: [[lon, lat], …] },
128
- actions: [],
129
- airportId: "WARR",
130
- });
131
-
132
- await client.rules.list(); // every rule
133
- await client.rules.list({ airportId: "WARR" }); // one airport's rules
134
- await client.rules.update({ id: ruleId, enabled: false });
135
- await client.rules.delete({ id: ruleId });
136
- ```
137
-
138
- Geofence `conditions`: `kind` (`runway | taxiway | apron | restricted | custom`), `polygon` (`[lon, lat]` ring), optional `maxSpeedKt`, `appliesTo` (`all | aircraft | vehicle`), `runwayId` (ties a runway zone to active-runway suppression). Note: the safety engine currently evaluates `geofence` rules (plus built-in emergency-squawk/STCA/deviation); other types are stored but not evaluated.
139
-
140
- ### Routes — assigned-route conformance (deviation alerts)
141
-
142
- Routes are polylines of `[lat, lon]` vertices per airport (seeded from real OSM taxiways for WARR/WAHS/WAHI). Assign one to a live track and the safety engine starts emitting `deviation` alerts whenever the track strays beyond `maxDeviationM`; taxiway/apron incursion alerts are suppressed while the track conforms.
143
-
144
- ```ts
145
- // Route definitions
146
- const { routes } = await client.routes.list({ airportId: "WARR" });
147
- const { id } = await client.routes.create({
148
- airportId: "WARR",
149
- name: "Stand A1 → RWY 10",
150
- kind: "taxi", // taxi | departure | arrival
151
- points: [[-7.3822, 112.7794], [-7.3820, 112.7900], …], // [lat, lon]
152
- maxDeviationM: 50,
153
- });
154
- await client.routes.delete({ id });
155
-
156
- // Clearances (live track → route). Stored in Redis; the safety engine
157
- // refreshes them every 5 s.
158
- await client.routes.assign({ trackId: "trk_abc", routeId: routes[0].routeId });
159
- await client.routes.unassign({ trackId: "trk_abc" });
160
- const { assignments } = await client.routes.assignments();
161
- // [{ trackId, routeId, assignedAt }, …]
162
- ```
163
-
164
- The resulting `deviation` alerts arrive through the normal alert stream with `details: { routeId, route, deviationM, maxDeviationM, routeProgress }`.
165
-
166
- ### Alerts
167
-
168
- ```ts
169
- // History (tRPC)
170
- const { alerts } = await client.alerts.list({ severity: "high", limit: 50 });
171
-
172
- // Live subscription (WS)
173
- const stop = client.alerts.subscribe((alert) => {
174
- // { alertId, type, severity, trackId, triggeredAt, reason, details }
175
- // type: emergency | area_penetration | runway_incursion |
176
- // taxiway_incursion | deviation | stca | …
177
- showToast(alert);
178
- });
179
- stop();
180
- ```
181
-
182
- ### Movement analytics (takeoffs/landings per hour)
183
-
184
- ```ts
185
- const stats = await client.analytics.movements({ airportId: "WARR", day: "2026-07-14" });
186
- // {
187
- // day: "2026-07-14",
188
- // hours: [{ hour: "2026-07-14T07:00:00.000Z", takeoffs: 21, landings: 17 }, …],
189
- // totals: { takeoffs: 132, landings: 128 },
190
- // peak: { hour: "2026-07-14T07:00:00.000Z", count: 38 }, // jam puncak + jumlah
191
- // }
192
- ```
193
-
194
- Omit `airportId` for all airports; omit `day` for today (UTC). Movements are detected server-side by the shared `MovementDetector` (`@aeroswarm/core`) from fused-track transitions.
195
-
196
- ### Replay — server-paced WS playback
197
-
198
- The server streams historical tracks **at the requested speed** using the same envelopes as the live stream, so any renderer that handles live data can render a replay unchanged. Drive your playhead from `onProgress` (the server's clock) — never a local timer.
199
-
200
- ```ts
201
- const handle = await client.replay.stream(
202
- {
203
- from: "2026-07-14T02:00:00Z",
204
- to: "2026-07-14T03:00:00Z",
205
- speed: 2, // 2× real time (server-paced, drift-free)
206
- airportId: "WARR", // federated airport → its own backend serves this
207
- // sessionId: "…" // resume a previous session (kept 1 h in Redis)
208
- },
209
- {
210
- onSnapshot: (tracks) => seedMap(tracks), // state AT `from`
211
- onTrackUpdate: (t) => upsertMarker(t),
212
- onTrackRemoved: (ev) => removeMarker(ev.trackId),
213
- onProgress: (p) => setPlayhead(p.at, p.pct), // ISO time + 0-100 %
214
- onDone: () => setFinished(),
215
- onError: (err) => showError(err.message),
216
- },
217
- );
218
- // `stream()` is async — it first resolves whether `airportId` is a
219
- // federated airport (see "Federated history" below), which needs a lookup.
220
-
221
- handle.pause();
222
- handle.resume();
223
- handle.seek(new Date("2026-07-14T02:45:00Z")); // server restarts the paced stream there
224
- handle.stop(); // ends the session, closes the socket
225
- handle.sessionId; // save to resume after a page reload
226
- ```
227
-
228
- For range pickers, the metadata endpoints are still available:
229
-
230
- ```ts
231
- // Which days actually HAVE data (populate/validate the date picker)
232
- const { days } = await client.replay.days({ airportId: "WARR", lookbackDays: 60 });
233
- // [{ day: "2026-07-14", eventCount: 51230, trackCount: 42,
234
- // firstAt: "2026-07-14T01:58:03Z", lastAt: "2026-07-14T09:12:44Z" }, …]
235
- // A date absent from the list has no recorded data — don't offer it.
236
-
237
- const range = await client.replay.range({ from, to });
238
- // { earliest, latest, eventCount, trackCount }
239
- ```
240
-
241
- Not using the SDK? The wire protocol is plain JSON on `/ws/tracks/replay` — documented in `docs/sdk/websocket.md` (§ Replay Streaming). `AeroReplayClient` is also exported standalone.
242
-
243
- ### Federated history
244
-
245
- An airport can serve its **own** replay/history instead of AeroSwarm's — its own backend, its
246
- own database, reached directly (see `docs/development/federation/`). `replay.stream`,
247
- `replay.days`, `replay.range`, `tracks.history`, and `alerts.list` all resolve this
248
- automatically, in priority order:
249
-
250
- 1. **Explicit override** — pass `endpoint` and the registry is skipped entirely:
251
- ```ts
252
- await client.replay.days({ endpoint: "http://192.168.2.225:4000/aero" });
253
- ```
254
- 2. **Registry** — if the airport's `history_endpoint` is set (`client.airports.list()` /
255
- dashboard's Airports tab), calls for that `airportId` go there instead:
256
- ```ts
257
- const handle = await client.replay.stream({ from, to, airportId: "WARR" }, callbacks);
258
- // WARR has history_endpoint set → connects to that backend's WS replay, transparently
259
- ```
260
- 3. **AeroSwarm itself** — the default, unchanged behavior, for any airport with no endpoint set.
261
-
262
- A lookup failure (e.g. `airports.list()` errors) always falls back to AeroSwarm's own endpoint
263
- rather than breaking replay — federation degrades safely.
264
-
265
- `replay.window` and `replay.seek` are **not** part of the federation contract — they throw for a
266
- federated airport; use `replay.stream` (which covers the same "jump to a point in time" need via
267
- `seek()` on the returned handle) instead.
268
-
269
- Building the receiving side (an airport's own backend)? See
270
- [`@aeroswarm/replay-server`](../../server/replay-server/README.md) — mounts this exact protocol
271
- on top of your own database with a few lines of code.
272
-
273
- **Multi-airport.** Every replay call accepts `airportId` as *optional*. For a **federated**
274
- airport (one with a `historyEndpoint` — see the next section) it selects which airport backend
275
- to connect to; always pass it for those. Against AeroSwarm's own endpoint (dev/single-node
276
- mode) it's a row filter on the local database — omitting it returns all local rows. There is no
277
- cross-backend "all airports" merged replay: each federated airport's history lives behind its
278
- own backend, replayed one airport at a time.
279
-
280
- ### Sources & webhooks
281
-
282
- A **source** is a registered sensor/feed (radar, ADS-B, synthetic, …) identified by a bearer
283
- token (`sourceToken`). Tag it with `airportId` at creation and that token **carries its airport
284
- with it** from then on — anything that authenticates with it (WS `/ws/tracks/live`,
285
- `/ws/tracks/replay`, tRPC, REST) is automatically scoped to that airport, with no need to keep
286
- passing `airportId` on every call:
287
-
288
- ```ts
289
- const { sources } = await client.sources.list(); // all sources
290
- const { sources: warrSources } = await client.sources.list({ airportId: "WARR" });
291
-
292
- const created = await client.sources.create({
293
- name: "WARR ASTERIX feed",
294
- sourceType: "ASTERIX_CAT021",
295
- airportId: "WARR", // ← ties this token to WARR
296
- });
297
- // { ok: true, data: { sourceId: "src_...", sourceToken: "srctk_...", airportId: "WARR" } }
298
-
299
- // Later, a client connecting with that token doesn't need to pass airportId at all:
300
- const scoped = new AeroSwarm({ endpoint, wsEndpoint, token: created.data.sourceToken });
301
- await scoped.tracks.stream({}); // already scoped to WARR — the token carries it
302
-
303
- await client.sources.health("src_abc");
304
-
305
- // Reassign (or clear, with null) a source's airport after the fact — takes
306
- // effect immediately, no need to wait out the token cache TTL.
307
- await client.sources.update({ id: "src_abc", airportId: "CGK" });
308
- await client.sources.update({ id: "src_abc", airportId: null });
309
-
310
- await client.webhooks.create({ url: "https://ops.example/hook", events: ["alert"] });
311
- const hooks = await client.webhooks.list();
312
- await client.webhooks.delete({ id: hooks[0].id });
313
- ```
314
-
315
- `airportId` is required on `sources.create` — a source is a sensor physically at one airport, so
316
- a token that isn't tied to any airport is more footgun than feature. (Sources created before this
317
- was enforced may still have `airportId: null`; `sources.update` can backfill one.) For access
318
- that spans multiple airports, use the viewer/dashboard token instead — it's never auto-scoped
319
- this way, regardless of any source's configuration, and always sees whatever `airportId` (or lack
320
- thereof) it explicitly asks for.
321
-
322
- ### `TrackCache`
323
-
324
- In-memory `Map`-backed track store with a typed emitter — the primitive the React store builds on:
325
-
326
- `applyUpdate(track)`, `applyRemoved(ev)`, `applySnapshot(tracks)` (authoritative — replaces contents), `getAll()`, `get(id)`, `getRenderable()` (only tracks with valid lat/lon), `size`, `clear()`, plus `update`/`removed`/`snapshot` events.
327
-
328
- ### `AeroWsClient`
329
-
330
- Low-level WebSocket client (subscribe payloads, sequence tracking, heartbeats, reconnect/backoff, JSON + msgpack). Use `AeroSwarm.tracks.subscribe` unless you're building your own state layer.
331
-
332
- ## React usage
333
-
334
- ```tsx
335
- import { AeroSwarmProvider, useAeroTracks } from "@aeroswarm/client/react";
336
-
337
- function App() {
338
- return (
339
- <AeroSwarmProvider endpoint="http://…:8070" token={token}>
340
- <LiveMap />
341
- </AeroSwarmProvider>
342
- );
343
- }
344
-
345
- function LiveMap() {
346
- const { tracks, status, error, lastUpdateAt } = useAeroTracks({
347
- bbox: [94, -11, 141, 6.5],
348
- minConfidence: 0.5,
349
- maxUpdateHz: 5,
350
- });
351
-
352
- if (status !== "connected") return <p>{status}…</p>;
353
- return <ul>{tracks.map((t) => <li key={t.trackId}>{t.callsign ?? t.icao24}</li>)}</ul>;
354
- }
355
- ```
356
-
357
- ### Hooks (`@aeroswarm/client/react`)
358
-
359
- | Hook | Purpose |
360
- |------|---------|
361
- | `useAeroTracks(opts?)` (alias `useTracks`) | Live track list + connection state for a subscription |
362
- | `useAeroTrack(trackId)` | Single track by id |
363
- | `useAeroSnapshot(opts?)` | One-shot snapshot fetch |
364
- | `useAeroStatus()` | Connection status only |
365
- | `useTrackCount()` | Track count only (cheap selector) |
366
- | `useSourceHealth(sourceId?)` | Source health/status |
367
- | `useAlerts(opts?)` | Live alerts |
368
- | `useRules(opts?)` / `useRule(id, opts?)` | Alert rules |
369
- | `useWebhooks(opts?)` | Webhooks |
370
- | `useReplayStatus(sessionId, opts?)` / `useReplayStop()` | Replay session control |
371
- | `useLineage(eventId, opts?)` / `useLineageTrace(trackId, opts?)` | Event lineage/provenance |
372
- | `useTrackHistory(options)` | **Map animation engine** — see below |
373
- | `useAeroSwarmContext()` | Raw client from the provider |
374
-
375
- ### `useTrackStore` — Zustand store
376
-
377
- Shared live-track state; feed it from your subscription callbacks and read it imperatively (maps) or via selectors (React):
378
-
379
- | Member | Notes |
380
- |--------|-------|
381
- | `tracks: Map<string, CurrentTrack>` | Current state |
382
- | `applyUpdate(track)` | One store write per event — prefer `applyBatch` at high rates |
383
- | `applyBatch(updates, removedIds)` | **One Map clone per batch** — use this from buffered/throttled feeds |
384
- | `applyRemoved({trackId})` / `applySnapshot(tracks)` | Snapshot is authoritative (replaces state — ghosts from a dropped connection are cleared) |
385
- | `pruneStale(maxAgeMs)` | Remove tracks not updated within `maxAgeMs`; returns removed ids. **Call on an interval** (e.g. 60 s sweep every 10 s) — track ids churn on feed/replay restarts and abandoned ids never get a `removed` event, so without pruning they linger as ghost icons |
386
- | `setStatus` / `setSourceStatus` / `setError` / `setDegraded` / `clear` | Connection metadata |
387
- | Selectors | `selectAllTracks`, `selectRenderableTracks`, `selectTrack(id)`, `selectTrackCount`, `selectStatus`, `selectIsDegraded`, `selectLastUpdateAt`, `selectError` |
388
-
389
- ### `useTrackHistory` — smooth map animation
390
-
391
- Maintains per-track position interpolation (tweening), dead-reckoning between updates, and bounded trail history, emitting animation frames outside React render:
392
-
393
- ```ts
394
- const { processUpdate, processRemove } = useTrackHistory({
395
- maxHistoryPoints: 500, // trail length cap per track
396
- minDeltaDeg: 0.0001, // ignore sub-threshold jitter
397
- maxJumpDeg: 5.0, // discard implausible teleports
398
- onFrame: ({ currentPositions, history, isMoving }) => drawMarkers(...),
399
- });
400
- ```
401
-
402
- Update gaps longer than 30 s (lost coverage, socket offline) restart the track's trail instead of drawing a straight line across the map. Speeds beyond 1 200 kt (misdecodes, or position deltas divided by a compressed replay clock) are rejected and never drive dead reckoning.
403
-
404
- ## Frontend recipes
405
-
406
- ### Live map, end to end
407
-
408
- The proven wiring (this is what the reference dashboard does):
409
-
410
- ```tsx
411
- // 1. Subscription → store, batched (never setState per message)
412
- const pending = new Map<string, CurrentTrack>();
413
- const removed = new Set<string>();
414
- client.tracks.subscribe(opts, {
415
- onSnapshot: (ts) => useTrackStore.getState().applySnapshot(ts),
416
- onTrackUpdate: (t) => { pending.set(t.trackId, t); removed.delete(t.trackId); },
417
- onTrackRemoved: (ev) => { pending.delete(ev.trackId); removed.add(ev.trackId); },
418
- });
419
- setInterval(() => {
420
- useTrackStore.getState().applyBatch([...pending.values()], [...removed]);
421
- pending.clear(); removed.clear();
422
- }, 250);
423
-
424
- // 2. Ghost cleanup — ids churn on feed/replay restarts
425
- setInterval(() => { useTrackStore.getState().pruneStale(60_000); }, 10_000);
426
-
427
- // 3. Store → animation engine → map markers (imperative, no React re-render)
428
- const { processUpdate, processRemove } = useTrackHistory({ onFrame: drawMarkers });
429
- useTrackStore.subscribe((state, prev) => {
430
- for (const [id, t] of state.tracks) if (prev.tracks.get(id) !== t) processUpdate(t);
431
- for (const id of prev.tracks.keys()) if (!state.tracks.has(id)) processRemove(id);
432
- });
433
- ```
434
-
435
- ### Replay in the same map
436
-
437
- Feed `replay.stream` callbacks into the **same** store/animation path and pause the live subscription while a session is active — replay then looks exactly like live. Batch `onTrackUpdate` into ~250 ms flushes the same way.
438
-
439
- ### Assigned-route overlay
440
-
441
- ```ts
442
- const { routes } = await client.routes.list({ airportId });
443
- const { assignments } = await client.routes.assignments();
444
- const route = routes.find(r => r.routeId === assignments.find(a => a.trackId === selectedId)?.routeId);
445
- // draw route.points ([lat, lon][]) as a dashed LineString: points.map(([lat, lon]) => [lon, lat])
446
- ```
447
-
448
- ## Performance notes (important for dashboards)
449
-
450
- - **Don't call a React `setState` per WS message.** At realistic rates (hundreds of tracks × several Hz) that renders your page thousands of times per second. Buffer updates and flush on an interval (~250 ms to the store for the map, ~1 s to React state for panels), using `applyBatch`.
451
- - **Request only the rate you need** — `maxUpdateHz: 5` with client-side interpolation looks identical to 20 Hz on a map and cuts WS traffic 4×.
452
- - Drive map markers **imperatively** from `useTrackStore.subscribe(...)` + `useTrackHistory`, not through React props.
453
-
454
- ## Wire encoding
455
-
456
- `json` (default) or `msgpack` (binary, lower bandwidth) — set once in `AeroSwarmOptions.encoding` or per-subscription.
457
-
458
- ## Environment variables (Vite frontends)
459
-
460
- | Variable | Description |
461
- |----------|-------------|
462
- | `VITE_AEROSWARM_API_URL` | REST/tRPC/GraphQL base URL |
463
- | `VITE_AEROSWARM_WS_URL` | WebSocket base URL |
464
- | `VITE_AEROSWARM_API_KEY` | Viewer token |
465
-
466
- These are baked into the bundle at build time (Vite), not read at runtime.
467
-
468
- ## Building & testing
469
-
470
- ```bash
471
- pnpm run build # tsup → dist/ (index + /sdk + /react)
472
- pnpm run typecheck
473
- pnpm test
474
- ```
1
+ # @aeroswarm/client
2
+
3
+ TypeScript SDK and React bindings for consuming AeroSwarm data: live track streaming over WebSocket, REST/tRPC/GraphQL APIs, alerts, replay, and map-ready track state management.
4
+
5
+ This is the **only** package frontends should use — surveillance decoding, Kafka, and Redis all stay server-side; the SDK consumes the normalized event stream.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add @aeroswarm/client
11
+ ```
12
+
13
+ ## Entry points
14
+
15
+ | Import | Contents |
16
+ |--------|----------|
17
+ | `@aeroswarm/client` | Everything from `/sdk` + shared types/utils |
18
+ | `@aeroswarm/client/sdk` | `AeroSwarm` class, `AeroWsClient`, `TrackCache` |
19
+ | `@aeroswarm/client/react` | React hooks, `AeroSwarmProvider`, Zustand track store |
20
+
21
+ ## SDK usage (framework-agnostic)
22
+
23
+ ```ts
24
+ import { AeroSwarm } from "@aeroswarm/client/sdk";
25
+
26
+ const client = new AeroSwarm({
27
+ endpoint: "http://192.168.2.231:8070", // REST/tRPC/GraphQL base
28
+ token: "<viewer-or-source-token>",
29
+ // wsEndpoint?: string — defaults to endpoint with ws:// scheme
30
+ // encoding?: "json" | "msgpack"
31
+ // reconnect?: boolean, maxReconnectDelay?: number (ms, default 30 000)
32
+ // staleAfterMs?, removeAfterMs? — client-side track lifecycle overrides
33
+ // onTokenExpired?: () => Promise<string> — refresh hook
34
+ });
35
+
36
+ const unsubscribe = client.tracks.subscribe(
37
+ {
38
+ bbox: [94, -11, 141, 6.5], // lonMin, latMin, lonMax, latMax
39
+ source: "fused", // "fused" | "normalized"
40
+ minConfidence: 0.5,
41
+ maxTracks: 500, // server-side top-N
42
+ maxUpdateHz: 5, // per-track update cap — see performance notes
43
+ mode: "delta", // "delta" (updates) | "full" (periodic snapshots)
44
+ includeAlerts: false,
45
+ },
46
+ {
47
+ onSnapshot: (tracks) => console.log("initial state", tracks.length),
48
+ onTrackUpdate: (track) => console.log(track.trackId, track.lat, track.lon),
49
+ onTrackRemoved:(ev) => console.log("removed", ev.trackId),
50
+ onStatusChange:(status) => console.log("ws:", status),
51
+ onError: (err) => console.error(err.code, err.message),
52
+ },
53
+ );
54
+
55
+ // later
56
+ unsubscribe();
57
+ client.disconnect();
58
+ ```
59
+
60
+ ### `AeroSwarm` surface
61
+
62
+ | Member | Description |
63
+ |--------|-------------|
64
+ | `tracks.subscribe(opts, callbacks)` | Live WS stream (`/ws/tracks/live`); returns unsubscribe fn. Auto-reconnects with backoff; server sends a fresh authoritative snapshot on reconnect |
65
+ | `tracks.stream(opts)` | Same live stream as an `AsyncIterable<LabeledTrack>` (`for await`) |
66
+ | `tracks.updateFilter(opts)` | Change bbox / minConfidence / maxUpdateHz / maxTracks without reconnecting |
67
+ | `tracks.reconnect()` | Force an immediate reconnect (skips backoff, re-subscribes with the last options, resumes after `disconnect()`); `false` if never subscribed. Also `AeroSwarm.reconnect()` and `useAeroTracks().reconnect` |
68
+
69
+ ### Label category / color
70
+
71
+ Every track from `tracks.subscribe` / `tracks.stream` callbacks, the cache and `useAeroTracks` carries `labelCategory` and `labelColor`, derived after merging deltas so a delta without `flightPlan` never changes the color:
72
+
73
+ | Category | Color | When |
74
+ |---|---|---|
75
+ | `ARRIVAL` | `#FACC15` yellow | `flightPlan.phase === "ARRIVAL"` |
76
+ | `DEPARTURE` | `#22C55E` green | `flightPlan.phase === "DEPARTURE"` |
77
+ | `GROUND_VEHICLE` | `#3B82F6` blue | `targetType === "vehicle"` |
78
+ | `LOST` | `#FFFFFF` white | `status === "lost"` (takes priority) |
79
+ | `UNKNOWN` | `#FFFFFF` white | no flight plan / overflight |
80
+
81
+ Also exported: `LABEL_COLORS`, `getLabelCategory(track)`, `withLabel(track)`.
82
+
83
+ ### Response data (live WS)
84
+
85
+ The server sends JSON envelopes `{ type, seq, serverTime, payload }`. The client validates them, drops out-of-order `seq`, and re-requests a snapshot after a gap. You never see envelopes — you get typed callbacks:
86
+
87
+ | Callback | Payload | Notes |
88
+ |---|---|---|
89
+ | `onSnapshot` | `LabeledTrack[]` | Full authoritative state; sent on subscribe and on every reconnect |
90
+ | `onTrackUpdate` | `LabeledTrack` | Delta **merged with the previous state** of that track (fields the delta omits keep their last value). Also fires when `status` becomes `"lost"` (the track is then evicted from `cache`) |
91
+ | `onTrackRemoved` | `{ trackId, reason }` | Server dropped the track |
92
+ | `onSourceStatus` | `{ sourceId, status }` | `healthy` \| `degraded` \| `offline` |
93
+ | `onStatusChange` | `AeroConnectionStatus` | `idle` \| `connecting` \| `connected` \| `reconnecting` \| `degraded` \| `offline` |
94
+ | `onDegraded` | `reason: string` | e.g. `heartbeat_timeout` |
95
+ | `onError` | `{ code, message, recoverable }` | |
96
+
97
+ `LabeledTrack` = `CurrentTrack` + label fields:
98
+
99
+ ```jsonc
100
+ {
101
+ "trackId": "fused-4b1a2c",
102
+ "source": "FUSED", // ADSB | ASTERIX_CAT010/011/020/021/048/062 | FUSED
103
+ "icao24": "8a0123", // string | null
104
+ "callsign": "GIA123", // string | null
105
+ "squawk": "1200", // string | null
106
+ "targetType": "aircraft", // optional: aircraft | vehicle | unknown
107
+ "vehicleFleet": 3, // optional, only for vehicles
108
+ "airportId": "WARR", // optional ICAO scope
109
+ "lat": -7.3797, "lon": 112.7868,// number | null
110
+ "altitudeFt": 0,
111
+ "groundSpeedKt": 12,
112
+ "headingDeg": 90,
113
+ "verticalRateFpm": 0,
114
+ "confidence": 0.98, // 0..1
115
+ "lastSeenAt": 1761640000000, // epoch ms
116
+ "status": "active", // active | stale | lost
117
+ "flightPlan": { // optional: only when correlated with an AFTN plan
118
+ "id": "GIA123|WARR|WIII|20261028",
119
+ "adep": "WARR", "ades": "WIII",
120
+ "status": "filed", // filed | delayed | departed | arrived | cancelled
121
+ "phase": "DEPARTURE" // ARRIVAL | DEPARTURE | OVERFLIGHT | UNKNOWN (relative to airportId)
122
+ },
123
+ // added by the client:
124
+ "labelCategory": "DEPARTURE", // ARRIVAL | DEPARTURE | GROUND_VEHICLE | LOST | UNKNOWN
125
+ "labelColor": "#22C55E"
126
+ }
127
+ ```
128
+
129
+ `status` and `labelColor` change only when the track's category or status actually changes — position-only deltas leave them untouched, so a label painted from `labelColor` does not flicker.
130
+ | `tracks.snapshot(input?)` | One-shot current state via tRPC (`airportId`, `bbox`, `source`, `minConfidence`, `limit`, `offset`) |
131
+ | `tracks.history(input)` | Persisted position history for one track (`trackId`, `airportId?`, `limit?`) — federated airports are served by their own backend |
132
+ | `airports.*` | Airport registry: `list/create/update/delete` + `surface`/`setSurface` (runway/taxiway/apron GeoJSON for the map) |
133
+ | `sources.*` | Register/list (optionally by `airportId`)/delete data sources (returns per-source auth tokens) |
134
+ | `alerts.*` | Alert history + live alert subscription |
135
+ | `rules.*` | Detection-rule CRUD — **global or per-airport** (see example below) |
136
+ | `routes.*` | Route conformance: pre-defined ground routes + live track clearances (deviation alerts) |
137
+ | `analytics.movements()` | Takeoffs/landings per hour + peak hour, per airport |
138
+ | `webhooks.*` | Webhook CRUD |
139
+ | `replay.days(input?)` | Data-availability calendar — which days have recorded tracks (see example below) |
140
+ | `replay.range/seek/window` | Historical replay metadata: range summary, point-in-time seek, time-bucketed window for scrubbing |
141
+ | `replay.stream(opts, cb)` | **Server-paced WS playback** — historical tracks streamed like live (see example below) |
142
+ | `system.*` | `stats`, `infraHealth`, `getConfig`/`setConfig`, `clearTable`, `ingestHistory` |
143
+ | `trpc` / `rest` / `graphql` | Direct typed API clients for anything not wrapped above — **untyped** (`createTRPCProxyClient<any>`), so prefer the wrappers above; fall back to `client.trpc.<router>.<procedure>.query/mutate(...)` only for endpoints not yet wrapped |
144
+ | `cache` | A `TrackCache` instance (see below) |
145
+ | `getStatus()` / `getLastUpdateAt()` / `getError()` / `getSourceStatus()` | Connection introspection |
146
+ | `disconnect()` | Tear down the WS connection |
147
+
148
+ ## Per-domain usage examples
149
+
150
+ Everything below assumes `const client = new AeroSwarm({ endpoint, token })`.
151
+
152
+ ### Airports (multi-airport tenancy)
153
+
154
+ ```ts
155
+ const { airports } = await client.airports.list();
156
+ // [{ id: "WARR", name: "Juanda International Airport", city, lat, lon, bbox, timezone }, …]
157
+
158
+ await client.airports.create({
159
+ id: "WARR", name: "Juanda International Airport",
160
+ lat: -7.3798, lon: 112.7869, bbox: [112.7, -7.42, 112.86, -7.33],
161
+ });
162
+ await client.airports.update({ id: "WARR", city: "Surabaya" });
163
+ await client.airports.delete({ id: "WARR" });
164
+
165
+ // Runway/taxiway/apron GeoJSON for the map's surface layer
166
+ const { surface } = await client.airports.surface({ id: "WARR" });
167
+ ```
168
+
169
+ Most other calls accept an `airportId` (ICAO code) to scope to one airport; omitting it means "all airports".
170
+
171
+ ### Rules — global vs per-airport
172
+
173
+ Rules with no `airportId` are **global**: the safety engine evaluates them against tracks from every airport. With an `airportId` they only apply to tracks stamped with that airport.
174
+
175
+ ```ts
176
+ // GLOBAL geofence — applies everywhere
177
+ const { ruleId } = await client.rules.create({
178
+ name: "Restricted area — national",
179
+ type: "geofence", // geofence | altitude | speed | squawk | custom
180
+ severity: "high", // low | medium | high | critical
181
+ conditions: { kind: "restricted", polygon: [[lon, lat], …] },
182
+ actions: [],
183
+ });
184
+
185
+ // Airport-scoped geofence — WARR tracks only
186
+ await client.rules.create({
187
+ name: "South Apron",
188
+ type: "geofence",
189
+ severity: "medium",
190
+ conditions: { kind: "apron", polygon: [[lon, lat], …] },
191
+ actions: [],
192
+ airportId: "WARR",
193
+ });
194
+
195
+ await client.rules.list(); // every rule
196
+ await client.rules.list({ airportId: "WARR" }); // one airport's rules
197
+ await client.rules.update({ id: ruleId, enabled: false });
198
+ await client.rules.delete({ id: ruleId });
199
+ ```
200
+
201
+ Geofence `conditions`: `kind` (`runway | taxiway | apron | restricted | custom`), `polygon` (`[lon, lat]` ring), optional `maxSpeedKt`, `appliesTo` (`all | aircraft | vehicle`), `runwayId` (ties a runway zone to active-runway suppression). Note: the safety engine currently evaluates `geofence` rules (plus built-in emergency-squawk/STCA/deviation); other types are stored but not evaluated.
202
+
203
+ ### Routes — assigned-route conformance (deviation alerts)
204
+
205
+ Routes are polylines of `[lat, lon]` vertices per airport (seeded from real OSM taxiways for WARR/WAHS/WAHI). Assign one to a live track and the safety engine starts emitting `deviation` alerts whenever the track strays beyond `maxDeviationM`; taxiway/apron incursion alerts are suppressed while the track conforms.
206
+
207
+ ```ts
208
+ // Route definitions
209
+ const { routes } = await client.routes.list({ airportId: "WARR" });
210
+ const { id } = await client.routes.create({
211
+ airportId: "WARR",
212
+ name: "Stand A1 → RWY 10",
213
+ kind: "taxi", // taxi | departure | arrival
214
+ points: [[-7.3822, 112.7794], [-7.3820, 112.7900], …], // [lat, lon]
215
+ maxDeviationM: 50,
216
+ });
217
+ await client.routes.delete({ id });
218
+
219
+ // Clearances (live track → route). Stored in Redis; the safety engine
220
+ // refreshes them every 5 s.
221
+ await client.routes.assign({ trackId: "trk_abc", routeId: routes[0].routeId });
222
+ await client.routes.unassign({ trackId: "trk_abc" });
223
+ const { assignments } = await client.routes.assignments();
224
+ // [{ trackId, routeId, assignedAt }, …]
225
+ ```
226
+
227
+ The resulting `deviation` alerts arrive through the normal alert stream with `details: { routeId, route, deviationM, maxDeviationM, routeProgress }`.
228
+
229
+ ### Alerts
230
+
231
+ ```ts
232
+ // History (tRPC)
233
+ const { alerts } = await client.alerts.list({ severity: "high", limit: 50 });
234
+
235
+ // Live subscription (WS)
236
+ const stop = client.alerts.subscribe((alert) => {
237
+ // { alertId, type, severity, trackId, triggeredAt, reason, details }
238
+ // type: emergency | area_penetration | runway_incursion |
239
+ // taxiway_incursion | deviation | stca | …
240
+ showToast(alert);
241
+ });
242
+ stop();
243
+ ```
244
+
245
+ ### Movement analytics (takeoffs/landings per hour)
246
+
247
+ ```ts
248
+ const stats = await client.analytics.movements({ airportId: "WARR", day: "2026-07-14" });
249
+ // {
250
+ // day: "2026-07-14",
251
+ // hours: [{ hour: "2026-07-14T07:00:00.000Z", takeoffs: 21, landings: 17 }, …],
252
+ // totals: { takeoffs: 132, landings: 128 },
253
+ // peak: { hour: "2026-07-14T07:00:00.000Z", count: 38 }, // jam puncak + jumlah
254
+ // }
255
+ ```
256
+
257
+ Omit `airportId` for all airports; omit `day` for today (UTC). Movements are detected server-side by the shared `MovementDetector` (`@aeroswarm/core`) from fused-track transitions.
258
+
259
+ ### Replay — server-paced WS playback
260
+
261
+ The server streams historical tracks **at the requested speed** using the same envelopes as the live stream, so any renderer that handles live data can render a replay unchanged. Drive your playhead from `onProgress` (the server's clock) — never a local timer.
262
+
263
+ ```ts
264
+ const handle = await client.replay.stream(
265
+ {
266
+ from: "2026-07-14T02:00:00Z",
267
+ to: "2026-07-14T03:00:00Z",
268
+ speed: 2, // 2× real time (server-paced, drift-free)
269
+ airportId: "WARR", // federated airport → its own backend serves this
270
+ // sessionId: "…" // resume a previous session (kept 1 h in Redis)
271
+ },
272
+ {
273
+ onSnapshot: (tracks) => seedMap(tracks), // state AT `from`
274
+ onTrackUpdate: (t) => upsertMarker(t),
275
+ onTrackRemoved: (ev) => removeMarker(ev.trackId),
276
+ onProgress: (p) => setPlayhead(p.at, p.pct), // ISO time + 0-100 %
277
+ onDone: () => setFinished(),
278
+ onError: (err) => showError(err.message),
279
+ },
280
+ );
281
+ // `stream()` is async — it first resolves whether `airportId` is a
282
+ // federated airport (see "Federated history" below), which needs a lookup.
283
+
284
+ handle.pause();
285
+ handle.resume();
286
+ handle.seek(new Date("2026-07-14T02:45:00Z")); // server restarts the paced stream there
287
+ handle.stop(); // ends the session, closes the socket
288
+ handle.sessionId; // save to resume after a page reload
289
+ ```
290
+
291
+ For range pickers, the metadata endpoints are still available:
292
+
293
+ ```ts
294
+ // Which days actually HAVE data (populate/validate the date picker)
295
+ const { days } = await client.replay.days({ airportId: "WARR", lookbackDays: 60 });
296
+ // [{ day: "2026-07-14", eventCount: 51230, trackCount: 42,
297
+ // firstAt: "2026-07-14T01:58:03Z", lastAt: "2026-07-14T09:12:44Z" }, …]
298
+ // A date absent from the list has no recorded data — don't offer it.
299
+
300
+ const range = await client.replay.range({ from, to });
301
+ // { earliest, latest, eventCount, trackCount }
302
+ ```
303
+
304
+ Not using the SDK? The wire protocol is plain JSON on `/ws/tracks/replay` — documented in `docs/sdk/websocket.md` (§ Replay Streaming). `AeroReplayClient` is also exported standalone.
305
+
306
+ ### Federated history
307
+
308
+ An airport can serve its **own** replay/history instead of AeroSwarm's — its own backend, its
309
+ own database, reached directly (see `docs/development/federation/`). `replay.stream`,
310
+ `replay.days`, `replay.range`, `tracks.history`, and `alerts.list` all resolve this
311
+ automatically, in priority order:
312
+
313
+ 1. **Explicit override** — pass `endpoint` and the registry is skipped entirely:
314
+ ```ts
315
+ await client.replay.days({ endpoint: "http://192.168.2.225:4000/aero" });
316
+ ```
317
+ 2. **Registry** — if the airport's `history_endpoint` is set (`client.airports.list()` /
318
+ dashboard's Airports tab), calls for that `airportId` go there instead:
319
+ ```ts
320
+ const handle = await client.replay.stream({ from, to, airportId: "WARR" }, callbacks);
321
+ // WARR has history_endpoint set → connects to that backend's WS replay, transparently
322
+ ```
323
+ 3. **AeroSwarm itself** — the default, unchanged behavior, for any airport with no endpoint set.
324
+
325
+ A lookup failure (e.g. `airports.list()` errors) always falls back to AeroSwarm's own endpoint
326
+ rather than breaking replay — federation degrades safely.
327
+
328
+ `replay.window` and `replay.seek` are **not** part of the federation contract — they throw for a
329
+ federated airport; use `replay.stream` (which covers the same "jump to a point in time" need via
330
+ `seek()` on the returned handle) instead.
331
+
332
+ Building the receiving side (an airport's own backend)? See
333
+ [`@aeroswarm/replay-server`](../../server/replay-server/README.md) — mounts this exact protocol
334
+ on top of your own database with a few lines of code.
335
+
336
+ **Multi-airport.** Every replay call accepts `airportId` as *optional*. For a **federated**
337
+ airport (one with a `historyEndpoint` — see the next section) it selects which airport backend
338
+ to connect to; always pass it for those. Against AeroSwarm's own endpoint (dev/single-node
339
+ mode) it's a row filter on the local database — omitting it returns all local rows. There is no
340
+ cross-backend "all airports" merged replay: each federated airport's history lives behind its
341
+ own backend, replayed one airport at a time.
342
+
343
+ ### Sources & webhooks
344
+
345
+ A **source** is a registered sensor/feed (radar, ADS-B, synthetic, …) identified by a bearer
346
+ token (`sourceToken`). Tag it with `airportId` at creation and that token **carries its airport
347
+ with it** from then on — anything that authenticates with it (WS `/ws/tracks/live`,
348
+ `/ws/tracks/replay`, tRPC, REST) is automatically scoped to that airport, with no need to keep
349
+ passing `airportId` on every call:
350
+
351
+ ```ts
352
+ const { sources } = await client.sources.list(); // all sources
353
+ const { sources: warrSources } = await client.sources.list({ airportId: "WARR" });
354
+
355
+ const created = await client.sources.create({
356
+ name: "WARR ASTERIX feed",
357
+ sourceType: "ASTERIX_CAT021",
358
+ airportId: "WARR", // ← ties this token to WARR
359
+ });
360
+ // { ok: true, data: { sourceId: "src_...", sourceToken: "srctk_...", airportId: "WARR" } }
361
+
362
+ // Later, a client connecting with that token doesn't need to pass airportId at all:
363
+ const scoped = new AeroSwarm({ endpoint, wsEndpoint, token: created.data.sourceToken });
364
+ await scoped.tracks.stream({}); // already scoped to WARR — the token carries it
365
+
366
+ await client.sources.health("src_abc");
367
+
368
+ // Reassign (or clear, with null) a source's airport after the fact — takes
369
+ // effect immediately, no need to wait out the token cache TTL.
370
+ await client.sources.update({ id: "src_abc", airportId: "CGK" });
371
+ await client.sources.update({ id: "src_abc", airportId: null });
372
+
373
+ await client.webhooks.create({ url: "https://ops.example/hook", events: ["alert"] });
374
+ const hooks = await client.webhooks.list();
375
+ await client.webhooks.delete({ id: hooks[0].id });
376
+ ```
377
+
378
+ `airportId` is required on `sources.create` — a source is a sensor physically at one airport, so
379
+ a token that isn't tied to any airport is more footgun than feature. (Sources created before this
380
+ was enforced may still have `airportId: null`; `sources.update` can backfill one.) For access
381
+ that spans multiple airports, use the viewer/dashboard token instead — it's never auto-scoped
382
+ this way, regardless of any source's configuration, and always sees whatever `airportId` (or lack
383
+ thereof) it explicitly asks for.
384
+
385
+ ### `TrackCache`
386
+
387
+ In-memory `Map`-backed track store with a typed emitter — the primitive the React store builds on:
388
+
389
+ `applyUpdate(track)`, `applyRemoved(ev)`, `applySnapshot(tracks)` (authoritative — replaces contents), `getAll()`, `get(id)`, `getRenderable()` (only tracks with valid lat/lon), `size`, `clear()`, plus `update`/`removed`/`snapshot` events.
390
+
391
+ ### `AeroWsClient`
392
+
393
+ Low-level WebSocket client (subscribe payloads, sequence tracking, heartbeats, reconnect/backoff, JSON + msgpack). Use `AeroSwarm.tracks.subscribe` unless you're building your own state layer.
394
+
395
+ ## React usage
396
+
397
+ ```tsx
398
+ import { AeroSwarmProvider, useAeroTracks } from "@aeroswarm/client/react";
399
+
400
+ function App() {
401
+ return (
402
+ <AeroSwarmProvider endpoint="http://…:8070" token={token}>
403
+ <LiveMap />
404
+ </AeroSwarmProvider>
405
+ );
406
+ }
407
+
408
+ function LiveMap() {
409
+ const { tracks, status, error, lastUpdateAt } = useAeroTracks({
410
+ bbox: [94, -11, 141, 6.5],
411
+ minConfidence: 0.5,
412
+ maxUpdateHz: 5,
413
+ });
414
+
415
+ if (status !== "connected") return <p>{status}…</p>;
416
+ return <ul>{tracks.map((t) => <li key={t.trackId}>{t.callsign ?? t.icao24}</li>)}</ul>;
417
+ }
418
+ ```
419
+
420
+ ### Hooks (`@aeroswarm/client/react`)
421
+
422
+ | Hook | Purpose |
423
+ |------|---------|
424
+ | `useAeroTracks(opts?)` (alias `useTracks`) | Live track list + connection state for a subscription |
425
+ | `useAeroTrack(trackId)` | Single track by id |
426
+ | `useAeroSnapshot(opts?)` | One-shot snapshot fetch |
427
+ | `useAeroStatus()` | Connection status only |
428
+ | `useTrackCount()` | Track count only (cheap selector) |
429
+ | `useSourceHealth(sourceId?)` | Source health/status |
430
+ | `useAlerts(opts?)` | Live alerts |
431
+ | `useRules(opts?)` / `useRule(id, opts?)` | Alert rules |
432
+ | `useWebhooks(opts?)` | Webhooks |
433
+ | `useReplayStatus(sessionId, opts?)` / `useReplayStop()` | Replay session control |
434
+ | `useLineage(eventId, opts?)` / `useLineageTrace(trackId, opts?)` | Event lineage/provenance |
435
+ | `useTrackHistory(options)` | **Map animation engine** — see below |
436
+ | `useAeroSwarmContext()` | Raw client from the provider |
437
+
438
+ ### `useTrackStore` — Zustand store
439
+
440
+ Shared live-track state; feed it from your subscription callbacks and read it imperatively (maps) or via selectors (React):
441
+
442
+ | Member | Notes |
443
+ |--------|-------|
444
+ | `tracks: Map<string, LabeledTrack>` | Current state (each track carries `labelCategory` / `labelColor`) |
445
+ | `applyUpdate(track)` | One store write per event — prefer `applyBatch` at high rates |
446
+ | `applyBatch(updates, removedIds)` | **One Map clone per batch** — use this from buffered/throttled feeds |
447
+ | `applyRemoved({trackId})` / `applySnapshot(tracks)` | Snapshot is authoritative (replaces state — ghosts from a dropped connection are cleared) |
448
+ | `pruneStale(maxAgeMs)` | Remove tracks not updated within `maxAgeMs`; returns removed ids. **Call on an interval** (e.g. 60 s sweep every 10 s) — track ids churn on feed/replay restarts and abandoned ids never get a `removed` event, so without pruning they linger as ghost icons |
449
+ | `setStatus` / `setSourceStatus` / `setError` / `setDegraded` / `clear` | Connection metadata |
450
+ | Selectors | `selectAllTracks`, `selectRenderableTracks`, `selectTrack(id)`, `selectTrackCount`, `selectStatus`, `selectIsDegraded`, `selectLastUpdateAt`, `selectError` |
451
+
452
+ ### `useTrackHistory` — smooth map animation
453
+
454
+ Maintains per-track position interpolation (tweening), dead-reckoning between updates, and bounded trail history, emitting animation frames outside React render:
455
+
456
+ ```ts
457
+ const { processUpdate, processRemove } = useTrackHistory({
458
+ maxHistoryPoints: 500, // trail length cap per track
459
+ minDeltaDeg: 0.0001, // ignore sub-threshold jitter
460
+ maxJumpDeg: 5.0, // discard implausible teleports
461
+ onFrame: ({ currentPositions, history, isMoving }) => drawMarkers(...),
462
+ });
463
+ ```
464
+
465
+ Update gaps longer than 30 s (lost coverage, socket offline) restart the track's trail instead of drawing a straight line across the map. Speeds beyond 1 200 kt (misdecodes, or position deltas divided by a compressed replay clock) are rejected and never drive dead reckoning.
466
+
467
+ ## Frontend recipes
468
+
469
+ ### Live map, end to end
470
+
471
+ The proven wiring (this is what the reference dashboard does):
472
+
473
+ ```tsx
474
+ // 1. Subscription → store, batched (never setState per message)
475
+ const pending = new Map<string, CurrentTrack>();
476
+ const removed = new Set<string>();
477
+ client.tracks.subscribe(opts, {
478
+ onSnapshot: (ts) => useTrackStore.getState().applySnapshot(ts),
479
+ onTrackUpdate: (t) => { pending.set(t.trackId, t); removed.delete(t.trackId); },
480
+ onTrackRemoved: (ev) => { pending.delete(ev.trackId); removed.add(ev.trackId); },
481
+ });
482
+ setInterval(() => {
483
+ useTrackStore.getState().applyBatch([...pending.values()], [...removed]);
484
+ pending.clear(); removed.clear();
485
+ }, 250);
486
+
487
+ // 2. Ghost cleanup — ids churn on feed/replay restarts
488
+ setInterval(() => { useTrackStore.getState().pruneStale(60_000); }, 10_000);
489
+
490
+ // 3. Store → animation engine → map markers (imperative, no React re-render)
491
+ const { processUpdate, processRemove } = useTrackHistory({ onFrame: drawMarkers });
492
+ useTrackStore.subscribe((state, prev) => {
493
+ for (const [id, t] of state.tracks) if (prev.tracks.get(id) !== t) processUpdate(t);
494
+ for (const id of prev.tracks.keys()) if (!state.tracks.has(id)) processRemove(id);
495
+ });
496
+ ```
497
+
498
+ ### Replay in the same map
499
+
500
+ Feed `replay.stream` callbacks into the **same** store/animation path and pause the live subscription while a session is active — replay then looks exactly like live. Batch `onTrackUpdate` into ~250 ms flushes the same way.
501
+
502
+ ### Assigned-route overlay
503
+
504
+ ```ts
505
+ const { routes } = await client.routes.list({ airportId });
506
+ const { assignments } = await client.routes.assignments();
507
+ const route = routes.find(r => r.routeId === assignments.find(a => a.trackId === selectedId)?.routeId);
508
+ // draw route.points ([lat, lon][]) as a dashed LineString: points.map(([lat, lon]) => [lon, lat])
509
+ ```
510
+
511
+ ## Performance notes (important for dashboards)
512
+
513
+ - **Don't call a React `setState` per WS message.** At realistic rates (hundreds of tracks × several Hz) that renders your page thousands of times per second. Buffer updates and flush on an interval (~250 ms to the store for the map, ~1 s to React state for panels), using `applyBatch`.
514
+ - **Request only the rate you need** — `maxUpdateHz: 5` with client-side interpolation looks identical to 20 Hz on a map and cuts WS traffic 4×.
515
+ - Drive map markers **imperatively** from `useTrackStore.subscribe(...)` + `useTrackHistory`, not through React props.
516
+
517
+ ## Wire encoding
518
+
519
+ `json` (default) or `msgpack` (binary, lower bandwidth) — set once in `AeroSwarmOptions.encoding` or per-subscription.
520
+
521
+ ## Environment variables (Vite frontends)
522
+
523
+ | Variable | Description |
524
+ |----------|-------------|
525
+ | `VITE_AEROSWARM_API_URL` | REST/tRPC/GraphQL base URL |
526
+ | `VITE_AEROSWARM_WS_URL` | WebSocket base URL |
527
+ | `VITE_AEROSWARM_API_KEY` | Viewer token |
528
+
529
+ These are baked into the bundle at build time (Vite), not read at runtime.
530
+
531
+ ## Building & testing
532
+
533
+ ```bash
534
+ pnpm run build # tsup → dist/ (index + /sdk + /react)
535
+ pnpm run typecheck
536
+ pnpm test
537
+ ```