@aeroswarm/client 0.1.15 → 0.1.17
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 +631 -617
- package/dist/{index-CeayQnTp.d.ts → index-DH3DH1Nj.d.ts} +9 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +7 -1
- package/dist/react/index.js.map +1 -1
- package/dist/sdk/index.d.ts +1 -1
- package/dist/sdk/index.js +7 -1
- package/dist/sdk/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,617 +1,631 @@
|
|
|
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
|
-
| `tracks.snapshot(input?)` | One-shot current state via tRPC (`airportId`, `bbox`, `source`, `minConfidence`, `limit`, `offset`) |
|
|
69
|
-
| `tracks.history(input)` | Persisted position history for one track (`trackId`, `airportId?`, `limit?`) — federated airports are served by their own backend |
|
|
70
|
-
| `airports.*` | Airport registry: `list/create/update/delete` + `surface`/`setSurface` (runway/taxiway/apron GeoJSON for the map) |
|
|
71
|
-
| `sources.*` | Register/list (optionally by `airportId`)/delete data sources (returns per-source auth tokens) |
|
|
72
|
-
| `alerts.*` | Alert history + live alert subscription |
|
|
73
|
-
| `rules.*` | Detection-rule CRUD — **global or per-airport** (see example below) |
|
|
74
|
-
| `routes.*` | Route conformance: pre-defined ground routes + live track clearances (deviation alerts) |
|
|
75
|
-
| `analytics.movements()` | Takeoffs/landings per hour + peak hour, per airport |
|
|
76
|
-
| `webhooks.*` | Webhook CRUD |
|
|
77
|
-
| `replay.days(input?)` | Data-availability calendar — which days have recorded tracks (see example below) |
|
|
78
|
-
| `replay.range/seek/window` | Historical replay metadata: range summary, point-in-time seek, time-bucketed window for scrubbing |
|
|
79
|
-
| `replay.stream(opts, cb)` | **Server-paced WS playback** — historical tracks streamed like live (see example below) |
|
|
80
|
-
| `system.*` | `stats`, `infraHealth`, `getConfig`/`setConfig`, `clearTable`, `ingestHistory` |
|
|
81
|
-
| `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 |
|
|
82
|
-
| `cache` | A `TrackCache` instance (see below) |
|
|
83
|
-
| `getStatus()` / `getLastUpdateAt()` / `getError()` / `getSourceStatus()` | Connection introspection |
|
|
84
|
-
| `disconnect()` | Tear down the WS connection |
|
|
85
|
-
|
|
86
|
-
### Label category / color
|
|
87
|
-
|
|
88
|
-
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:
|
|
89
|
-
|
|
90
|
-
| Category | Color | When |
|
|
91
|
-
|---|---|---|
|
|
92
|
-
| `ARRIVAL` | `#FACC15` yellow | `flightPlan.phase === "ARRIVAL"` |
|
|
93
|
-
| `DEPARTURE` | `#22C55E` green | `flightPlan.phase === "DEPARTURE"` |
|
|
94
|
-
| `GROUND_VEHICLE` | `#3B82F6` blue | `targetType === "vehicle"` |
|
|
95
|
-
| `LOST` | `#FFFFFF` white | `status === "lost"` (takes priority) |
|
|
96
|
-
| `UNKNOWN` | `#FFFFFF` white | no flight plan / overflight |
|
|
97
|
-
|
|
98
|
-
Also exported: `LABEL_COLORS`, `getLabelCategory(track)`, `withLabel(track)`.
|
|
99
|
-
|
|
100
|
-
### Response data (live WS)
|
|
101
|
-
|
|
102
|
-
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:
|
|
103
|
-
|
|
104
|
-
| Callback | Payload | Notes |
|
|
105
|
-
|---|---|---|
|
|
106
|
-
| `onSnapshot` | `LabeledTrack[]` | Full authoritative state; sent on subscribe and on every reconnect |
|
|
107
|
-
| `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`) |
|
|
108
|
-
| `onTrackRemoved` | `{ trackId, reason }` | Server dropped the track |
|
|
109
|
-
| `onSourceStatus` | `{ sourceId, status }` | `healthy` \| `degraded` \| `offline` |
|
|
110
|
-
| `onStatusChange` | `AeroConnectionStatus` | `idle` \| `connecting` \| `connected` \| `reconnecting` \| `degraded` \| `offline` |
|
|
111
|
-
| `onDegraded` | `reason: string` | e.g. `heartbeat_timeout` |
|
|
112
|
-
| `onError` | `{ code, message, recoverable }` | |
|
|
113
|
-
|
|
114
|
-
`LabeledTrack` = `CurrentTrack` + label fields:
|
|
115
|
-
|
|
116
|
-
```jsonc
|
|
117
|
-
{
|
|
118
|
-
"trackId": "fused-4b1a2c",
|
|
119
|
-
"source": "FUSED", // ADSB | ASTERIX_CAT010/011/020/021/048/062 | FUSED
|
|
120
|
-
"icao24": "8a0123", // string | null
|
|
121
|
-
"callsign": "GIA123", // string | null
|
|
122
|
-
"squawk": "1200", // string | null
|
|
123
|
-
"targetType": "aircraft", // optional: aircraft | vehicle | unknown
|
|
124
|
-
"vehicleFleet": 3, // optional, only for vehicles
|
|
125
|
-
"airportId": "WARR", // optional ICAO scope
|
|
126
|
-
"dataSource": { "sac": 32, "sic": 136 }, // optional: the sensor behind the position shown (ASTERIX feeds)
|
|
127
|
-
"lat": -7.3797, "lon": 112.7868,// number | null
|
|
128
|
-
"altitudeFt": 0,
|
|
129
|
-
"groundSpeedKt": 12,
|
|
130
|
-
"headingDeg": 90,
|
|
131
|
-
"verticalRateFpm": 0,
|
|
132
|
-
"confidence": 0.98, // 0..1
|
|
133
|
-
"lastSeenAt": 1761640000000, // epoch ms
|
|
134
|
-
"status": "active", // active | stale | lost
|
|
135
|
-
"flightPlan": { // optional: only when correlated with an AFTN plan
|
|
136
|
-
"id": "GIA123|WARR|WIII|20261028",
|
|
137
|
-
"adep": "WARR", "ades": "WIII",
|
|
138
|
-
"status": "filed", // filed | delayed | departed | arrived | cancelled
|
|
139
|
-
"phase": "DEPARTURE" // ARRIVAL | DEPARTURE | OVERFLIGHT | UNKNOWN (relative to airportId)
|
|
140
|
-
},
|
|
141
|
-
// added by the client:
|
|
142
|
-
"labelCategory": "DEPARTURE", // ARRIVAL | DEPARTURE | GROUND_VEHICLE | LOST | UNKNOWN
|
|
143
|
-
"labelColor": "#22C55E"
|
|
144
|
-
}
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
`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.
|
|
148
|
-
|
|
149
|
-
`dataSource` is the ASTERIX System Area / System Identification Code (SAC/SIC) of the sensor that reported the position shown (for a merged track, the sensor that is currently preferred). `formatDataSource(track)` gives the display string `"ADSB 32/136"` (`sourceLabel(track.source)` gives just the kind: `ADSB`, `SURFACE` for CAT010/011, `MLAT`, `RADAR`, `SYSTEM`, `FUSED`); without a SAC/SIC it is the kind alone.
|
|
150
|
-
|
|
151
|
-
## Per-domain usage examples
|
|
152
|
-
|
|
153
|
-
Everything below assumes `const client = new AeroSwarm({ endpoint, token })`.
|
|
154
|
-
|
|
155
|
-
### Airports (multi-airport tenancy)
|
|
156
|
-
|
|
157
|
-
```ts
|
|
158
|
-
const { airports } = await client.airports.list();
|
|
159
|
-
// [{ id: "WARR", name: "Juanda International Airport", city, lat, lon, bbox, timezone }, …]
|
|
160
|
-
|
|
161
|
-
await client.airports.create({
|
|
162
|
-
id: "WARR", name: "Juanda International Airport",
|
|
163
|
-
lat: -7.3798, lon: 112.7869, bbox: [112.7, -7.42, 112.86, -7.33],
|
|
164
|
-
});
|
|
165
|
-
await client.airports.update({ id: "WARR", city: "Surabaya" });
|
|
166
|
-
await client.airports.delete({ id: "WARR" });
|
|
167
|
-
|
|
168
|
-
// Runway/taxiway/apron GeoJSON for the map's surface layer
|
|
169
|
-
const { surface } = await client.airports.surface({ id: "WARR" });
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
Most other calls accept an `airportId` (ICAO code) to scope to one airport; omitting it means "all airports".
|
|
173
|
-
|
|
174
|
-
### Rules — global vs per-airport
|
|
175
|
-
|
|
176
|
-
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.
|
|
177
|
-
|
|
178
|
-
```ts
|
|
179
|
-
// GLOBAL geofence — applies everywhere
|
|
180
|
-
const { ruleId } = await client.rules.create({
|
|
181
|
-
name: "Restricted area — national",
|
|
182
|
-
type: "geofence", // geofence | altitude | speed | squawk | custom
|
|
183
|
-
severity: "high", // low | medium | high | critical
|
|
184
|
-
conditions: { kind: "restricted", polygon: [[lon, lat], …] },
|
|
185
|
-
actions: [],
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
// Airport-scoped geofence — WARR tracks only
|
|
189
|
-
await client.rules.create({
|
|
190
|
-
name: "South Apron",
|
|
191
|
-
type: "geofence",
|
|
192
|
-
severity: "medium",
|
|
193
|
-
conditions: { kind: "apron", polygon: [[lon, lat], …] },
|
|
194
|
-
actions: [],
|
|
195
|
-
airportId: "WARR",
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
await client.rules.list(); // every rule
|
|
199
|
-
await client.rules.list({ airportId: "WARR" }); // one airport's rules
|
|
200
|
-
await client.rules.update({ id: ruleId, enabled: false });
|
|
201
|
-
await client.rules.delete({ id: ruleId });
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
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.
|
|
205
|
-
|
|
206
|
-
### Routes — assigned-route conformance (deviation alerts)
|
|
207
|
-
|
|
208
|
-
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.
|
|
209
|
-
|
|
210
|
-
```ts
|
|
211
|
-
// Route definitions
|
|
212
|
-
const { routes } = await client.routes.list({ airportId: "WARR" });
|
|
213
|
-
const { id } = await client.routes.create({
|
|
214
|
-
airportId: "WARR",
|
|
215
|
-
name: "Stand A1 → RWY 10",
|
|
216
|
-
kind: "taxi", // taxi | departure | arrival
|
|
217
|
-
points: [[-7.3822, 112.7794], [-7.3820, 112.7900], …], // [lat, lon]
|
|
218
|
-
maxDeviationM: 50,
|
|
219
|
-
});
|
|
220
|
-
await client.routes.delete({ id });
|
|
221
|
-
|
|
222
|
-
// Clearances (live track → route). Stored in Redis; the safety engine
|
|
223
|
-
// refreshes them every 5 s.
|
|
224
|
-
await client.routes.assign({ trackId: "trk_abc", routeId: routes[0].routeId });
|
|
225
|
-
await client.routes.unassign({ trackId: "trk_abc" });
|
|
226
|
-
const { assignments } = await client.routes.assignments();
|
|
227
|
-
// [{ trackId, routeId, assignedAt }, …]
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
The resulting `deviation` alerts arrive through the normal alert stream with `details: { routeId, route, deviationM, maxDeviationM, routeProgress }`.
|
|
231
|
-
|
|
232
|
-
### Alerts
|
|
233
|
-
|
|
234
|
-
```ts
|
|
235
|
-
// History (tRPC)
|
|
236
|
-
const { alerts } = await client.alerts.list({ severity: "high", limit: 50 });
|
|
237
|
-
|
|
238
|
-
// Live subscription (WS)
|
|
239
|
-
const stop = client.alerts.subscribe((alert) => {
|
|
240
|
-
// { alertId, type, severity, trackId, triggeredAt, reason, details }
|
|
241
|
-
// type: emergency | area_penetration | runway_incursion |
|
|
242
|
-
// taxiway_incursion | deviation | stca | …
|
|
243
|
-
showToast(alert);
|
|
244
|
-
});
|
|
245
|
-
stop();
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
### Movement analytics (takeoffs/landings per hour)
|
|
249
|
-
|
|
250
|
-
```ts
|
|
251
|
-
const stats = await client.analytics.movements({ airportId: "WARR", day: "2026-07-14" });
|
|
252
|
-
// {
|
|
253
|
-
// day: "2026-07-14",
|
|
254
|
-
// hours: [{ hour: "2026-07-14T07:00:00.000Z", takeoffs: 21, landings: 17 }, …],
|
|
255
|
-
// totals: { takeoffs: 132, landings: 128 },
|
|
256
|
-
// peak: { hour: "2026-07-14T07:00:00.000Z", count: 38 }, // jam puncak + jumlah
|
|
257
|
-
// }
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
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.
|
|
261
|
-
|
|
262
|
-
### Replay — server-paced WS playback
|
|
263
|
-
|
|
264
|
-
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.
|
|
265
|
-
|
|
266
|
-
```ts
|
|
267
|
-
const handle = await client.replay.stream(
|
|
268
|
-
{
|
|
269
|
-
from: "2026-07-14T02:00:00Z",
|
|
270
|
-
to: "2026-07-14T03:00:00Z",
|
|
271
|
-
speed: 2, // 2× real time (server-paced, drift-free)
|
|
272
|
-
airportId: "WARR", // federated airport → its own backend serves this
|
|
273
|
-
// sessionId: "…" // resume a previous session (kept 1 h in Redis)
|
|
274
|
-
},
|
|
275
|
-
{
|
|
276
|
-
onSnapshot: (tracks) => seedMap(tracks), // state AT `from`
|
|
277
|
-
onTrackUpdate: (t) => upsertMarker(t),
|
|
278
|
-
onTrackRemoved: (ev) => removeMarker(ev.trackId),
|
|
279
|
-
onProgress: (p) => setPlayhead(p.at, p.pct), // ISO time + 0-100 %
|
|
280
|
-
onDone: () => setFinished(),
|
|
281
|
-
onError: (err) => showError(err.message),
|
|
282
|
-
},
|
|
283
|
-
);
|
|
284
|
-
// `stream()` is async — it first resolves whether `airportId` is a
|
|
285
|
-
// federated airport (see "Federated history" below), which needs a lookup.
|
|
286
|
-
|
|
287
|
-
handle.pause();
|
|
288
|
-
handle.resume();
|
|
289
|
-
handle.seek(new Date("2026-07-14T02:45:00Z")); // server restarts the paced stream there
|
|
290
|
-
handle.stop(); // ends the session, closes the socket
|
|
291
|
-
handle.sessionId; // save to resume after a page reload
|
|
292
|
-
```
|
|
293
|
-
|
|
294
|
-
For range pickers, the metadata endpoints are still available:
|
|
295
|
-
|
|
296
|
-
```ts
|
|
297
|
-
// Which days actually HAVE data (populate/validate the date picker)
|
|
298
|
-
const { days } = await client.replay.days({ airportId: "WARR", lookbackDays: 60 });
|
|
299
|
-
// [{ day: "2026-07-14", eventCount: 51230, trackCount: 42,
|
|
300
|
-
// firstAt: "2026-07-14T01:58:03Z", lastAt: "2026-07-14T09:12:44Z" }, …]
|
|
301
|
-
// A date absent from the list has no recorded data — don't offer it.
|
|
302
|
-
|
|
303
|
-
const range = await client.replay.range({ from, to });
|
|
304
|
-
// { earliest, latest, eventCount, trackCount }
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
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.
|
|
308
|
-
|
|
309
|
-
### Federated history
|
|
310
|
-
|
|
311
|
-
An airport can serve its **own** replay/history instead of AeroSwarm's — its own backend, its
|
|
312
|
-
own database, reached directly (see `docs/development/federation/`). `replay.stream`,
|
|
313
|
-
`replay.days`, `replay.range`, `tracks.history`, and `alerts.list` all resolve this
|
|
314
|
-
automatically, in priority order:
|
|
315
|
-
|
|
316
|
-
1. **Explicit override** — pass `endpoint` and the registry is skipped entirely:
|
|
317
|
-
```ts
|
|
318
|
-
await client.replay.days({ endpoint: "http://192.168.2.225:4000/aero" });
|
|
319
|
-
```
|
|
320
|
-
2. **Registry** — if the airport's `history_endpoint` is set (`client.airports.list()` /
|
|
321
|
-
dashboard's Airports tab), calls for that `airportId` go there instead:
|
|
322
|
-
```ts
|
|
323
|
-
const handle = await client.replay.stream({ from, to, airportId: "WARR" }, callbacks);
|
|
324
|
-
// WARR has history_endpoint set → connects to that backend's WS replay, transparently
|
|
325
|
-
```
|
|
326
|
-
3. **AeroSwarm itself** — the default, unchanged behavior, for any airport with no endpoint set.
|
|
327
|
-
|
|
328
|
-
A lookup failure (e.g. `airports.list()` errors) always falls back to AeroSwarm's own endpoint
|
|
329
|
-
rather than breaking replay — federation degrades safely.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
`
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
await client.sources.
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
|
446
|
-
|
|
447
|
-
| `
|
|
448
|
-
| `
|
|
449
|
-
| `
|
|
450
|
-
| `
|
|
451
|
-
| `
|
|
452
|
-
| `
|
|
453
|
-
|
|
|
454
|
-
|
|
455
|
-
###
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
```ts
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
```
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
useTrackStore.
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const {
|
|
586
|
-
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
##
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
+
| `tracks.snapshot(input?)` | One-shot current state via tRPC (`airportId`, `bbox`, `source`, `minConfidence`, `limit`, `offset`) |
|
|
69
|
+
| `tracks.history(input)` | Persisted position history for one track (`trackId`, `airportId?`, `limit?`) — federated airports are served by their own backend |
|
|
70
|
+
| `airports.*` | Airport registry: `list/create/update/delete` + `surface`/`setSurface` (runway/taxiway/apron GeoJSON for the map) |
|
|
71
|
+
| `sources.*` | Register/list (optionally by `airportId`)/delete data sources (returns per-source auth tokens) |
|
|
72
|
+
| `alerts.*` | Alert history + live alert subscription |
|
|
73
|
+
| `rules.*` | Detection-rule CRUD — **global or per-airport** (see example below) |
|
|
74
|
+
| `routes.*` | Route conformance: pre-defined ground routes + live track clearances (deviation alerts) |
|
|
75
|
+
| `analytics.movements()` | Takeoffs/landings per hour + peak hour, per airport |
|
|
76
|
+
| `webhooks.*` | Webhook CRUD |
|
|
77
|
+
| `replay.days(input?)` | Data-availability calendar — which days have recorded tracks (see example below) |
|
|
78
|
+
| `replay.range/seek/window` | Historical replay metadata: range summary, point-in-time seek, time-bucketed window for scrubbing |
|
|
79
|
+
| `replay.stream(opts, cb)` | **Server-paced WS playback** — historical tracks streamed like live (see example below) |
|
|
80
|
+
| `system.*` | `stats`, `infraHealth`, `getConfig`/`setConfig`, `clearTable`, `ingestHistory` |
|
|
81
|
+
| `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 |
|
|
82
|
+
| `cache` | A `TrackCache` instance (see below) |
|
|
83
|
+
| `getStatus()` / `getLastUpdateAt()` / `getError()` / `getSourceStatus()` | Connection introspection |
|
|
84
|
+
| `disconnect()` | Tear down the WS connection |
|
|
85
|
+
|
|
86
|
+
### Label category / color
|
|
87
|
+
|
|
88
|
+
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:
|
|
89
|
+
|
|
90
|
+
| Category | Color | When |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `ARRIVAL` | `#FACC15` yellow | `flightPlan.phase === "ARRIVAL"` |
|
|
93
|
+
| `DEPARTURE` | `#22C55E` green | `flightPlan.phase === "DEPARTURE"` |
|
|
94
|
+
| `GROUND_VEHICLE` | `#3B82F6` blue | `targetType === "vehicle"` |
|
|
95
|
+
| `LOST` | `#FFFFFF` white | `status === "lost"` (takes priority) |
|
|
96
|
+
| `UNKNOWN` | `#FFFFFF` white | no flight plan / overflight |
|
|
97
|
+
|
|
98
|
+
Also exported: `LABEL_COLORS`, `getLabelCategory(track)`, `withLabel(track)`.
|
|
99
|
+
|
|
100
|
+
### Response data (live WS)
|
|
101
|
+
|
|
102
|
+
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:
|
|
103
|
+
|
|
104
|
+
| Callback | Payload | Notes |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `onSnapshot` | `LabeledTrack[]` | Full authoritative state; sent on subscribe and on every reconnect |
|
|
107
|
+
| `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`) |
|
|
108
|
+
| `onTrackRemoved` | `{ trackId, reason }` | Server dropped the track |
|
|
109
|
+
| `onSourceStatus` | `{ sourceId, status }` | `healthy` \| `degraded` \| `offline` |
|
|
110
|
+
| `onStatusChange` | `AeroConnectionStatus` | `idle` \| `connecting` \| `connected` \| `reconnecting` \| `degraded` \| `offline` |
|
|
111
|
+
| `onDegraded` | `reason: string` | e.g. `heartbeat_timeout` |
|
|
112
|
+
| `onError` | `{ code, message, recoverable }` | |
|
|
113
|
+
|
|
114
|
+
`LabeledTrack` = `CurrentTrack` + label fields:
|
|
115
|
+
|
|
116
|
+
```jsonc
|
|
117
|
+
{
|
|
118
|
+
"trackId": "fused-4b1a2c",
|
|
119
|
+
"source": "FUSED", // ADSB | ASTERIX_CAT010/011/020/021/048/062 | FUSED
|
|
120
|
+
"icao24": "8a0123", // string | null
|
|
121
|
+
"callsign": "GIA123", // string | null
|
|
122
|
+
"squawk": "1200", // string | null
|
|
123
|
+
"targetType": "aircraft", // optional: aircraft | vehicle | unknown
|
|
124
|
+
"vehicleFleet": 3, // optional, only for vehicles
|
|
125
|
+
"airportId": "WARR", // optional ICAO scope
|
|
126
|
+
"dataSource": { "sac": 32, "sic": 136 }, // optional: the sensor behind the position shown (ASTERIX feeds)
|
|
127
|
+
"lat": -7.3797, "lon": 112.7868,// number | null
|
|
128
|
+
"altitudeFt": 0,
|
|
129
|
+
"groundSpeedKt": 12,
|
|
130
|
+
"headingDeg": 90,
|
|
131
|
+
"verticalRateFpm": 0,
|
|
132
|
+
"confidence": 0.98, // 0..1
|
|
133
|
+
"lastSeenAt": 1761640000000, // epoch ms
|
|
134
|
+
"status": "active", // active | stale | lost
|
|
135
|
+
"flightPlan": { // optional: only when correlated with an AFTN plan
|
|
136
|
+
"id": "GIA123|WARR|WIII|20261028",
|
|
137
|
+
"adep": "WARR", "ades": "WIII",
|
|
138
|
+
"status": "filed", // filed | delayed | departed | arrived | cancelled
|
|
139
|
+
"phase": "DEPARTURE" // ARRIVAL | DEPARTURE | OVERFLIGHT | UNKNOWN (relative to airportId)
|
|
140
|
+
},
|
|
141
|
+
// added by the client:
|
|
142
|
+
"labelCategory": "DEPARTURE", // ARRIVAL | DEPARTURE | GROUND_VEHICLE | LOST | UNKNOWN
|
|
143
|
+
"labelColor": "#22C55E"
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`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.
|
|
148
|
+
|
|
149
|
+
`dataSource` is the ASTERIX System Area / System Identification Code (SAC/SIC) of the sensor that reported the position shown (for a merged track, the sensor that is currently preferred). `formatDataSource(track)` gives the display string `"ADSB 32/136"` (`sourceLabel(track.source)` gives just the kind: `ADSB`, `SURFACE` for CAT010/011, `MLAT`, `RADAR`, `SYSTEM`, `FUSED`); without a SAC/SIC it is the kind alone.
|
|
150
|
+
|
|
151
|
+
## Per-domain usage examples
|
|
152
|
+
|
|
153
|
+
Everything below assumes `const client = new AeroSwarm({ endpoint, token })`.
|
|
154
|
+
|
|
155
|
+
### Airports (multi-airport tenancy)
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const { airports } = await client.airports.list();
|
|
159
|
+
// [{ id: "WARR", name: "Juanda International Airport", city, lat, lon, bbox, timezone }, …]
|
|
160
|
+
|
|
161
|
+
await client.airports.create({
|
|
162
|
+
id: "WARR", name: "Juanda International Airport",
|
|
163
|
+
lat: -7.3798, lon: 112.7869, bbox: [112.7, -7.42, 112.86, -7.33],
|
|
164
|
+
});
|
|
165
|
+
await client.airports.update({ id: "WARR", city: "Surabaya" });
|
|
166
|
+
await client.airports.delete({ id: "WARR" });
|
|
167
|
+
|
|
168
|
+
// Runway/taxiway/apron GeoJSON for the map's surface layer
|
|
169
|
+
const { surface } = await client.airports.surface({ id: "WARR" });
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Most other calls accept an `airportId` (ICAO code) to scope to one airport; omitting it means "all airports".
|
|
173
|
+
|
|
174
|
+
### Rules — global vs per-airport
|
|
175
|
+
|
|
176
|
+
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.
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
// GLOBAL geofence — applies everywhere
|
|
180
|
+
const { ruleId } = await client.rules.create({
|
|
181
|
+
name: "Restricted area — national",
|
|
182
|
+
type: "geofence", // geofence | altitude | speed | squawk | custom
|
|
183
|
+
severity: "high", // low | medium | high | critical
|
|
184
|
+
conditions: { kind: "restricted", polygon: [[lon, lat], …] },
|
|
185
|
+
actions: [],
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Airport-scoped geofence — WARR tracks only
|
|
189
|
+
await client.rules.create({
|
|
190
|
+
name: "South Apron",
|
|
191
|
+
type: "geofence",
|
|
192
|
+
severity: "medium",
|
|
193
|
+
conditions: { kind: "apron", polygon: [[lon, lat], …] },
|
|
194
|
+
actions: [],
|
|
195
|
+
airportId: "WARR",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await client.rules.list(); // every rule
|
|
199
|
+
await client.rules.list({ airportId: "WARR" }); // one airport's rules
|
|
200
|
+
await client.rules.update({ id: ruleId, enabled: false });
|
|
201
|
+
await client.rules.delete({ id: ruleId });
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
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.
|
|
205
|
+
|
|
206
|
+
### Routes — assigned-route conformance (deviation alerts)
|
|
207
|
+
|
|
208
|
+
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.
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
// Route definitions
|
|
212
|
+
const { routes } = await client.routes.list({ airportId: "WARR" });
|
|
213
|
+
const { id } = await client.routes.create({
|
|
214
|
+
airportId: "WARR",
|
|
215
|
+
name: "Stand A1 → RWY 10",
|
|
216
|
+
kind: "taxi", // taxi | departure | arrival
|
|
217
|
+
points: [[-7.3822, 112.7794], [-7.3820, 112.7900], …], // [lat, lon]
|
|
218
|
+
maxDeviationM: 50,
|
|
219
|
+
});
|
|
220
|
+
await client.routes.delete({ id });
|
|
221
|
+
|
|
222
|
+
// Clearances (live track → route). Stored in Redis; the safety engine
|
|
223
|
+
// refreshes them every 5 s.
|
|
224
|
+
await client.routes.assign({ trackId: "trk_abc", routeId: routes[0].routeId });
|
|
225
|
+
await client.routes.unassign({ trackId: "trk_abc" });
|
|
226
|
+
const { assignments } = await client.routes.assignments();
|
|
227
|
+
// [{ trackId, routeId, assignedAt }, …]
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The resulting `deviation` alerts arrive through the normal alert stream with `details: { routeId, route, deviationM, maxDeviationM, routeProgress }`.
|
|
231
|
+
|
|
232
|
+
### Alerts
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
// History (tRPC)
|
|
236
|
+
const { alerts } = await client.alerts.list({ severity: "high", limit: 50 });
|
|
237
|
+
|
|
238
|
+
// Live subscription (WS)
|
|
239
|
+
const stop = client.alerts.subscribe((alert) => {
|
|
240
|
+
// { alertId, type, severity, trackId, triggeredAt, reason, details }
|
|
241
|
+
// type: emergency | area_penetration | runway_incursion |
|
|
242
|
+
// taxiway_incursion | deviation | stca | …
|
|
243
|
+
showToast(alert);
|
|
244
|
+
});
|
|
245
|
+
stop();
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Movement analytics (takeoffs/landings per hour)
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
const stats = await client.analytics.movements({ airportId: "WARR", day: "2026-07-14" });
|
|
252
|
+
// {
|
|
253
|
+
// day: "2026-07-14",
|
|
254
|
+
// hours: [{ hour: "2026-07-14T07:00:00.000Z", takeoffs: 21, landings: 17 }, …],
|
|
255
|
+
// totals: { takeoffs: 132, landings: 128 },
|
|
256
|
+
// peak: { hour: "2026-07-14T07:00:00.000Z", count: 38 }, // jam puncak + jumlah
|
|
257
|
+
// }
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
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.
|
|
261
|
+
|
|
262
|
+
### Replay — server-paced WS playback
|
|
263
|
+
|
|
264
|
+
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.
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
const handle = await client.replay.stream(
|
|
268
|
+
{
|
|
269
|
+
from: "2026-07-14T02:00:00Z",
|
|
270
|
+
to: "2026-07-14T03:00:00Z",
|
|
271
|
+
speed: 2, // 2× real time (server-paced, drift-free)
|
|
272
|
+
airportId: "WARR", // federated airport → its own backend serves this
|
|
273
|
+
// sessionId: "…" // resume a previous session (kept 1 h in Redis)
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
onSnapshot: (tracks) => seedMap(tracks), // state AT `from`
|
|
277
|
+
onTrackUpdate: (t) => upsertMarker(t),
|
|
278
|
+
onTrackRemoved: (ev) => removeMarker(ev.trackId),
|
|
279
|
+
onProgress: (p) => setPlayhead(p.at, p.pct), // ISO time + 0-100 %
|
|
280
|
+
onDone: () => setFinished(),
|
|
281
|
+
onError: (err) => showError(err.message),
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
// `stream()` is async — it first resolves whether `airportId` is a
|
|
285
|
+
// federated airport (see "Federated history" below), which needs a lookup.
|
|
286
|
+
|
|
287
|
+
handle.pause();
|
|
288
|
+
handle.resume();
|
|
289
|
+
handle.seek(new Date("2026-07-14T02:45:00Z")); // server restarts the paced stream there
|
|
290
|
+
handle.stop(); // ends the session, closes the socket
|
|
291
|
+
handle.sessionId; // save to resume after a page reload
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
For range pickers, the metadata endpoints are still available:
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
// Which days actually HAVE data (populate/validate the date picker)
|
|
298
|
+
const { days } = await client.replay.days({ airportId: "WARR", lookbackDays: 60 });
|
|
299
|
+
// [{ day: "2026-07-14", eventCount: 51230, trackCount: 42,
|
|
300
|
+
// firstAt: "2026-07-14T01:58:03Z", lastAt: "2026-07-14T09:12:44Z" }, …]
|
|
301
|
+
// A date absent from the list has no recorded data — don't offer it.
|
|
302
|
+
|
|
303
|
+
const range = await client.replay.range({ from, to });
|
|
304
|
+
// { earliest, latest, eventCount, trackCount }
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
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.
|
|
308
|
+
|
|
309
|
+
### Federated history
|
|
310
|
+
|
|
311
|
+
An airport can serve its **own** replay/history instead of AeroSwarm's — its own backend, its
|
|
312
|
+
own database, reached directly (see `docs/development/federation/`). `replay.stream`,
|
|
313
|
+
`replay.days`, `replay.range`, `tracks.history`, and `alerts.list` all resolve this
|
|
314
|
+
automatically, in priority order:
|
|
315
|
+
|
|
316
|
+
1. **Explicit override** — pass `endpoint` and the registry is skipped entirely:
|
|
317
|
+
```ts
|
|
318
|
+
await client.replay.days({ endpoint: "http://192.168.2.225:4000/aero" });
|
|
319
|
+
```
|
|
320
|
+
2. **Registry** — if the airport's `history_endpoint` is set (`client.airports.list()` /
|
|
321
|
+
dashboard's Airports tab), calls for that `airportId` go there instead:
|
|
322
|
+
```ts
|
|
323
|
+
const handle = await client.replay.stream({ from, to, airportId: "WARR" }, callbacks);
|
|
324
|
+
// WARR has history_endpoint set → connects to that backend's WS replay, transparently
|
|
325
|
+
```
|
|
326
|
+
3. **AeroSwarm itself** — the default, unchanged behavior, for any airport with no endpoint set.
|
|
327
|
+
|
|
328
|
+
A lookup failure (e.g. `airports.list()` errors) always falls back to AeroSwarm's own endpoint
|
|
329
|
+
rather than breaking replay — federation degrades safely.
|
|
330
|
+
|
|
331
|
+
**Credential for the airport's backend.** By default the airport's backend gets the same `token`
|
|
332
|
+
as AeroSwarm. When it expects something else — typically the user's login JWT, checked by the
|
|
333
|
+
airport's gateway — pass `backendToken`; a function is read on **every** request, so a refreshed
|
|
334
|
+
login is picked up without rebuilding the client. It falls back to `token` when it returns nothing.
|
|
335
|
+
It applies only to calls that go to an airport's backend (`tracks.history`, `tracks.trail`,
|
|
336
|
+
`replay.days`/`range`), never to AeroSwarm's own endpoint or the live WebSocket.
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
const client = new AeroSwarm({
|
|
340
|
+
endpoint, token,
|
|
341
|
+
backendToken: () => useAuthStore.getState().accessToken,
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`replay.window` and `replay.seek` are **not** part of the federation contract — they throw for a
|
|
346
|
+
federated airport; use `replay.stream` (which covers the same "jump to a point in time" need via
|
|
347
|
+
`seek()` on the returned handle) instead.
|
|
348
|
+
|
|
349
|
+
Building the receiving side (an airport's own backend)? See
|
|
350
|
+
[`@aeroswarm/replay-server`](../../server/replay-server/README.md) — mounts this exact protocol
|
|
351
|
+
on top of your own database with a few lines of code.
|
|
352
|
+
|
|
353
|
+
**Multi-airport.** Every replay call accepts `airportId` as *optional*. For a **federated**
|
|
354
|
+
airport (one with a `historyEndpoint` — see the next section) it selects which airport backend
|
|
355
|
+
to connect to; always pass it for those. Against AeroSwarm's own endpoint (dev/single-node
|
|
356
|
+
mode) it's a row filter on the local database — omitting it returns all local rows. There is no
|
|
357
|
+
cross-backend "all airports" merged replay: each federated airport's history lives behind its
|
|
358
|
+
own backend, replayed one airport at a time.
|
|
359
|
+
|
|
360
|
+
### Sources & webhooks
|
|
361
|
+
|
|
362
|
+
A **source** is a registered sensor/feed (radar, ADS-B, synthetic, …) identified by a bearer
|
|
363
|
+
token (`sourceToken`). Tag it with `airportId` at creation and that token **carries its airport
|
|
364
|
+
with it** from then on — anything that authenticates with it (WS `/ws/tracks/live`,
|
|
365
|
+
`/ws/tracks/replay`, tRPC, REST) is automatically scoped to that airport, with no need to keep
|
|
366
|
+
passing `airportId` on every call:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
const { sources } = await client.sources.list(); // all sources
|
|
370
|
+
const { sources: warrSources } = await client.sources.list({ airportId: "WARR" });
|
|
371
|
+
|
|
372
|
+
const created = await client.sources.create({
|
|
373
|
+
name: "WARR ASTERIX feed",
|
|
374
|
+
sourceType: "ASTERIX_CAT021",
|
|
375
|
+
airportId: "WARR", // ← ties this token to WARR
|
|
376
|
+
});
|
|
377
|
+
// { ok: true, data: { sourceId: "src_...", sourceToken: "srctk_...", airportId: "WARR" } }
|
|
378
|
+
|
|
379
|
+
// Later, a client connecting with that token doesn't need to pass airportId at all:
|
|
380
|
+
const scoped = new AeroSwarm({ endpoint, wsEndpoint, token: created.data.sourceToken });
|
|
381
|
+
await scoped.tracks.stream({}); // already scoped to WARR — the token carries it
|
|
382
|
+
|
|
383
|
+
await client.sources.health("src_abc");
|
|
384
|
+
|
|
385
|
+
// Reassign (or clear, with null) a source's airport after the fact — takes
|
|
386
|
+
// effect immediately, no need to wait out the token cache TTL.
|
|
387
|
+
await client.sources.update({ id: "src_abc", airportId: "CGK" });
|
|
388
|
+
await client.sources.update({ id: "src_abc", airportId: null });
|
|
389
|
+
|
|
390
|
+
await client.webhooks.create({ url: "https://ops.example/hook", events: ["alert"] });
|
|
391
|
+
const hooks = await client.webhooks.list();
|
|
392
|
+
await client.webhooks.delete({ id: hooks[0].id });
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
`airportId` is required on `sources.create` — a source is a sensor physically at one airport, so
|
|
396
|
+
a token that isn't tied to any airport is more footgun than feature. (Sources created before this
|
|
397
|
+
was enforced may still have `airportId: null`; `sources.update` can backfill one.) For access
|
|
398
|
+
that spans multiple airports, use the viewer/dashboard token instead — it's never auto-scoped
|
|
399
|
+
this way, regardless of any source's configuration, and always sees whatever `airportId` (or lack
|
|
400
|
+
thereof) it explicitly asks for.
|
|
401
|
+
|
|
402
|
+
### `TrackCache`
|
|
403
|
+
|
|
404
|
+
In-memory `Map`-backed track store with a typed emitter — the primitive the React store builds on:
|
|
405
|
+
|
|
406
|
+
`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.
|
|
407
|
+
|
|
408
|
+
### `AeroWsClient`
|
|
409
|
+
|
|
410
|
+
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.
|
|
411
|
+
|
|
412
|
+
## React usage
|
|
413
|
+
|
|
414
|
+
```tsx
|
|
415
|
+
import { AeroSwarmProvider, useAeroTracks } from "@aeroswarm/client/react";
|
|
416
|
+
|
|
417
|
+
function App() {
|
|
418
|
+
return (
|
|
419
|
+
<AeroSwarmProvider endpoint="http://…:8070" token={token}>
|
|
420
|
+
<LiveMap />
|
|
421
|
+
</AeroSwarmProvider>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function LiveMap() {
|
|
426
|
+
const { tracks, status, error, lastUpdateAt } = useAeroTracks({
|
|
427
|
+
bbox: [94, -11, 141, 6.5],
|
|
428
|
+
minConfidence: 0.5,
|
|
429
|
+
maxUpdateHz: 5,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
if (status !== "connected") return <p>{status}…</p>;
|
|
433
|
+
return <ul>{tracks.map((t) => <li key={t.trackId}>{t.callsign ?? t.icao24}</li>)}</ul>;
|
|
434
|
+
}
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### Hooks (`@aeroswarm/client/react`)
|
|
438
|
+
|
|
439
|
+
| Hook | Purpose |
|
|
440
|
+
|------|---------|
|
|
441
|
+
| `useAeroTracks(opts?)` (alias `useTracks`) | Live track list + connection state for a subscription |
|
|
442
|
+
| `useAeroTrack(trackId)` | Single track by id |
|
|
443
|
+
| `useAeroSnapshot(opts?)` | One-shot snapshot fetch |
|
|
444
|
+
| `useAeroStatus()` | Connection status only |
|
|
445
|
+
| `useTrackCount()` | Track count only (cheap selector) |
|
|
446
|
+
| `useSourceHealth(sourceId?)` | Source health/status |
|
|
447
|
+
| `useAlerts(opts?)` | Live alerts |
|
|
448
|
+
| `useRules(opts?)` / `useRule(id, opts?)` | Alert rules |
|
|
449
|
+
| `useWebhooks(opts?)` | Webhooks |
|
|
450
|
+
| `useReplayStatus(sessionId, opts?)` / `useReplayStop()` | Replay session control |
|
|
451
|
+
| `useLineage(eventId, opts?)` / `useLineageTrace(trackId, opts?)` | Event lineage/provenance |
|
|
452
|
+
| `useTrackHistory(options)` | **Map animation engine** — see below |
|
|
453
|
+
| `useAeroSwarmContext()` | Raw client from the provider |
|
|
454
|
+
|
|
455
|
+
### `useTrackStore` — Zustand store
|
|
456
|
+
|
|
457
|
+
Shared live-track state; feed it from your subscription callbacks and read it imperatively (maps) or via selectors (React):
|
|
458
|
+
|
|
459
|
+
| Member | Notes |
|
|
460
|
+
|--------|-------|
|
|
461
|
+
| `tracks: Map<string, LabeledTrack>` | Current state (each track carries `labelCategory` / `labelColor`) |
|
|
462
|
+
| `applyUpdate(track)` | One store write per event — prefer `applyBatch` at high rates |
|
|
463
|
+
| `applyBatch(updates, removedIds)` | **One Map clone per batch** — use this from buffered/throttled feeds |
|
|
464
|
+
| `applyRemoved({trackId})` / `applySnapshot(tracks)` | Snapshot is authoritative (replaces state — ghosts from a dropped connection are cleared) |
|
|
465
|
+
| `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 |
|
|
466
|
+
| `setStatus` / `setSourceStatus` / `setError` / `setDegraded` / `clear` | Connection metadata |
|
|
467
|
+
| Selectors | `selectAllTracks`, `selectRenderableTracks`, `selectTrack(id)`, `selectTrackCount`, `selectStatus`, `selectIsDegraded`, `selectLastUpdateAt`, `selectError` |
|
|
468
|
+
|
|
469
|
+
### REST errors: `AeroApiError`
|
|
470
|
+
|
|
471
|
+
Every failed REST call (`client.rest.*`, federated `tracks.history` and the like) throws an `AeroApiError` that keeps what the server said, instead of just `GET /x → 500`:
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
try { await client.tracks.history({ trackId, airportId: "WARR" }); }
|
|
475
|
+
catch (e) {
|
|
476
|
+
if (e instanceof AeroApiError) {
|
|
477
|
+
e.status; // 403
|
|
478
|
+
e.code; // "FORBIDDEN" — stable, for switch; a newer server may send a code you do not know
|
|
479
|
+
e.recoverable; // retrying later may help (rate limit, server busy)
|
|
480
|
+
e.message; // "Federated request /tracks/t1/history → 403: not your airport"
|
|
481
|
+
e.details; // whatever the server attached
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
```
|
|
485
|
+
Servers answer either in the standard format (`{ ok, data, meta }` / `{ ok: false, error: { code, message, recoverable } }`, see `@aeroswarm/core/api`) or in the older shape (bare data, `{ ok: false, error: "text" }`); both are read. Federated calls send `x-aero-envelope: 1` to get the standard format. `parseApiResponse(res)` and `readApiBody(res)` are exported for your own `fetch` calls.
|
|
486
|
+
|
|
487
|
+
### `tracks.trail` / `createTrailPoller` / `useTrackTrail` — the path an aircraft has flown
|
|
488
|
+
|
|
489
|
+
The airport's backend builds the trail (memory first, stored history as a fallback), so the client computes nothing: it gets a GeoJSON `FeatureCollection` with one `LineString` (`[lon, lat, altitude m]`) and a `Point` per time mark, each with `properties.label` already written in the time zone you ask for.
|
|
490
|
+
|
|
491
|
+
```ts
|
|
492
|
+
const { trail } = await client.tracks.trail({
|
|
493
|
+
trackId,
|
|
494
|
+
endpoint: "https://asmgcs.example/aero", // the airport backend (or an airportId with a historyEndpoint)
|
|
495
|
+
tz: "Asia/Jakarta", // labels such as "01-11-26 11:16"; default UTC
|
|
496
|
+
});
|
|
497
|
+
// whole flight by default. fromMs/toMs: a period. stepM: coarser line. limit: at most n points. labelEveryMs: marks (default 2 min)
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Keep it current while an aircraft is selected: the first request is the whole trail, after that only what is new is fetched (`sinceMs`), so polling every few seconds costs a few hundred bytes.
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
const poller = createTrailPoller(
|
|
504
|
+
(sinceMs) => client.tracks.trail({ trackId, endpoint, tz: "Asia/Jakarta", sinceMs }),
|
|
505
|
+
{ pollMs: 5000, onUpdate: (trail) => draw(trail) }, // draw the line, and each mark's properties.label
|
|
506
|
+
);
|
|
507
|
+
poller.stop(); // when it is deselected
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
React: `const { trail, loading, error } = useTrackTrail(selectedTrackId, { endpoint, tz: "Asia/Jakarta" })` (pass `null` while nothing is selected). A fixed period (`toMs`) is fetched once. A trail already shown stays when a request fails; polling stops for good when the server says the track is unknown (`NOT_FOUND`). `mergeTrail(prev, delta)` and `trailEnd(trail)` are exported if you manage the polling yourself. To end the line at the icon, draw one more segment from the trail's last point to `createTrackMotion().sample(trackId)`.
|
|
511
|
+
|
|
512
|
+
### `useTrackMotion` / `createTrackMotion` — smooth live tracks
|
|
513
|
+
|
|
514
|
+
Feeds update a track once a second (MLAT, ADS-B) or once per antenna sweep (radar, ~7.5 s). Drawn as they arrive, icons jump. `createTrackMotion` (no React needed) shows where the aircraft *probably is right now* and eases corrections in, like a flight tracker:
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
import { createTrackMotion } from "@aeroswarm/client";
|
|
518
|
+
|
|
519
|
+
const motion = createTrackMotion(); // all options optional
|
|
520
|
+
|
|
521
|
+
client.tracks.subscribe(opts, {
|
|
522
|
+
onSnapshot: (tracks, meta) => tracks.forEach((t) => motion.update(t, meta)),
|
|
523
|
+
onTrackUpdate: (t, meta) => motion.update(t, meta), // meta.serverTime keeps the clocks aligned
|
|
524
|
+
onTrackRemoved: ({ trackId }) => motion.remove(trackId),
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const stop = motion.start(({ changed }) => { // ~30 frames/s, only tracks that moved
|
|
528
|
+
for (const [id, s] of changed) layer.put(id, s.lat, s.lon, s.headingDeg);
|
|
529
|
+
});
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
React: `const motion = useTrackMotion({ onFrame })`, then call `motion.update(...)` from the subscription. Nothing here renders React.
|
|
533
|
+
|
|
534
|
+
What it does:
|
|
535
|
+
|
|
536
|
+
| | |
|
|
537
|
+
|---|---|
|
|
538
|
+
| **Dead reckoning** | From the last fix, speed and heading carry the position forward, up to `maxExtrapolateMs` (20 s in the air, 8 s on the surface). `lost` tracks freeze. |
|
|
539
|
+
| **Eased corrections** | A new fix does not snap the icon. The gap between where it was shown and where the fix says it should be becomes an offset that fades out (smoothstep over `blendMs`, 1.5 s). The icon keeps moving at its own speed meanwhile, so there is no stop-and-go at every update. A correction larger than `snapDistanceM` (5 km) is a glitch and jumps. |
|
|
540
|
+
| **Heading** | Eases along the shortest arc (`headingSmoothMs`, 0.8 s); keeps the last heading when the track stops. |
|
|
541
|
+
| **Server clock** | `lastSeenAt` is the server's clock. `meta.serverTime` of each message lets the engine learn the offset to the browser clock (the estimate with the least network delay wins), so a wrong browser clock does not stretch or shrink the prediction. |
|
|
542
|
+
| **Cheap frames** | Tracks that stand still, or whose picture changed by less than `epsilonM` (0.5 m) / 0.5°, are not reported; no timer runs while nothing moves; `frameHz` caps the rate (30). |
|
|
543
|
+
|
|
544
|
+
Options (`MotionOptions`): `blendMs`, `headingSmoothMs`, `maxExtrapolateMs`, `minMovingKt` (5), `snapDistanceM`, `frameHz`, `epsilonM`, `extrapolate` (`false` shows raw positions, for comparing sources), `isSurface`, `clock` (inject the replay clock to play back at another speed), `scheduler` (tests). `motion.sample(id)` gives the shown position at any time without touching frame state, e.g. to attach a trail's head to the icon.
|
|
545
|
+
|
|
546
|
+
### `useTrackHistory` — smooth map animation (deprecated: use `useTrackMotion`)
|
|
547
|
+
|
|
548
|
+
Maintains per-track position interpolation (tweening), dead-reckoning between updates, and bounded trail history, emitting animation frames outside React render:
|
|
549
|
+
|
|
550
|
+
```ts
|
|
551
|
+
const { processUpdate, processRemove } = useTrackHistory({
|
|
552
|
+
maxHistoryPoints: 500, // trail length cap per track
|
|
553
|
+
minDeltaDeg: 0.0001, // ignore sub-threshold jitter
|
|
554
|
+
maxJumpDeg: 5.0, // discard implausible teleports
|
|
555
|
+
onFrame: ({ currentPositions, history, isMoving }) => drawMarkers(...),
|
|
556
|
+
});
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
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.
|
|
560
|
+
|
|
561
|
+
## Frontend recipes
|
|
562
|
+
|
|
563
|
+
### Live map, end to end
|
|
564
|
+
|
|
565
|
+
The proven wiring (this is what the reference dashboard does):
|
|
566
|
+
|
|
567
|
+
```tsx
|
|
568
|
+
// 1. Subscription → store, batched (never setState per message)
|
|
569
|
+
const pending = new Map<string, CurrentTrack>();
|
|
570
|
+
const removed = new Set<string>();
|
|
571
|
+
client.tracks.subscribe(opts, {
|
|
572
|
+
onSnapshot: (ts) => useTrackStore.getState().applySnapshot(ts),
|
|
573
|
+
onTrackUpdate: (t) => { pending.set(t.trackId, t); removed.delete(t.trackId); },
|
|
574
|
+
onTrackRemoved: (ev) => { pending.delete(ev.trackId); removed.add(ev.trackId); },
|
|
575
|
+
});
|
|
576
|
+
setInterval(() => {
|
|
577
|
+
useTrackStore.getState().applyBatch([...pending.values()], [...removed]);
|
|
578
|
+
pending.clear(); removed.clear();
|
|
579
|
+
}, 250);
|
|
580
|
+
|
|
581
|
+
// 2. Ghost cleanup — ids churn on feed/replay restarts
|
|
582
|
+
setInterval(() => { useTrackStore.getState().pruneStale(60_000); }, 10_000);
|
|
583
|
+
|
|
584
|
+
// 3. Store → animation engine → map markers (imperative, no React re-render)
|
|
585
|
+
const { processUpdate, processRemove } = useTrackHistory({ onFrame: drawMarkers });
|
|
586
|
+
useTrackStore.subscribe((state, prev) => {
|
|
587
|
+
for (const [id, t] of state.tracks) if (prev.tracks.get(id) !== t) processUpdate(t);
|
|
588
|
+
for (const id of prev.tracks.keys()) if (!state.tracks.has(id)) processRemove(id);
|
|
589
|
+
});
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
### Replay in the same map
|
|
593
|
+
|
|
594
|
+
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.
|
|
595
|
+
|
|
596
|
+
### Assigned-route overlay
|
|
597
|
+
|
|
598
|
+
```ts
|
|
599
|
+
const { routes } = await client.routes.list({ airportId });
|
|
600
|
+
const { assignments } = await client.routes.assignments();
|
|
601
|
+
const route = routes.find(r => r.routeId === assignments.find(a => a.trackId === selectedId)?.routeId);
|
|
602
|
+
// draw route.points ([lat, lon][]) as a dashed LineString: points.map(([lat, lon]) => [lon, lat])
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
## Performance notes (important for dashboards)
|
|
606
|
+
|
|
607
|
+
- **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`.
|
|
608
|
+
- **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×.
|
|
609
|
+
- Drive map markers **imperatively** from `useTrackStore.subscribe(...)` + `useTrackHistory`, not through React props.
|
|
610
|
+
|
|
611
|
+
## Wire encoding
|
|
612
|
+
|
|
613
|
+
`json` (default) or `msgpack` (binary, lower bandwidth) — set once in `AeroSwarmOptions.encoding` or per-subscription.
|
|
614
|
+
|
|
615
|
+
## Environment variables (Vite frontends)
|
|
616
|
+
|
|
617
|
+
| Variable | Description |
|
|
618
|
+
|----------|-------------|
|
|
619
|
+
| `VITE_AEROSWARM_API_URL` | REST/tRPC/GraphQL base URL |
|
|
620
|
+
| `VITE_AEROSWARM_WS_URL` | WebSocket base URL |
|
|
621
|
+
| `VITE_AEROSWARM_API_KEY` | Viewer token |
|
|
622
|
+
|
|
623
|
+
These are baked into the bundle at build time (Vite), not read at runtime.
|
|
624
|
+
|
|
625
|
+
## Building & testing
|
|
626
|
+
|
|
627
|
+
```bash
|
|
628
|
+
pnpm run build # tsup → dist/ (index + /sdk + /react)
|
|
629
|
+
pnpm run typecheck
|
|
630
|
+
pnpm test
|
|
631
|
+
```
|