@squawk/adsbtop 0.2.0

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.
Files changed (74) hide show
  1. package/README.md +65 -0
  2. package/dist/aircraft-state.d.ts +77 -0
  3. package/dist/aircraft-state.d.ts.map +1 -0
  4. package/dist/aircraft-state.js +87 -0
  5. package/dist/app.d.ts +41 -0
  6. package/dist/app.d.ts.map +1 -0
  7. package/dist/app.js +166 -0
  8. package/dist/cli-args.d.ts +43 -0
  9. package/dist/cli-args.d.ts.map +1 -0
  10. package/dist/cli-args.js +150 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +17 -0
  14. package/dist/columns.d.ts +84 -0
  15. package/dist/columns.d.ts.map +1 -0
  16. package/dist/columns.js +203 -0
  17. package/dist/components/aircraft-table.d.ts +27 -0
  18. package/dist/components/aircraft-table.d.ts.map +1 -0
  19. package/dist/components/aircraft-table.js +83 -0
  20. package/dist/components/detail-view.d.ts +20 -0
  21. package/dist/components/detail-view.d.ts.map +1 -0
  22. package/dist/components/detail-view.js +14 -0
  23. package/dist/components/help-overlay.d.ts +4 -0
  24. package/dist/components/help-overlay.d.ts.map +1 -0
  25. package/dist/components/help-overlay.js +22 -0
  26. package/dist/components/hotkey-bar.d.ts +21 -0
  27. package/dist/components/hotkey-bar.d.ts.map +1 -0
  28. package/dist/components/hotkey-bar.js +28 -0
  29. package/dist/components/messages-panel.d.ts +27 -0
  30. package/dist/components/messages-panel.d.ts.map +1 -0
  31. package/dist/components/messages-panel.js +17 -0
  32. package/dist/components/search-bar.d.ts +19 -0
  33. package/dist/components/search-bar.d.ts.map +1 -0
  34. package/dist/components/search-bar.js +13 -0
  35. package/dist/components/status-header.d.ts +17 -0
  36. package/dist/components/status-header.d.ts.map +1 -0
  37. package/dist/components/status-header.js +14 -0
  38. package/dist/create-feed.d.ts +33 -0
  39. package/dist/create-feed.d.ts.map +1 -0
  40. package/dist/create-feed.js +40 -0
  41. package/dist/detail-fields.d.ts +23 -0
  42. package/dist/detail-fields.d.ts.map +1 -0
  43. package/dist/detail-fields.js +68 -0
  44. package/dist/format.d.ts +91 -0
  45. package/dist/format.d.ts.map +1 -0
  46. package/dist/format.js +138 -0
  47. package/dist/location.d.ts +20 -0
  48. package/dist/location.d.ts.map +1 -0
  49. package/dist/location.js +29 -0
  50. package/dist/registration-cache.d.ts +23 -0
  51. package/dist/registration-cache.d.ts.map +1 -0
  52. package/dist/registration-cache.js +24 -0
  53. package/dist/search.d.ts +27 -0
  54. package/dist/search.d.ts.map +1 -0
  55. package/dist/search.js +54 -0
  56. package/dist/selection.d.ts +25 -0
  57. package/dist/selection.d.ts.map +1 -0
  58. package/dist/selection.js +38 -0
  59. package/dist/sparkline.d.ts +26 -0
  60. package/dist/sparkline.d.ts.map +1 -0
  61. package/dist/sparkline.js +62 -0
  62. package/dist/status-line.d.ts +28 -0
  63. package/dist/status-line.d.ts.map +1 -0
  64. package/dist/status-line.js +18 -0
  65. package/dist/test-utils.d.ts +23 -0
  66. package/dist/test-utils.d.ts.map +1 -0
  67. package/dist/test-utils.js +35 -0
  68. package/dist/use-aircraft-feed.d.ts +28 -0
  69. package/dist/use-aircraft-feed.d.ts.map +1 -0
  70. package/dist/use-aircraft-feed.js +68 -0
  71. package/dist/use-icao-registry.d.ts +27 -0
  72. package/dist/use-icao-registry.d.ts.map +1 -0
  73. package/dist/use-icao-registry.js +37 -0
  74. package/package.json +58 -0
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # @squawk/adsbtop
2
+
3
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE.md) [![npm](https://img.shields.io/npm/v/@squawk/adsbtop)](https://www.npmjs.com/package/@squawk/adsbtop) ![TypeScript](https://img.shields.io/badge/TypeScript-blue?logo=typescript&logoColor=white)
4
+
5
+ A terminal dashboard for live ADS-B aircraft tracking, built on [`@squawk/adsb-feed`](../../packages/libs/adsb-feed). Connects to a local [dump1090-fa](https://github.com/flightaware/dump1090) station and renders tracked aircraft directly in your terminal.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install -g @squawk/adsbtop
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ adsbtop --source sbs --host 192.168.1.50
17
+ ```
18
+
19
+ ### Options
20
+
21
+ | Flag | Description | Default |
22
+ | ------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------- |
23
+ | `--source <source>` | Feed to connect to: `json`, `sbs`, or `beast` | `sbs` |
24
+ | `--host <host>` | dump1090-fa station hostname/IP | `localhost` |
25
+ | `--port <port>` | Port to connect to | `8080` (json), `30003` (sbs), `30005` (beast) |
26
+ | `--url <url>` | Full `aircraft.json` URL, overriding `--host`/`--port` (`--source json` only) | - |
27
+ | `--lat <lat>` | Receiver latitude in decimal degrees - enables the Dist/Brg columns (requires `--lon`) | - |
28
+ | `--lon <lon>` | Receiver longitude in decimal degrees - enables the Dist/Brg columns (requires `--lat`) | - |
29
+ | `-h`, `--help` | Show usage | - |
30
+
31
+ ### Hotkeys
32
+
33
+ | Key | Action |
34
+ | --------------- | ---------------------------------------------------------------------------- |
35
+ | `Up` / `Down` | Move the row cursor |
36
+ | `O` | Cycle the sort column (ICAO, callsign, altitude, ground speed, age) |
37
+ | `C` | Toggle compact columns, for narrow terminals |
38
+ | `P` | Pause/resume the table - the feed keeps running underneath |
39
+ | `S` | Search by ICAO hex, callsign, squawk, or N-number - jumps to the first match |
40
+ | `N` / `Shift+N` | Jump to the next/previous search match |
41
+ | `M` | Toggle the messages panel (recent new/update/lost events) |
42
+ | `V` | Toggle messages panel verbosity (new/lost only vs. every update) |
43
+ | `Enter` / `D` | Show the cursor row's full detail view |
44
+ | `H` | Toggle the help overlay |
45
+ | `Q` | Quit |
46
+
47
+ Aircraft squawking an emergency code (7500/7600/7700) render in bold red.
48
+
49
+ ### Detail view
50
+
51
+ Selecting a row and pressing `Enter` or `D` opens a full field dump for that aircraft, including barometric and geometric altitude, true track and magnetic heading, and indicated/true airspeed. Fields the active source doesn't populate show as `-`.
52
+
53
+ ### Messages panel
54
+
55
+ `M` toggles a live log of `aircraft:new`/`aircraft:update`/`aircraft:lost` events in a panel below the table. Defaults to showing only new/lost events, since `aircraft:update` fires far more often; `V` reveals every update too.
56
+
57
+ ### Registration lookup
58
+
59
+ The `Reg` column and the detail view's `Registration` field resolve each aircraft's ICAO hex to its N-number (and make/model/operator in the detail view) using the bundled FAA registry. `S`earch also matches against the N-number. The registry loads in the background after startup - rows show `-` for a few seconds until it's ready, then populate automatically as matches are found.
60
+
61
+ ### Location, distance, and bearing
62
+
63
+ Passing both `--lat` and `--lon` (either together or not at all) configures your receiver's own position and adds two columns: `Dist` (great-circle distance in nautical miles) and `Brg` (bearing in degrees true), computed from that position to each aircraft's current position. The detail view gets the same two fields (`Distance`/`Bearing`, shown right after `Position`). Without `--lat`/`--lon`, none of this appears at all - not the table columns, not the detail view rows. An aircraft with no position yet shows `-` until one arrives.
64
+
65
+ For `--source beast`, the same location also serves as the receiver position used to decode surface (on-ground) CPR positions, which otherwise can't resolve from paired frames alone.
@@ -0,0 +1,77 @@
1
+ import type { Aircraft } from '@squawk/types';
2
+ /** Which underlying feed event a {@link MessageLogEntry} records. */
3
+ export type MessageLogEntryType = 'new' | 'update' | 'lost';
4
+ /** One entry in the `[M]essages` panel's log, oldest first. */
5
+ export interface MessageLogEntry {
6
+ /** Monotonically increasing id, stable across log trimming - used as the React list key. */
7
+ id: number;
8
+ /** Which feed event this entry records. */
9
+ type: MessageLogEntryType;
10
+ /** 24-bit ICAO hex address the event concerns. */
11
+ icaoHex: string;
12
+ /** Callsign at the time of the event, if known. */
13
+ callsign: string | undefined;
14
+ /** Unix epoch ms the event was observed. */
15
+ at: number;
16
+ }
17
+ /**
18
+ * Accumulated view of a live `@squawk/adsb-feed` `AircraftFeed`'s event
19
+ * stream: currently tracked aircraft, two bounded logs of recent events for
20
+ * the `[M]essages` panel, plus lightweight message-activity bookkeeping for
21
+ * the status header.
22
+ */
23
+ export interface AircraftTableState {
24
+ /** Currently tracked aircraft, keyed by 24-bit ICAO hex address. */
25
+ aircraftByHex: Map<string, Aircraft>;
26
+ /** Total `aircraft:new`/`aircraft:update` events observed since the feed started. */
27
+ messageCount: number;
28
+ /** Unix epoch ms of the most recent `aircraft:new`/`aircraft:update` event, or undefined if none has arrived yet. */
29
+ lastMessageAt: number | undefined;
30
+ /** Every event type, oldest first, capped at {@link MAX_MESSAGE_LOG_ENTRIES} - backs the `[M]essages` panel's `all` verbosity. */
31
+ messageLog: MessageLogEntry[];
32
+ /**
33
+ * `new`/`lost` events only, oldest first, capped independently at
34
+ * {@link MAX_MESSAGE_LOG_ENTRIES} - backs the panel's default `newAndLost`
35
+ * verbosity. Kept as its own log rather than derived by filtering
36
+ * `messageLog`: `update` events fire far more often than `new`/`lost`, so
37
+ * a shared cap gets dominated by update volume and evicts a still-relevant
38
+ * new/lost entry within seconds of real traffic, even though nothing
39
+ * about it changed - toggling verbosity back to `newAndLost` would then
40
+ * show fewer entries than a moment before, for no reason visible to the
41
+ * user. A separate cap means `update` volume can never evict a
42
+ * `new`/`lost` entry.
43
+ */
44
+ newAndLostLog: MessageLogEntry[];
45
+ /** Next {@link MessageLogEntry.id} to assign - kept separate from either log's length so ids stay stable once old entries are trimmed off the front. */
46
+ nextLogId: number;
47
+ }
48
+ /** An update or removal observed on the underlying `AircraftFeed`. */
49
+ export type AircraftStateAction = {
50
+ type: 'message';
51
+ kind: 'new' | 'update';
52
+ aircraft: Aircraft;
53
+ at: number;
54
+ } | {
55
+ type: 'lost';
56
+ icaoHex: string;
57
+ callsign: string | undefined;
58
+ at: number;
59
+ };
60
+ /** Empty {@link AircraftTableState}, before any feed event has arrived. */
61
+ export declare const initialAircraftState: AircraftTableState;
62
+ /**
63
+ * Reducer accumulating {@link AircraftStateAction}s into an {@link AircraftTableState}.
64
+ * Pure and side-effect-free - the caller supplies `at` from `Date.now()` at
65
+ * dispatch time, rather than this function reading the clock itself, so it
66
+ * stays trivially testable with fixed timestamps. Every action appends a
67
+ * {@link MessageLogEntry}, including a `lost` action for an ICAO hex this
68
+ * state never tracked - that shouldn't happen against a real `AircraftFeed`,
69
+ * but the log's job is showing what events actually arrived, not judging
70
+ * whether they were expected.
71
+ *
72
+ * @param state - The current accumulated state.
73
+ * @param action - The event to apply.
74
+ * @returns The next state. Always a new `aircraftByHex` map when it changes, so React's `useReducer` sees a fresh reference.
75
+ */
76
+ export declare function aircraftStateReducer(state: AircraftTableState, action: AircraftStateAction): AircraftTableState;
77
+ //# sourceMappingURL=aircraft-state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aircraft-state.d.ts","sourceRoot":"","sources":["../src/aircraft-state.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,qEAAqE;AACrE,MAAM,MAAM,mBAAmB,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE5D,+DAA+D;AAC/D,MAAM,WAAW,eAAe;IAC9B,4FAA4F;IAC5F,EAAE,EAAE,MAAM,CAAC;IACX,2CAA2C;IAC3C,IAAI,EAAE,mBAAmB,CAAC;IAC1B,kDAAkD;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,4CAA4C;IAC5C,EAAE,EAAE,MAAM,CAAC;CACZ;AASD;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,oEAAoE;IACpE,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IACrC,qFAAqF;IACrF,YAAY,EAAE,MAAM,CAAC;IACrB,qHAAqH;IACrH,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,kIAAkI;IAClI,UAAU,EAAE,eAAe,EAAE,CAAC;IAC9B;;;;;;;;;;;OAWG;IACH,aAAa,EAAE,eAAe,EAAE,CAAC;IACjC,wJAAwJ;IACxJ,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,sEAAsE;AACtE,MAAM,MAAM,mBAAmB,GAC3B;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,KAAK,GAAG,QAAQ,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GAC3E;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC;AAEhF,2EAA2E;AAC3E,eAAO,MAAM,oBAAoB,EAAE,kBAOlC,CAAC;AAiBF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,kBAAkB,EACzB,MAAM,EAAE,mBAAmB,GAC1B,kBAAkB,CA4CpB"}
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Maximum {@link MessageLogEntry} rows retained. Oldest entries are dropped
3
+ * once exceeded, so the log stays a bounded "recent activity" view rather
4
+ * than growing unbounded for a long-running session.
5
+ */
6
+ const MAX_MESSAGE_LOG_ENTRIES = 200;
7
+ /** Empty {@link AircraftTableState}, before any feed event has arrived. */
8
+ export const initialAircraftState = {
9
+ aircraftByHex: new Map(),
10
+ messageCount: 0,
11
+ lastMessageAt: undefined,
12
+ messageLog: [],
13
+ newAndLostLog: [],
14
+ nextLogId: 0,
15
+ };
16
+ /**
17
+ * Appends `entry` to `log`, dropping the oldest entry once
18
+ * {@link MAX_MESSAGE_LOG_ENTRIES} is exceeded.
19
+ *
20
+ * @param log - The current log, oldest first.
21
+ * @param entry - The entry to append.
22
+ * @returns A new array with `entry` appended, trimmed to the max length.
23
+ */
24
+ function appendLogEntry(log, entry) {
25
+ const next = [...log, entry];
26
+ return next.length > MAX_MESSAGE_LOG_ENTRIES
27
+ ? next.slice(next.length - MAX_MESSAGE_LOG_ENTRIES)
28
+ : next;
29
+ }
30
+ /**
31
+ * Reducer accumulating {@link AircraftStateAction}s into an {@link AircraftTableState}.
32
+ * Pure and side-effect-free - the caller supplies `at` from `Date.now()` at
33
+ * dispatch time, rather than this function reading the clock itself, so it
34
+ * stays trivially testable with fixed timestamps. Every action appends a
35
+ * {@link MessageLogEntry}, including a `lost` action for an ICAO hex this
36
+ * state never tracked - that shouldn't happen against a real `AircraftFeed`,
37
+ * but the log's job is showing what events actually arrived, not judging
38
+ * whether they were expected.
39
+ *
40
+ * @param state - The current accumulated state.
41
+ * @param action - The event to apply.
42
+ * @returns The next state. Always a new `aircraftByHex` map when it changes, so React's `useReducer` sees a fresh reference.
43
+ */
44
+ export function aircraftStateReducer(state, action) {
45
+ switch (action.type) {
46
+ case 'message': {
47
+ const aircraftByHex = new Map(state.aircraftByHex);
48
+ aircraftByHex.set(action.aircraft.icaoHex, action.aircraft);
49
+ const logEntry = {
50
+ id: state.nextLogId,
51
+ type: action.kind,
52
+ icaoHex: action.aircraft.icaoHex,
53
+ callsign: action.aircraft.callsign,
54
+ at: action.at,
55
+ };
56
+ const newAndLostLog = action.kind === 'new' ? appendLogEntry(state.newAndLostLog, logEntry) : state.newAndLostLog;
57
+ return {
58
+ aircraftByHex,
59
+ messageCount: state.messageCount + 1,
60
+ lastMessageAt: action.at,
61
+ messageLog: appendLogEntry(state.messageLog, logEntry),
62
+ newAndLostLog,
63
+ nextLogId: state.nextLogId + 1,
64
+ };
65
+ }
66
+ case 'lost': {
67
+ const logEntry = {
68
+ id: state.nextLogId,
69
+ type: 'lost',
70
+ icaoHex: action.icaoHex,
71
+ callsign: action.callsign,
72
+ at: action.at,
73
+ };
74
+ const messageLog = appendLogEntry(state.messageLog, logEntry);
75
+ const newAndLostLog = appendLogEntry(state.newAndLostLog, logEntry);
76
+ const nextLogId = state.nextLogId + 1;
77
+ if (!state.aircraftByHex.has(action.icaoHex)) {
78
+ return { ...state, messageLog, newAndLostLog, nextLogId };
79
+ }
80
+ const aircraftByHex = new Map(state.aircraftByHex);
81
+ aircraftByHex.delete(action.icaoHex);
82
+ return { ...state, aircraftByHex, messageLog, newAndLostLog, nextLogId };
83
+ }
84
+ default:
85
+ return state;
86
+ }
87
+ }
package/dist/app.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { ReactElement } from 'react';
2
+ import type { AircraftFeed } from '@squawk/adsb-feed';
3
+ import type { Coordinates } from '@squawk/types';
4
+ import type { FeedSource } from './cli-args.js';
5
+ import type { RegistryDataLoader } from './use-icao-registry.js';
6
+ /** Props for {@link App}. */
7
+ export interface AppProps {
8
+ /** The live feed to subscribe to - already constructed for the CLI's selected source. */
9
+ feed: AircraftFeed;
10
+ /** Feed source in use, for the status header. */
11
+ source: FeedSource;
12
+ /** Station host, for the status header. */
13
+ host: string;
14
+ /** Station port, for the status header. */
15
+ port: number;
16
+ /** Loader for the bundled registry dataset used for registration enrichment. Defaults to a real dynamic import of `@squawk/icao-registry-data`; overridable in tests. */
17
+ registryDataLoader?: RegistryDataLoader;
18
+ /** Configured receiver location (`--lat`/`--lon`), if any. Enables the table's Dist/Brg columns when set. */
19
+ location: Coordinates | undefined;
20
+ }
21
+ /**
22
+ * adsbtop's root component: subscribes to the feed, owns display state
23
+ * (pause, compact columns, sort, cursor, search, messages, and which main
24
+ * panel is showing), wires the hotkey bar, and renders the status header,
25
+ * main panel, optional messages panel, optional search prompt, and hotkey
26
+ * bar.
27
+ *
28
+ * `[P]ause` freezes the table's displayed rows (the feed keeps running
29
+ * underneath - resuming immediately jumps to current state, doesn't replay
30
+ * what was missed). Implemented as a React "adjust state during render"
31
+ * pattern (comparing the live feed's aircraft-array reference against what's
32
+ * displayed, copying over only while not paused), not a `useEffect`, since
33
+ * an effect-based version of this exact pattern trips
34
+ * `react-hooks/set-state-in-effect` and cascades an extra render. The cursor
35
+ * row's auto-selection uses the same render-time-adjustment pattern for the
36
+ * same reason.
37
+ *
38
+ * @param props - The feed to display and its connection details.
39
+ */
40
+ export declare function App(props: AppProps): ReactElement;
41
+ //# sourceMappingURL=app.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAG1C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,KAAK,EAAY,WAAW,EAAE,MAAM,eAAe,CAAC;AAE3D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAiBhD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAUjE,6BAA6B;AAC7B,MAAM,WAAW,QAAQ;IACvB,yFAAyF;IACzF,IAAI,EAAE,YAAY,CAAC;IACnB,iDAAiD;IACjD,MAAM,EAAE,UAAU,CAAC;IACnB,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,yKAAyK;IACzK,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACxC,6GAA6G;IAC7G,QAAQ,EAAE,WAAW,GAAG,SAAS,CAAC;CACnC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,GAAG,CAAC,KAAK,EAAE,QAAQ,GAAG,YAAY,CAgMjD"}
package/dist/app.js ADDED
@@ -0,0 +1,166 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, useApp, useInput } from 'ink';
3
+ import { useEffect, useMemo, useState } from 'react';
4
+ import { nextSortKey, sortAircraft, visibleColumns } from './columns.js';
5
+ import { AircraftTable } from './components/aircraft-table.js';
6
+ import { DetailView } from './components/detail-view.js';
7
+ import { HelpOverlay } from './components/help-overlay.js';
8
+ import { HotkeyBar } from './components/hotkey-bar.js';
9
+ import { MessagesPanel } from './components/messages-panel.js';
10
+ import { SearchBar } from './components/search-bar.js';
11
+ import { StatusHeader } from './components/status-header.js';
12
+ import { enrichAircraftList } from './registration-cache.js';
13
+ import { findMatchIcaoHex } from './search.js';
14
+ import { moveSelection } from './selection.js';
15
+ import { useAircraftFeed } from './use-aircraft-feed.js';
16
+ import { useIcaoRegistry } from './use-icao-registry.js';
17
+ /** How often the age column and status-header "last update" text refresh. */
18
+ const CLOCK_TICK_MS = 1000;
19
+ /** Sort key adsbtop starts with. */
20
+ const INITIAL_SORT_KEY = 'icaoHex';
21
+ /**
22
+ * adsbtop's root component: subscribes to the feed, owns display state
23
+ * (pause, compact columns, sort, cursor, search, messages, and which main
24
+ * panel is showing), wires the hotkey bar, and renders the status header,
25
+ * main panel, optional messages panel, optional search prompt, and hotkey
26
+ * bar.
27
+ *
28
+ * `[P]ause` freezes the table's displayed rows (the feed keeps running
29
+ * underneath - resuming immediately jumps to current state, doesn't replay
30
+ * what was missed). Implemented as a React "adjust state during render"
31
+ * pattern (comparing the live feed's aircraft-array reference against what's
32
+ * displayed, copying over only while not paused), not a `useEffect`, since
33
+ * an effect-based version of this exact pattern trips
34
+ * `react-hooks/set-state-in-effect` and cascades an extra render. The cursor
35
+ * row's auto-selection uses the same render-time-adjustment pattern for the
36
+ * same reason.
37
+ *
38
+ * @param props - The feed to display and its connection details.
39
+ */
40
+ export function App(props) {
41
+ const { exit } = useApp();
42
+ const view = useAircraftFeed(props.feed);
43
+ const registry = useIcaoRegistry(props.registryDataLoader);
44
+ const [registrationCache] = useState(() => new Map());
45
+ const enrichedAircraft = useMemo(() => enrichAircraftList(view.aircraft, registry, registrationCache), [view.aircraft, registry, registrationCache]);
46
+ const [paused, setPaused] = useState(false);
47
+ const [compact, setCompact] = useState(false);
48
+ const [panel, setPanel] = useState('table');
49
+ const [sortKey, setSortKey] = useState(INITIAL_SORT_KEY);
50
+ const [now, setNow] = useState(() => Date.now());
51
+ const [displayedAircraft, setDisplayedAircraft] = useState(enrichedAircraft);
52
+ const [selectedIcaoHex, setSelectedIcaoHex] = useState(undefined);
53
+ const [showMessages, setShowMessages] = useState(false);
54
+ const [messageVerbosity, setMessageVerbosity] = useState('newAndLost');
55
+ const [searching, setSearching] = useState(false);
56
+ const [searchQuery, setSearchQuery] = useState('');
57
+ const [submittedSearchQuery, setSubmittedSearchQuery] = useState(undefined);
58
+ useEffect(() => {
59
+ const handle = setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);
60
+ return () => clearInterval(handle);
61
+ }, []);
62
+ if (!paused && enrichedAircraft !== displayedAircraft) {
63
+ setDisplayedAircraft(enrichedAircraft);
64
+ }
65
+ const sortedAircraft = useMemo(() => sortAircraft(displayedAircraft, sortKey), [displayedAircraft, sortKey]);
66
+ const columns = useMemo(() => visibleColumns(compact, props.location), [compact, props.location]);
67
+ const firstAircraft = sortedAircraft[0];
68
+ if (selectedIcaoHex === undefined && firstAircraft !== undefined) {
69
+ setSelectedIcaoHex(firstAircraft.icaoHex);
70
+ }
71
+ const selectedAircraft = sortedAircraft.find((aircraft) => aircraft.icaoHex === selectedIcaoHex);
72
+ function handleSearchSubmit(query) {
73
+ setSearching(false);
74
+ const trimmed = query.trim();
75
+ if (trimmed === '') {
76
+ setSubmittedSearchQuery(undefined);
77
+ return;
78
+ }
79
+ setSubmittedSearchQuery(trimmed);
80
+ const match = findMatchIcaoHex(sortedAircraft, trimmed, selectedIcaoHex, 1);
81
+ if (match !== undefined) {
82
+ setSelectedIcaoHex(match);
83
+ }
84
+ }
85
+ useInput((input, key) => {
86
+ if (input === 'q' || input === 'Q') {
87
+ exit();
88
+ return;
89
+ }
90
+ if (key.escape) {
91
+ if (panel !== 'table') {
92
+ setPanel('table');
93
+ }
94
+ return;
95
+ }
96
+ if (key.upArrow) {
97
+ setSelectedIcaoHex((prev) => moveSelection(sortedAircraft, prev, -1));
98
+ return;
99
+ }
100
+ if (key.downArrow) {
101
+ setSelectedIcaoHex((prev) => moveSelection(sortedAircraft, prev, 1));
102
+ return;
103
+ }
104
+ if (key.return) {
105
+ if (selectedAircraft !== undefined) {
106
+ setPanel((prev) => (prev === 'detail' ? 'table' : 'detail'));
107
+ }
108
+ return;
109
+ }
110
+ switch (input) {
111
+ case 'p':
112
+ case 'P':
113
+ setPaused((prev) => !prev);
114
+ break;
115
+ case 'c':
116
+ case 'C':
117
+ setCompact((prev) => !prev);
118
+ break;
119
+ case 'h':
120
+ case 'H':
121
+ setPanel((prev) => (prev === 'help' ? 'table' : 'help'));
122
+ break;
123
+ case 'o':
124
+ case 'O':
125
+ setSortKey((prev) => nextSortKey(prev));
126
+ break;
127
+ case 'd':
128
+ case 'D':
129
+ if (selectedAircraft !== undefined) {
130
+ setPanel((prev) => (prev === 'detail' ? 'table' : 'detail'));
131
+ }
132
+ break;
133
+ case 's':
134
+ case 'S':
135
+ setSearching(true);
136
+ setSearchQuery('');
137
+ break;
138
+ case 'm':
139
+ case 'M':
140
+ setShowMessages((prev) => !prev);
141
+ break;
142
+ case 'v':
143
+ case 'V':
144
+ setMessageVerbosity((prev) => (prev === 'all' ? 'newAndLost' : 'all'));
145
+ break;
146
+ case 'n':
147
+ if (submittedSearchQuery !== undefined) {
148
+ setSelectedIcaoHex((prev) => findMatchIcaoHex(sortedAircraft, submittedSearchQuery, prev, 1) ?? prev);
149
+ }
150
+ break;
151
+ case 'N':
152
+ if (submittedSearchQuery !== undefined) {
153
+ setSelectedIcaoHex((prev) => findMatchIcaoHex(sortedAircraft, submittedSearchQuery, prev, -1) ?? prev);
154
+ }
155
+ break;
156
+ default:
157
+ break;
158
+ }
159
+ }, { isActive: !searching });
160
+ useInput((_input, key) => {
161
+ if (key.escape) {
162
+ setSearching(false);
163
+ }
164
+ }, { isActive: searching });
165
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusHeader, { source: props.source, host: props.host, port: props.port, aircraftCount: view.aircraft.length, messageRatePerSec: view.messageRatePerSec, lastMessageAt: view.lastMessageAt, nowMs: now, paused: paused }), panel === 'help' ? (_jsx(HelpOverlay, {})) : panel === 'detail' && selectedAircraft !== undefined ? (_jsx(DetailView, { aircraft: selectedAircraft, nowMs: now, location: props.location })) : (_jsx(AircraftTable, { aircraft: sortedAircraft, columns: columns, nowMs: now, sortKey: sortKey, selectedIcaoHex: selectedIcaoHex })), showMessages ? (_jsx(MessagesPanel, { entries: messageVerbosity === 'newAndLost' ? view.newAndLostLog : view.messageLog, verbosity: messageVerbosity })) : undefined, searching ? (_jsx(SearchBar, { query: searchQuery, onChange: setSearchQuery, onSubmit: handleSearchSubmit })) : undefined, _jsx(HotkeyBar, { paused: paused, showMessages: showMessages, hasActiveSearch: submittedSearchQuery !== undefined })] }));
166
+ }
@@ -0,0 +1,43 @@
1
+ import type { Coordinates } from '@squawk/types';
2
+ /** Which dump1090-fa output adsbtop connects to. */
3
+ export type FeedSource = 'json' | 'sbs' | 'beast';
4
+ /** Default TCP/HTTP port per {@link FeedSource}, matching dump1090-fa's own defaults. */
5
+ export declare const DEFAULT_PORT_BY_SOURCE: Record<FeedSource, number>;
6
+ /** Parsed and validated CLI options for the `adsbtop` command. */
7
+ export interface CliOptions {
8
+ /** Whether `--help` was passed. When true, every other field is a placeholder and the caller should print usage and exit without starting a feed. */
9
+ help: boolean;
10
+ /** Which dump1090-fa output to connect to. */
11
+ source: FeedSource;
12
+ /** Hostname or IP address of the dump1090-fa station. */
13
+ host: string;
14
+ /** TCP/HTTP port to connect to. Defaults to {@link DEFAULT_PORT_BY_SOURCE} for the selected source. */
15
+ port: number;
16
+ /** Full override URL for the `json` source's `aircraft.json` endpoint. Undefined for `sbs`/`beast`, and undefined for `json` unless `--url` was passed explicitly. */
17
+ url: string | undefined;
18
+ /**
19
+ * The receiving station's own position, from `--lat`/`--lon`. When set,
20
+ * the table's Dist/Brg columns compute distance and bearing from this
21
+ * point to each aircraft, and for `--source beast` the same value also
22
+ * feeds `BeastFeedOptions.receiverPosition` for surface CPR decoding.
23
+ * Undefined unless both `--lat` and `--lon` were passed.
24
+ */
25
+ location: Coordinates | undefined;
26
+ }
27
+ /** A `parseCliArgs` failure: the reason `argv` could not be turned into {@link CliOptions}. */
28
+ export interface CliArgsError {
29
+ /** Human-readable message describing what was wrong with `argv`, suitable for printing directly to stderr. */
30
+ message: string;
31
+ }
32
+ /** Usage text printed for `--help`/`-h`, and on an argument error. */
33
+ export declare const USAGE: string;
34
+ /**
35
+ * Parses and validates `adsbtop`'s command-line arguments. Returns a result
36
+ * type rather than throwing, so callers (and tests) can handle a bad
37
+ * argument the same way as any other expected outcome.
38
+ *
39
+ * @param argv - Argument list, excluding the `node`/script entries (i.e. `process.argv.slice(2)`).
40
+ * @returns The parsed options, or a {@link CliArgsError} describing the first problem found.
41
+ */
42
+ export declare function parseCliArgs(argv: string[]): CliOptions | CliArgsError;
43
+ //# sourceMappingURL=cli-args.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-args.d.ts","sourceRoot":"","sources":["../src/cli-args.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEjD,oDAAoD;AACpD,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,CAAC;AAElD,yFAAyF;AACzF,eAAO,MAAM,sBAAsB,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAI7D,CAAC;AAEF,kEAAkE;AAClE,MAAM,WAAW,UAAU;IACzB,qJAAqJ;IACrJ,IAAI,EAAE,OAAO,CAAC;IACd,8CAA8C;IAC9C,MAAM,EAAE,UAAU,CAAC;IACnB,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,uGAAuG;IACvG,IAAI,EAAE,MAAM,CAAC;IACb,sKAAsK;IACtK,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;IACxB;;;;;;OAMG;IACH,QAAQ,EAAE,WAAW,GAAG,SAAS,CAAC;CACnC;AAED,+FAA+F;AAC/F,MAAM,WAAW,YAAY;IAC3B,8GAA8G;IAC9G,OAAO,EAAE,MAAM,CAAC;CACjB;AAuCD,sEAAsE;AACtE,eAAO,MAAM,KAAK,QAUjB,CAAC;AAiCF;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,GAAG,YAAY,CAiEtE"}
@@ -0,0 +1,150 @@
1
+ import { parseArgs } from 'node:util';
2
+ /** Default TCP/HTTP port per {@link FeedSource}, matching dump1090-fa's own defaults. */
3
+ export const DEFAULT_PORT_BY_SOURCE = {
4
+ json: 8080,
5
+ sbs: 30003,
6
+ beast: 30005,
7
+ };
8
+ const DEFAULT_HOST = 'localhost';
9
+ const DEFAULT_SOURCE = 'sbs';
10
+ const NEGATIVE_NUMBER = /^-\d+(\.\d+)?$/;
11
+ function isFeedSource(value) {
12
+ return value === 'json' || value === 'sbs' || value === 'beast';
13
+ }
14
+ /**
15
+ * Node's `parseArgs` rejects `--lon -73.7781` as an ambiguous option value
16
+ * (a bare token starting with `-` looks like another flag), requiring the
17
+ * awkward `--lon=-73.7781` form instead - a real problem here since most
18
+ * real-world longitudes (and some latitudes) are negative. Rewrites a
19
+ * `--lat`/`--lon` immediately followed by a bare negative number into the
20
+ * `=` form before parsing, so the natural `--lon -73.7781` spelling works.
21
+ *
22
+ * @param argv - Raw argument list, as passed to {@link parseCliArgs}.
23
+ * @returns `argv` with negative `--lat`/`--lon` values rewritten to `--flag=value` form.
24
+ */
25
+ function normalizeNegativeLocationArgs(argv) {
26
+ const normalized = [];
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const arg = argv[i];
29
+ if (arg === undefined) {
30
+ continue;
31
+ }
32
+ const next = argv[i + 1];
33
+ if ((arg === '--lat' || arg === '--lon') && next !== undefined && NEGATIVE_NUMBER.test(next)) {
34
+ normalized.push(`${arg}=${next}`);
35
+ i++;
36
+ }
37
+ else {
38
+ normalized.push(arg);
39
+ }
40
+ }
41
+ return normalized;
42
+ }
43
+ /** Usage text printed for `--help`/`-h`, and on an argument error. */
44
+ export const USAGE = `Usage: adsbtop [options]
45
+
46
+ Options:
47
+ --source <json|sbs|beast> Feed to connect to (default: ${DEFAULT_SOURCE})
48
+ --host <host> dump1090-fa station hostname/IP (default: ${DEFAULT_HOST})
49
+ --port <port> Port to connect to (default: 8080 json, 30003 sbs, 30005 beast)
50
+ --url <url> Full aircraft.json URL, overriding --host/--port (source=json only)
51
+ --lat <lat> Receiver latitude in decimal degrees - enables Dist/Brg columns (requires --lon)
52
+ --lon <lon> Receiver longitude in decimal degrees - enables Dist/Brg columns (requires --lat)
53
+ -h, --help Show this help message
54
+ `;
55
+ /**
56
+ * Parses and validates `--lat`/`--lon` into a {@link Coordinates}. Both must
57
+ * be given together; either alone is an error. Returns `{ location:
58
+ * undefined }` when neither was passed, since omitting location entirely is
59
+ * valid and distinct from an invalid one.
60
+ *
61
+ * @param rawLat - Raw `--lat` value, if passed.
62
+ * @param rawLon - Raw `--lon` value, if passed.
63
+ * @returns The parsed location (possibly undefined), or a {@link CliArgsError}.
64
+ */
65
+ function parseLocation(rawLat, rawLon) {
66
+ if (rawLat === undefined && rawLon === undefined) {
67
+ return { location: undefined };
68
+ }
69
+ if (rawLat === undefined || rawLon === undefined) {
70
+ return { message: '--lat and --lon must be provided together.' };
71
+ }
72
+ const lat = Number(rawLat);
73
+ if (!Number.isFinite(lat) || lat < -90 || lat > 90) {
74
+ return { message: `Invalid --lat "${rawLat}" - expected a number between -90 and 90.` };
75
+ }
76
+ const lon = Number(rawLon);
77
+ if (!Number.isFinite(lon) || lon < -180 || lon > 180) {
78
+ return { message: `Invalid --lon "${rawLon}" - expected a number between -180 and 180.` };
79
+ }
80
+ return { location: { lat, lon } };
81
+ }
82
+ /**
83
+ * Parses and validates `adsbtop`'s command-line arguments. Returns a result
84
+ * type rather than throwing, so callers (and tests) can handle a bad
85
+ * argument the same way as any other expected outcome.
86
+ *
87
+ * @param argv - Argument list, excluding the `node`/script entries (i.e. `process.argv.slice(2)`).
88
+ * @returns The parsed options, or a {@link CliArgsError} describing the first problem found.
89
+ */
90
+ export function parseCliArgs(argv) {
91
+ let parsed;
92
+ try {
93
+ parsed = parseArgs({
94
+ args: normalizeNegativeLocationArgs(argv),
95
+ options: {
96
+ source: { type: 'string', default: DEFAULT_SOURCE },
97
+ host: { type: 'string', default: DEFAULT_HOST },
98
+ port: { type: 'string' },
99
+ url: { type: 'string' },
100
+ lat: { type: 'string' },
101
+ lon: { type: 'string' },
102
+ help: { type: 'boolean', short: 'h', default: false },
103
+ },
104
+ strict: true,
105
+ });
106
+ }
107
+ catch (error) {
108
+ return { message: error instanceof Error ? error.message : String(error) };
109
+ }
110
+ const { values } = parsed;
111
+ if (values.help === true) {
112
+ return {
113
+ help: true,
114
+ source: DEFAULT_SOURCE,
115
+ host: DEFAULT_HOST,
116
+ port: DEFAULT_PORT_BY_SOURCE[DEFAULT_SOURCE],
117
+ url: undefined,
118
+ location: undefined,
119
+ };
120
+ }
121
+ const parsedLocation = parseLocation(values.lat, values.lon);
122
+ if ('message' in parsedLocation) {
123
+ return parsedLocation;
124
+ }
125
+ const rawSource = values.source ?? DEFAULT_SOURCE;
126
+ if (!isFeedSource(rawSource)) {
127
+ return { message: `Invalid --source "${rawSource}" - expected json, sbs, or beast.` };
128
+ }
129
+ let port = DEFAULT_PORT_BY_SOURCE[rawSource];
130
+ if (values.port !== undefined) {
131
+ const parsedPort = Number(values.port);
132
+ if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) {
133
+ return {
134
+ message: `Invalid --port "${values.port}" - expected an integer between 1 and 65535.`,
135
+ };
136
+ }
137
+ port = parsedPort;
138
+ }
139
+ if (values.url !== undefined && rawSource !== 'json') {
140
+ return { message: '--url is only valid with --source json.' };
141
+ }
142
+ return {
143
+ help: false,
144
+ source: rawSource,
145
+ host: values.host ?? DEFAULT_HOST,
146
+ port,
147
+ url: values.url,
148
+ location: parsedLocation.location,
149
+ };
150
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.tsx"],"names":[],"mappings":""}